diff --git a/CHANGELOG.md b/CHANGELOG.md index 73bfb23b649..808e65b6d65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,8 @@ and this project adheres to [#5202](https://github.com/OpenFn/lightning/pull/5202) - Index to improve the performance of the dataclip deletion query. [#5226](https://github.com/OpenFn/lightning/issues/5226) +- The Repository and Branch dropdowns on the GitHub sync form now have a text + filter. [#5235](https://github.com/OpenFn/lightning/pull/5235) ### Changed @@ -111,6 +113,12 @@ and this project adheres to donuts show the success rate and the failure count in their middle. Triage rows show each failure's state and adaptor, and the step bars show each step's adaptor. [#5229](https://github.com/OpenFn/lightning/pull/5229) +- The project settings "Sync to GitHub" tab is now "Sync", and holds the project + export and a new CLI section with commands for pulling, deploying and running + a project locally. New GitHub connections default to the v2 `openfn.yaml` + format, with a switch to use the legacy format, and the connected panel now + carries its own title, verification banner and a "Remove integration" button. + [#5235](https://github.com/OpenFn/lightning/pull/5235) ### Removed diff --git a/assets/js/hooks/SearchableSelect.ts b/assets/js/hooks/SearchableSelect.ts new file mode 100644 index 00000000000..adbea63e8ef --- /dev/null +++ b/assets/js/hooks/SearchableSelect.ts @@ -0,0 +1,59 @@ +import type { PhoenixHook } from './PhoenixHook'; + +type SearchableSelect = PhoenixHook<{ + search: HTMLInputElement | null; + applyFilter(): void; +}>; + +/** + * Adds a text filter to the `custom-select` input (`searchable` attr). + * + * Options are filtered client-side by hiding non-matching `li[role=option]` + * elements. The filter is re-applied after every LiveView patch so a re-render + * of the options doesn't leave the list out of sync with the search box. + */ +const SearchableSelect = { + mounted() { + this.search = this.el.querySelector( + 'input[data-select-search]' + ); + + if (!this.search) return; + + this.search.addEventListener('input', () => this.applyFilter()); + + this.search.addEventListener('keydown', e => { + // Don't submit the surrounding form. + if (e.key === 'Enter') { + e.preventDefault(); + this.el + .querySelector('li[role=option]:not([hidden])') + ?.click(); + } + }); + + // The wrapper's phx-click opens the list; focus the search box and start + // with a clean filter whenever the toggle button is clicked. + this.el.querySelector('button')?.addEventListener('click', () => { + requestAnimationFrame(() => { + if (!this.search) return; + this.search.value = ''; + this.applyFilter(); + this.search.focus(); + }); + }); + }, + updated() { + this.applyFilter(); + }, + applyFilter() { + const term = (this.search?.value ?? '').trim().toLowerCase(); + + this.el.querySelectorAll('li[role=option]').forEach(option => { + const label = (option.textContent ?? '').toLowerCase(); + option.hidden = term !== '' && !label.includes(term); + }); + }, +} as SearchableSelect; + +export { SearchableSelect }; diff --git a/assets/js/hooks/index.ts b/assets/js/hooks/index.ts index 67f54077889..4bb524d0c58 100644 --- a/assets/js/hooks/index.ts +++ b/assets/js/hooks/index.ts @@ -25,6 +25,7 @@ import { } from './KeyHandlers'; import LogLineHighlight from './LogLineHighlight'; import type { PhoenixHook } from './PhoenixHook'; +import { SearchableSelect } from './SearchableSelect'; import { TabbedContainer } from './TabbedContainer'; export { @@ -34,6 +35,7 @@ export { TemplateToWorkflow, ElapsedIndicator, TabbedContainer, + SearchableSelect, SaveViaCtrlS, InspectorSaveViaCtrlS, OpenSyncModalViaCtrlShiftS, diff --git a/assets/test/hooks/SearchableSelect.test.ts b/assets/test/hooks/SearchableSelect.test.ts new file mode 100644 index 00000000000..5217dbaaa7a --- /dev/null +++ b/assets/test/hooks/SearchableSelect.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { SearchableSelect } from '#/hooks/SearchableSelect'; + +const OPTIONS = ['Select a repository', 'openfn/lightning', 'openfn/kit']; + +function setup() { + document.body.innerHTML = ` +
+ +
    +
  • + ${OPTIONS.map(o => `
  • ${o}
  • `).join('')} +
+
+ `; + + const el = document.getElementById('repo-searchable') as HTMLElement; + const search = el.querySelector('input')!; + const hook = Object.assign(Object.create(SearchableSelect), { el }); + hook.mounted(); + + const options = () => + Array.from(el.querySelectorAll('li[role=option]')); + const visible = () => + options() + .filter(o => !o.hidden) + .map(o => o.textContent); + const type = (value: string) => { + search.value = value; + search.dispatchEvent(new Event('input')); + }; + + return { el, search, hook, options, visible, type }; +} + +const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)); + +describe('SearchableSelect', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('shows every option before anything is typed', () => { + const { visible } = setup(); + + expect(visible()).toEqual(OPTIONS); + }); + + it('hides options that do not match, ignoring case', () => { + const { visible, type } = setup(); + + type('LIGHT'); + + expect(visible()).toEqual(['openfn/lightning']); + }); + + it('shows everything again when the filter is cleared', () => { + const { visible, type } = setup(); + + type('kit'); + type(''); + + expect(visible()).toEqual(OPTIONS); + }); + + it('never hides the search row itself', () => { + const { el, type } = setup(); + + type('nothing matches this'); + + expect(el.querySelector('li[role=presentation]')!.hidden).toBe( + false + ); + }); + + it('reapplies the filter after a LiveView patch', () => { + const { el, hook, visible, type } = setup(); + + type('kit'); + + // a patch re-renders the list with a new option and no hidden flags + const added = document.createElement('li'); + added.setAttribute('role', 'option'); + added.textContent = 'openfn/kitchen'; + el.querySelector('ul')!.appendChild(added); + el.querySelectorAll('li[role=option]').forEach( + o => (o.hidden = false) + ); + + hook.updated(); + + expect(visible()).toEqual(['openfn/kit', 'openfn/kitchen']); + }); + + it('clicks the first visible option on Enter without submitting', () => { + const { search, options, type } = setup(); + const clicked: (string | null)[] = []; + options().forEach(o => + o.addEventListener('click', () => clicked.push(o.textContent)) + ); + + type('kit'); + const event = new KeyboardEvent('keydown', { + key: 'Enter', + cancelable: true, + }); + search.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(clicked).toEqual(['openfn/kit']); + }); + + it('clears the filter and focuses the box when the list is opened', async () => { + const { el, search, visible, type } = setup(); + + type('kit'); + el.querySelector('button')!.click(); + await nextFrame(); + + expect(search.value).toBe(''); + expect(visible()).toEqual(OPTIONS); + expect(document.activeElement).toBe(search); + }); +}); diff --git a/lib/lightning/version_control/project_repo_connection.ex b/lib/lightning/version_control/project_repo_connection.ex index 65f65b097e6..6949156f2d5 100644 --- a/lib/lightning/version_control/project_repo_connection.ex +++ b/lib/lightning/version_control/project_repo_connection.ex @@ -204,7 +204,9 @@ defmodule Lightning.VersionControl.ProjectRepoConnection do validate_change(changeset, :sync_direction, fn _field, value -> path = get_field(changeset, :config_path) - if value == :deploy and is_nil(path) do + # v2 syncs always use openfn.yaml, so only legacy ones need a path + if value == :deploy and is_nil(path) and + not get_field(changeset, :sync_version) do [config_path: "you must specify a path to an existing config file"] else [] diff --git a/lib/lightning_web/components/github_components.ex b/lib/lightning_web/components/github_components.ex index 3fcbdd0c6ad..5f1b5995699 100644 --- a/lib/lightning_web/components/github_components.ex +++ b/lib/lightning_web/components/github_components.ex @@ -4,11 +4,6 @@ defmodule LightningWeb.Components.GithubComponents do use LightningWeb, :component attr :id, :string, required: true - - attr :class, :string, - default: - "text-center py-2 px-4 shadow-xs text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 bg-primary-600 hover:bg-primary-700 text-white" - attr :user, Lightning.Accounts.User, required: true attr :github_query_params, :map attr :disabled, :boolean, default: false @@ -18,15 +13,40 @@ defmodule LightningWeb.Components.GithubComponents do assign_new(assigns, :github_query_params, fn -> build_query_params() end) ~H""" - <.link + <.button_link id={@id} + theme="primary" href={"https://github.com/login/oauth/authorize?" <> Plug.Conn.Query.encode(@github_query_params)} target="_blank" - class={[@class, "#{if @disabled, do: "bg-primary-300 cursor-not-allowed"}"]} - {if @user.github_oauth_token, do: ["phx-hook": "Tooltip", "aria-label": "Your token has expired"], else: []} + disabled={@disabled} + phx-hook={@user.github_oauth_token && "Tooltip"} + aria-label={@user.github_oauth_token && "Your token has expired"} > {if @user.github_oauth_token, do: "Reconnect", else: "Connect"} your GitHub Account - + + """ + end + + attr :class, :string, default: "mb-4", doc: "spacing classes for the banner" + + @doc """ + Blue info banner pointing to the GitHub sync docs. Shown inside each state of + the Sync tab's GitHub panel. + """ + def sync_docs_banner(assigns) do + ~H""" + + <:message> + Learn more about synchronizing your project with GitHub at + <.link + target="_blank" + href="https://docs.openfn.org/documentation/link-to-GitHub" + class="font-medium underline" + > + docs.openfn.org + + + """ end diff --git a/lib/lightning_web/components/new_inputs.ex b/lib/lightning_web/components/new_inputs.ex index 1cef631f16c..4d9d47e43b9 100644 --- a/lib/lightning_web/components/new_inputs.ex +++ b/lib/lightning_web/components/new_inputs.ex @@ -398,6 +398,10 @@ defmodule LightningWeb.Components.NewInputs do attr :button_placement, :string, default: nil + attr :searchable, :boolean, + default: false, + doc: "custom-select only: show a text box that filters the options" + attr :placeholder, :string, default: "" attr :autocomplete, :string, default: "off" @@ -524,7 +528,9 @@ defmodule LightningWeb.Components.NewInputs do
- -
+ + Use legacy formats (config.json) + + """ end @@ -776,12 +812,10 @@ defmodule LightningWeb.ProjectLive.GithubSyncComponent do ~H"""
<.label> - Initial Setup Action + Initial Setup

- Do you want to initialize this 2-way sync by committing your current - OpenFn project to GitHub or do you want to overwrite your current OpenFn - project, importing a previously created project from a GitHub repo? + Choose the direction of the first sync: export this project to GitHub, or import an existing project from GitHub.

Direction of Initial Sync @@ -799,12 +833,11 @@ defmodule LightningWeb.ProjectLive.GithubSyncComponent do

- This option will commit a copy of your current OpenFn project to a GitHub repo. + Commit a copy of your current OpenFn project to GitHub. This does not affect any workflows or configuration here.

@@ -825,10 +858,7 @@ defmodule LightningWeb.ProjectLive.GithubSyncComponent do Import from GitHub (overwrite this project)

- If you already have an openfn.yaml - (or legacy config.json) - tracked on GitHub and you want to overwrite - this project on OpenFn, you can choose this advanced option. + Replace this OpenFn project with the workflows, collections and channels that are already committed to GitHub.

@@ -845,8 +875,11 @@ defmodule LightningWeb.ProjectLive.GithubSyncComponent do defp accept_checkbox(assigns) do ~H"""
<.input type="checkbox" @@ -854,7 +887,7 @@ defmodule LightningWeb.ProjectLive.GithubSyncComponent do required="true" hidden_input={false} /> - + I understand that <%= if @form[:sync_direction].value == :deploy do %> my current OpenFn project will be destroyed and @@ -862,31 +895,34 @@ defmodule LightningWeb.ProjectLive.GithubSyncComponent do the following files will be committed to <%= @form[ :repo ].value %>: -
    -
  • - <.icon name="hero-document-plus" class="h-4 w-4" /> - +
      +
    • + <.icon name="hero-document-plus" class="h-4 w-4 shrink-0 text-gray-500" /> + .github/workflows/openfn-pull.yml -> {@default_branch}
    • -
    • - <.icon name="hero-document-plus" class="h-4 w-4" /> - +
    • + <.icon name="hero-document-plus" class="h-4 w-4 shrink-0 text-gray-500" /> + .github/workflows/openfn-{@project.id}-deploy.yml -> {@form[ :branch ].value}
    • <%= if to_string(@form[:config_path].value) == "" do %> -
    • - <.icon name="hero-document-plus" class="h-4 w-4" /> - +
    • + <.icon + name="hero-document-plus" + class="h-4 w-4 shrink-0 text-gray-500" + /> + {config_filename(@form, @project.id)} -> {@form[:branch].value}
    • <% end %>
    - Existing versions of these files on these branches will be overwritten. (I'll be able to find them in my git history if needed.) + Existing versions of these files on these branches will be overwritten (but will still exist in git history).
""" diff --git a/lib/lightning_web/live/project_live/github_sync_component.html.heex b/lib/lightning_web/live/project_live/github_sync_component.html.heex index 3c88963c7f2..798bf72d05f 100644 --- a/lib/lightning_web/live/project_live/github_sync_component.html.heex +++ b/lib/lightning_web/live/project_live/github_sync_component.html.heex @@ -9,6 +9,10 @@ phx-submit="save" phx-target={@myself} > +
+ Configure two-way sync to GitHub +
+ <%!-- Token is expired/invalid --%> <.error_banner :if={show_invalid_oauth_error?(@installations)} @@ -56,112 +60,118 @@ There was a problem connecting to GitHub. Refresh the page or contact support if the problem persists.

-
- <.input - id="select-installations-input" - type="custom-select" - field={f[:github_installation_id]} - label="GitHub Installation" - prompt="Select an installation" - options={installations_select_options(@installations)} - disabled={ - @actions_disabled || - if(!@installations.ok?, do: true, else: false) - } - required="true" - button_placement="right" - > - <.button - id="refresh-installation-button" - type="button" - theme="custom" - class="rounded-l-none my-px h-9 text-gray-900 ring-1 ring-gray-300 hover:bg-gray-100" - disabled={ - @actions_disabled || - if(@installations.loading, do: true, else: false) - } - phx-click="refresh-installations" - phx-target={@myself} - > - <.icon - name="hero-arrow-path-mini" - class={"-ml-0.5 h-5 w-5 #{if(@installations.loading, do: "animate-spin")}"} +
+
+
+ <.input + id="select-installations-input" + type="custom-select" + field={f[:github_installation_id]} + label="GitHub Installation" + prompt="Select an installation" + options={installations_select_options(@installations)} + disabled={ + @actions_disabled || + if(!@installations.ok?, do: true, else: false) + } + required="true" + button_placement="right" + > + <.button + id="refresh-installation-button" + type="button" + theme="custom" + class="rounded-l-none my-px h-9 text-gray-900 ring-1 ring-gray-300 hover:bg-gray-100" + disabled={ + @actions_disabled || + if(@installations.loading, do: true, else: false) + } + phx-click="refresh-installations" + phx-target={@myself} + > + <.icon + name="hero-arrow-path-mini" + class={"-ml-0.5 h-5 w-5 #{if(@installations.loading, do: "animate-spin")}"} + /> + + +
+
+ <.input + id="select-repos-input" + searchable + type="custom-select" + field={f[:repo]} + label="Repository" + prompt="Select a repository" + options={ + repos_select_options(@repos, f[:github_installation_id].value) + } + disabled={ + to_string(f[:github_installation_id].value) == "" or !@repos.ok? + } + required="true" /> - - -
-
- Can't find the right installation or repository? - <.link - target="_blank" - class="link" - href={"https://github.com/apps/#{github_config()[:app_name]}"} - > - Create/update GitHub installations or modify permissions. - -
-
- <.input - id="select-repos-input" - type="custom-select" - field={f[:repo]} - label="Repository" - prompt="Select a repository" - options={ - repos_select_options(@repos, f[:github_installation_id].value) - } - disabled={ - to_string(f[:github_installation_id].value) == "" or !@repos.ok? - } - required="true" - /> -
-
- <.input - id="select-branches-input" - type="custom-select" - field={f[:branch]} - label="Branch" - prompt="Select a branch" - options={branches_select_options(@branches, f[:repo].value)} - disabled={to_string(f[:repo].value) == "" or !@branches.ok?} - required="true" - button_placement="right" - > - <.button - id="refresh-branches-button" - type="button" - theme="custom" - class="rounded-l-none my-px h-9 text-gray-900 ring-1 ring-gray-300 hover:bg-gray-100" - disabled={if(@branches.loading, do: true, else: false)} - phx-click="refresh-branches" - phx-target={@myself} - > - <.icon - name="hero-arrow-path-mini" - class={"-ml-0.5 h-5 w-5 #{if(@installations.loading, do: "animate-spin")}"} - /> - - +
+
+ Can't find the right installation or repository? + <.link + target="_blank" + class="link" + href={"https://github.com/apps/#{github_config()[:app_name]}"} + > + Create/update GitHub installations or modify permissions. + +
+
+ <.input + id="select-branches-input" + searchable + type="custom-select" + field={f[:branch]} + label="Branch" + prompt="Select a branch" + options={branches_select_options(@branches, f[:repo].value)} + disabled={to_string(f[:repo].value) == "" or !@branches.ok?} + required="true" + button_placement="right" + > + <.button + id="refresh-branches-button" + type="button" + theme="custom" + class="rounded-l-none my-px h-9 text-gray-900 ring-1 ring-gray-300 hover:bg-gray-100" + disabled={if(@branches.loading, do: true, else: false)} + phx-click="refresh-branches" + phx-target={@myself} + > + <.icon + name="hero-arrow-path-mini" + class={"-ml-0.5 h-5 w-5 #{if(@installations.loading, do: "animate-spin")}"} + /> + + +
+
<.sync_order_radio form={f} />
-
+ <.config_format_toggle form={f} project_id={@project.id} myself={@myself} /> +
<.input type="text" field={f[:config_path]} - label="Path to config (required)" + label={ + if f[:sync_direction].value == :deploy, + do: "Path to config (required)", + else: "Path to config (optional)" + } placeholder={"./openfn-#{@project.id}-config.json"} class="placeholder:italic placeholder:text-slate-400" />
- <.config_format_toggle - :if={f[:sync_direction].value != :deploy} - form={f} - project_id={@project.id} - /> - <%= if f[:branch].value do %> + <%= if f[:branch].value not in [nil, ""] do %> <.accept_checkbox project={@project} form={f} @@ -173,7 +183,7 @@ } /> <% end %> -
+
<.button id="connect-and-sync-button" type="submit" @@ -188,22 +198,24 @@
- <.verify_connection_banner - id="verify-connection-banner" - verify_connection={@verify_connection} - myself={@myself} - changeset={@changeset} - repos={@repos} - project={@project} - can_reconnect={ - @can_install_github && - can_access_github_installation?( - @project_repo_connection, - @installations - ) - } - />
+
Connected to GitHub
+ + <.verify_connection_banner + id="verify-connection-banner" + verify_connection={@verify_connection} + myself={@myself} + changeset={@changeset} + repos={@repos} + project={@project} + can_reconnect={ + @can_install_github && + can_access_github_installation?( + @project_repo_connection, + @installations + ) + } + />
Repository: @@ -230,23 +242,21 @@ - + Path to config: {ProjectRepoConnection.config_path(@project_repo_connection)} - - Sync version: - - {if @project_repo_connection.sync_version, - do: "v2 (new)", - else: "v1 (legacy)"} - - +

+ This connection uses the legacy sync format (config.json). Reconnect to update to v2 sync formats +

-
+
<.button id="initiate-sync-button" disabled={ @@ -266,20 +276,19 @@ > Initiate Sync to Branch + <.button + :if={@can_install_github} + id="remove-integration-button" + type="button" + theme="danger" + phx-click={show_modal("remove_connection_modal")} + > + Remove integration +
-
-
- No longer need to sync this project? - <.link - href="#" - class="link link-danger" - phx-click={show_modal("remove_connection_modal")} - phx-target={@myself} - > - Remove Integration. - <.confirm_connection_removal_modal + :if={@can_install_github} id="remove_connection_modal" myself={@myself} /> diff --git a/lib/lightning_web/live/project_live/settings.html.heex b/lib/lightning_web/live/project_live/settings.html.heex index fa4fed9806c..245ad064e03 100644 --- a/lib/lightning_web/live/project_live/settings.html.heex +++ b/lib/lightning_web/live/project_live/settings.html.heex @@ -54,7 +54,7 @@ <:tab hash="vcs"> <.icon name="hero-arrow-path" class="h-5 w-5 inline-block mr-2" /> - Sync to GitHub + Sync <:tab hash="data-storage"> <.icon name="hero-square-3-stack-3d" class="h-5 w-5 inline-block mr-2" /> @@ -74,7 +74,7 @@ else: "Projects are isolated workspaces that contain workflows, accessible to certain users." } - permissions_message="basic settings, but you can export a copy." + permissions_message="basic settings." can_perform_action={@can_edit_project} /> <.sandbox_settings_banner @@ -249,21 +249,6 @@ current_user={@current_user} /> -
-
-
Export your Project
-

- Export your project as code, to save this version or edit your project locally. -

-
- <.button_link - theme="primary" - href={~p"/download/yaml?id=#{@project.id}"} - target="_blank" - > - Export project - -
<%= if @can_delete_project do %>
@@ -757,10 +742,8 @@ <:panel hash="vcs" class="space-y-4"> <.section_header - title="Version control" - subtitle="View or modify external version control settings for this project." - permissions_message={"version control configuration#{@can_initiate_github_sync && " but you can initiate a sync." || "."}"} - can_perform_action={@can_install_github} + title="Sync" + subtitle="Sync your project with GitHub, export it, or work on it with the CLI." /> <.sandbox_settings_banner :if={@sandbox?} @@ -794,6 +777,26 @@ } /> <% else %> +
+
+ Configure two-way sync to GitHub +
+ + <:message> + Role based permissions: You cannot modify this project's version control configuration{if @can_initiate_github_sync, + do: " but you can initiate a sync.", + else: "."} + + +
<.live_component :if={user_has_valid_oauth_token(@current_user)} @@ -817,16 +820,16 @@ />
Connect your OpenFn account to GitHub
- - To create a new GitHub version control connection you must first connect your OpenFn account to GitHub. - Please click the button below to get started. + + + You need to do this before you can set up sync. -
+
<% end %> <% else %> -
+
- Sync to GitHub + Connect to GitHub
- + + Version Control is not configured for this Lightning instance. Contact the superuser for more information.
<% end %> -
- Need to learn more about GitHub Sync? - See - <.link +
+
+
Export your project
+

+ Save your project as a YAML file to be edited locally, backed up, or deployed elsewhere. +

+
+ <.button_link + theme="primary" + href={~p"/download/yaml?id=#{@project.id}"} target="_blank" - href="https://docs.openfn.org/documentation/deploy/portability#automated-version-control-with-github-and-lightning" - class="link" > - portability docs - - for full documentation on associated GitHub actions and automated workflows. + Export project + +
+
+
+
Sync with CLI
+

+ Use the OpenFn CLI to download, deploy and execute workflows in this project. Learn more in the <.link + target="_blank" + href="https://docs.openfn.org/documentation/sync" + class="font-medium text-indigo-600 hover:text-indigo-900" + > + CLI sync docs. +

+
+ + <:message> + You'll need to configure a Personal Access Token and add it to your environment for these commands to work: see + <.link + target="_blank" + href="https://docs.openfn.org/documentation/sync#authorization" + class="font-medium underline" + > + Authorization docs + + + +
+
"} + ]) + }> +

{label}

+
+
{command}
+ + + +
+
+
+

+ <.link + id="cli-more-examples-link" + target="_blank" + href="https://docs.openfn.org/documentation/cli-usage" + class="font-medium text-indigo-600 hover:text-indigo-900" + > + See more CLI commands + +

<:panel hash="data-storage" class="space-y-4"> diff --git a/test/lightning/version_control/project_repo_connection_test.exs b/test/lightning/version_control/project_repo_connection_test.exs index 92cd7e57d44..77b8ac4ced2 100644 --- a/test/lightning/version_control/project_repo_connection_test.exs +++ b/test/lightning/version_control/project_repo_connection_test.exs @@ -233,6 +233,58 @@ defmodule Lightning.VersionControl.ProjectRepoConnectionTest do end end + describe "config path when importing from GitHub (configure_changeset/2)" do + setup do + %{ + attrs: %{ + project_id: insert(:project).id, + repo: "openfn/example", + branch: "main", + github_installation_id: "1234", + sync_direction: "deploy", + accept: true + } + } + end + + test "a legacy import needs a path to config", %{attrs: attrs} do + changeset = + ProjectRepoConnection.configure_changeset( + %ProjectRepoConnection{}, + Map.put(attrs, :sync_version, false) + ) + + refute changeset.valid? + + assert {"you must specify a path to an existing config file", _} = + changeset.errors[:config_path] + end + + test "a legacy import is valid with a path", %{attrs: attrs} do + changeset = + ProjectRepoConnection.configure_changeset( + %ProjectRepoConnection{}, + Map.merge(attrs, %{sync_version: false, config_path: "./config.json"}) + ) + + assert changeset.valid? + end + + test "a v2 import needs no path and uses openfn.yaml", %{attrs: attrs} do + changeset = + ProjectRepoConnection.configure_changeset( + %ProjectRepoConnection{}, + Map.put(attrs, :sync_version, true) + ) + + assert changeset.valid? + + assert ProjectRepoConnection.config_path( + Ecto.Changeset.apply_changes(changeset) + ) == "openfn.yaml" + end + end + describe "tree_branch_conflict?/4" do test "returns false when no other connection in the tree uses the (repo, branch)" do root = insert(:project) diff --git a/test/lightning_web/live/project_live/github_sync_ui_test.exs b/test/lightning_web/live/project_live/github_sync_ui_test.exs new file mode 100644 index 00000000000..f67919d7a2f --- /dev/null +++ b/test/lightning_web/live/project_live/github_sync_ui_test.exs @@ -0,0 +1,447 @@ +defmodule LightningWeb.ProjectLive.GithubSyncUiTest do + @moduledoc """ + Tests for the Sync tab UI on project settings: the new connection form (title, + searchable dropdowns, legacy format switch) and the connected-state panel. + """ + + use LightningWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import Lightning.Factories + import Lightning.GithubHelpers + import Mox + import Lightning.ApplicationHelpers, only: [put_temporary_env: 3] + + setup :stub_usage_limiter_ok + setup :verify_on_exit! + + @installation %{ + "id" => "1234", + "account" => %{"type" => "User", "login" => "username"} + } + + @switch "#toggle-legacy-format-switch" + @sync_version_input ~s{input[name="connection[sync_version]"]} + @config_path_input ~s{input[name="connection[config_path]"]} + + defp open_new_connection_form(conn) do + project = insert(:project) + {conn, user} = setup_project_user(conn, project, :admin) + set_valid_github_oauth_token!(user) + + expect_get_user_installations(200, %{"installations" => [@installation]}) + expect_create_installation_token(@installation["id"]) + expect_get_installation_repos(200, %{"repositories" => []}) + + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/settings#vcs") + render_async(view) + + view + end + + defp stub_failed_verification do + Mox.stub(Lightning.Tesla.Mock, :call, fn _env, _opts -> + {:ok, %Tesla.Env{status: 404, body: %{"something" => "not right"}}} + end) + end + + defp open_connected_panel(conn, role, connection_attrs) do + project = insert(:project) + {conn, _user} = setup_project_user(conn, project, role) + + insert( + :project_repo_connection, + Keyword.merge( + [ + project: project, + repo: "someaccount/somerepo", + branch: "somebranch", + github_installation_id: "1234" + ], + connection_attrs + ) + ) + + stub_failed_verification() + + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/settings#vcs") + + {view, render_async(view)} + end + + describe "new connection form" do + test "shows the title and the docs banner", %{conn: conn} do + view = open_new_connection_form(conn) + + assert has_element?(view, "h6", "Configure two-way sync to GitHub") + + assert has_element?( + view, + ~s{#project-repo-connection-form a[href="https://docs.openfn.org/documentation/link-to-GitHub"]} + ) + end + + test "repository and branch dropdowns are searchable, installation is not", + %{conn: conn} do + view = open_new_connection_form(conn) + + assert has_element?( + view, + "#select-repos-input-searchable input[data-select-search]" + ) + + assert has_element?( + view, + "#select-branches-input-searchable input[data-select-search]" + ) + + refute has_element?(view, "#select-installations-input-searchable") + end + + test "defaults to the v2 format with the legacy switch off", %{conn: conn} do + view = open_new_connection_form(conn) + + assert has_element?(view, "#{@switch}[aria-checked=false]") + assert has_element?(view, ~s{#{@sync_version_input}[value=true]}) + end + + test "flipping the switch selects the legacy format and flips it back", + %{conn: conn} do + view = open_new_connection_form(conn) + + view |> element(@switch) |> render_click() + + assert has_element?(view, "#{@switch}[aria-checked=true]") + assert has_element?(view, ~s{#{@sync_version_input}[value=false]}) + + view |> element(@switch) |> render_click() + + assert has_element?(view, "#{@switch}[aria-checked=false]") + assert has_element?(view, ~s{#{@sync_version_input}[value=true]}) + end + + test "the legacy choice survives changing a dropdown", %{conn: conn} do + view = open_new_connection_form(conn) + + view |> element(@switch) |> render_click() + + view + |> form("#project-repo-connection-form") + |> render_change( + connection: %{github_installation_id: @installation["id"]} + ) + + assert has_element?(view, "#{@switch}[aria-checked=true]") + assert has_element?(view, ~s{#{@sync_version_input}[value=false]}) + end + + test "the switch stays visible when importing from GitHub", %{conn: conn} do + view = open_new_connection_form(conn) + + view + |> form("#project-repo-connection-form") + |> render_change(connection: %{sync_direction: "deploy"}) + + assert has_element?(view, @switch) + end + + test "a v2 import has no path to config input", %{conn: conn} do + view = open_new_connection_form(conn) + + view + |> form("#project-repo-connection-form") + |> render_change(connection: %{sync_direction: "deploy"}) + + assert has_element?(view, "#{@switch}[aria-checked=false]") + refute has_element?(view, @config_path_input) + end + + test "a legacy import asks for the path to config", %{conn: conn} do + view = open_new_connection_form(conn) + + view + |> form("#project-repo-connection-form") + |> render_change(connection: %{sync_direction: "deploy"}) + + view |> element(@switch) |> render_click() + + assert has_element?(view, @config_path_input) + + view |> element(@switch) |> render_click() + + refute has_element?(view, @config_path_input) + end + + test "an export-first v2 sync has no path to config", %{conn: conn} do + view = open_new_connection_form(conn) + + refute has_element?(view, @config_path_input) + end + + test "an export-first legacy sync shows an optional path to config", + %{conn: conn} do + view = open_new_connection_form(conn) + + view |> element(@switch) |> render_click() + + assert has_element?(view, @config_path_input) + assert render(view) =~ "Path to config (optional)" + + view |> element(@switch) |> render_click() + + refute has_element?(view, @config_path_input) + end + + test "a legacy import marks the path to config as required", %{conn: conn} do + view = open_new_connection_form(conn) + + view + |> form("#project-repo-connection-form") + |> render_change(connection: %{sync_direction: "deploy"}) + + view |> element(@switch) |> render_click() + + assert render(view) =~ "Path to config (required)" + end + + test "the confirm box waits until a branch is picked", %{conn: conn} do + view = open_new_connection_form(conn) + confirm = ~s{input[name="connection[accept]"]} + + view + |> form("#project-repo-connection-form") + |> render_change( + connection: %{github_installation_id: @installation["id"]} + ) + + refute has_element?(view, confirm) + + view + |> form("#project-repo-connection-form") + |> render_change(connection: %{branch: "main"}) + + assert has_element?(view, confirm) + end + end + + describe "docs banner" do + @banner "#github-sync-docs-banner" + + test "shows in the new connection form", %{conn: conn} do + view = open_new_connection_form(conn) + + assert has_element?(view, "#project-repo-connection-form #{@banner}") + end + + test "shows when the user must first connect their GitHub account", + %{conn: conn} do + project = insert(:project) + {conn, _user} = setup_project_user(conn, project, :admin) + + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/settings#vcs") + + assert has_element?(view, "#connect-github-link") + assert has_element?(view, @banner) + end + + test "shows when version control is not configured on the instance", + %{conn: conn} do + put_temporary_env(:lightning, :github_app, + cert: nil, + app_id: nil, + app_name: nil, + client_id: nil, + client_secret: nil + ) + + project = insert(:project) + {conn, _user} = setup_project_user(conn, project, :viewer) + + {:ok, view, html} = live(conn, ~p"/projects/#{project.id}/settings#vcs") + + assert html =~ "Version Control is not configured" + assert has_element?(view, @banner) + end + + test "shows on the connected panel", %{conn: conn} do + {view, _html} = open_connected_panel(conn, :admin, sync_version: true) + + assert has_element?(view, "div.bg-white:has(h6) #{@banner}") + end + + test "links to the sync docs", %{conn: conn} do + view = open_new_connection_form(conn) + + assert has_element?( + view, + ~s{#{@banner} a[target="_blank"][href="https://docs.openfn.org/documentation/link-to-GitHub"]} + ) + end + end + + describe "CLI card" do + test "warns that a personal access token is needed", %{conn: conn} do + view = open_sync_tab(conn, :viewer) + + assert has_element?( + view, + "#cli-auth-banner", + "You'll need to configure a Personal Access Token" + ) + + assert has_element?( + view, + ~s{#cli-auth-banner a[target="_blank"][href="https://docs.openfn.org/documentation/sync#authorization"]} + ) + end + + test "links to more CLI examples", %{conn: conn} do + view = open_sync_tab(conn, :viewer) + + assert has_element?( + view, + ~s{#cli-more-examples-link[target="_blank"][href="https://docs.openfn.org/documentation/cli-usage"]}, + "See more CLI commands" + ) + end + end + + describe "permissions banner" do + @permissions_banner "#vcs-permissions-banner" + + defp open_sync_tab(conn, role) do + project = insert(:project) + {conn, _user} = setup_project_user(conn, project, role) + + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/settings#vcs") + view + end + + test "viewers are told they can't change the connection", %{conn: conn} do + view = open_sync_tab(conn, :viewer) + + assert has_element?( + view, + @permissions_banner, + "You cannot modify this project's version control configuration." + ) + + refute render(view) =~ "but you can initiate a sync" + end + + test "editors are told they can still initiate a sync", %{conn: conn} do + view = open_sync_tab(conn, :editor) + + assert has_element?( + view, + @permissions_banner, + "version control configuration but you can initiate a sync." + ) + end + + test "admins see no permissions banner", %{conn: conn} do + view = open_sync_tab(conn, :admin) + + refute has_element?(view, @permissions_banner) + end + + test "the message sits in a plain box where the form would be", + %{conn: conn} do + view = open_sync_tab(conn, :viewer) + + assert has_element?( + view, + "#vcs-permissions-box h6", + "Configure two-way sync to GitHub" + ) + + assert has_element?( + view, + "#vcs-permissions-box #vcs-permissions-banner" + ) + + # the real form is not rendered, so nothing calls GitHub + refute has_element?(view, "#project-repo-connection-form") + refute has_element?(view, "#connect-github-link") + end + + test "admins get the real form and no permissions box", %{conn: conn} do + view = open_new_connection_form(conn) + + refute has_element?(view, "#vcs-permissions-box") + assert has_element?(view, "#project-repo-connection-form") + end + + test "a connected project shows its connection, not the box", + %{conn: conn} do + {view, _html} = open_connected_panel(conn, :editor, sync_version: true) + + assert has_element?(view, "h6", "Connected to GitHub") + refute has_element?(view, "#vcs-permissions-box") + end + + test "the message no longer sits in the section header", %{conn: conn} do + view = open_sync_tab(conn, :viewer) + + refute has_element?(view, "#vcs-panel small.text-red-700") + end + end + + describe "connected panel" do + test "v2 connections show the title and no legacy note or config path", + %{conn: conn} do + {view, html} = open_connected_panel(conn, :admin, sync_version: true) + + assert has_element?(view, "h6", "Connected to GitHub") + refute html =~ "legacy sync format" + refute html =~ "Path to config" + refute html =~ "Sync version:" + end + + test "v1 connections show the legacy note and the config path", + %{conn: conn} do + {view, html} = open_connected_panel(conn, :admin, sync_version: false) + + assert has_element?(view, "h6", "Connected to GitHub") + + assert html =~ + "This connection uses the legacy sync format (config.json). Reconnect to update to v2 sync formats" + + assert html =~ "Path to config" + end + + test "admins get a remove integration button next to the sync button", + %{conn: conn} do + {view, _html} = open_connected_panel(conn, :admin, sync_version: true) + + assert has_element?( + view, + "#remove-integration-button", + "Remove integration" + ) + + assert has_element?(view, "#initiate-sync-button") + assert has_element?(view, "#remove_connection_modal") + end + + test "editors can't remove the integration", %{conn: conn} do + {view, _html} = open_connected_panel(conn, :editor, sync_version: true) + + refute has_element?(view, "#remove-integration-button") + refute has_element?(view, "#remove_connection_modal") + end + + test "the verification banner renders inside the connected card", + %{conn: conn} do + {view, _html} = open_connected_panel(conn, :admin, sync_version: true) + + assert has_element?(view, "#verify-connection-banner") + + # the banner is a descendant of the card that holds the title + assert has_element?( + view, + "div.bg-white:has(h6) #verify-connection-banner" + ) + end + end +end diff --git a/test/lightning_web/live/project_live_test.exs b/test/lightning_web/live/project_live_test.exs index d7a3cfb3504..f0cb68d6787 100644 --- a/test/lightning_web/live/project_live_test.exs +++ b/test/lightning_web/live/project_live_test.exs @@ -198,7 +198,7 @@ defmodule LightningWeb.ProjectLiveTest do {:ok, index_live, html} = live(conn, ~p"/projects/#{project.id}/settings") assert html =~ - "Export your project as code, to save this version or edit your project locally" + "Save your project as a YAML file to be edited locally, backed up, or deployed elsewhere" assert index_live |> element(~s{a[target="_blank"]}, "Export project") @@ -5076,7 +5076,7 @@ defmodule LightningWeb.ProjectLiveTest do view |> element("#select-repos-input") |> render_async() - |> find_selected_option("#select-repos-input li") + |> find_selected_option("#select-repos-input li[role=option]") assert selected_repo =~ expected_repo["full_name"] @@ -5095,7 +5095,7 @@ defmodule LightningWeb.ProjectLiveTest do view |> element("#select-branches-input") |> render_async() - |> find_selected_option("#select-branches-input li") + |> find_selected_option("#select-branches-input li[role=option]") assert selected_branch =~ expected_branch["name"] @@ -5106,9 +5106,9 @@ defmodule LightningWeb.ProjectLiveTest do html = render_async(view) - refute find_selected_option(html, "#select-repos-input li") + refute find_selected_option(html, "#select-repos-input li[role=option]") - refute find_selected_option(html, "#select-branches-input li") + refute find_selected_option(html, "#select-branches-input li[role=option]") # let us list the branches again by following the ritual again view @@ -5141,7 +5141,7 @@ defmodule LightningWeb.ProjectLiveTest do |> element("#select-branches-input") |> render_async() |> Floki.parse_fragment!() - |> Floki.find("#select-branches-input li") + |> Floki.find("#select-branches-input li[role=option]") assert Enum.count(options) == 2 @@ -5168,7 +5168,7 @@ defmodule LightningWeb.ProjectLiveTest do |> element("#select-branches-input") |> render_async() |> Floki.parse_fragment!() - |> Floki.find("#select-branches-input li") + |> Floki.find("#select-branches-input li[role=option]") assert Enum.count(options) == 3 end @@ -5239,7 +5239,7 @@ defmodule LightningWeb.ProjectLiveTest do |> element("#select-branches-input") |> render_async() |> Floki.parse_fragment!() - |> Floki.find("#select-branches-input li") + |> Floki.find("#select-branches-input li[role=option]") # Only the "Select a branch" prompt is present; no branches leaked. assert Enum.count(options) == 1 @@ -5257,7 +5257,7 @@ defmodule LightningWeb.ProjectLiveTest do |> element("#select-branches-input") |> render_async() |> Floki.parse_fragment!() - |> Floki.find("#select-branches-input li") + |> Floki.find("#select-branches-input li[role=option]") assert Enum.count(options) == 1 end @@ -5317,7 +5317,7 @@ defmodule LightningWeb.ProjectLiveTest do # We just have the default option floki_fragment = view |> render_async() |> Floki.parse_fragment!() [repos_input] = Floki.find(floki_fragment, "#select-repos-input") - options = Floki.children(repos_input) + options = Floki.find(repos_input, "li[role=option]") assert Enum.count(options) == 1 options |> hd() |> Floki.raw_html() =~ "Select a repo" @@ -5331,7 +5331,7 @@ defmodule LightningWeb.ProjectLiveTest do # we should now have the repos listed floki_fragment = view |> render_async() |> Floki.parse_fragment!() [repos_input] = Floki.find(floki_fragment, "#select-repos-input") - options = Floki.children(repos_input) + options = Floki.find(repos_input, "li[role=option]") assert Enum.count(options) == 2 [default_option, repo_option] = options Floki.raw_html(default_option) =~ "Select a repo" @@ -5342,7 +5342,7 @@ defmodule LightningWeb.ProjectLiveTest do floki_fragment = view |> render_async() |> Floki.parse_fragment!() [branches_input] = Floki.find(floki_fragment, "#select-branches-input") - options = Floki.children(branches_input) + options = Floki.find(branches_input, "li[role=option]") assert Enum.count(options) == 1 options |> hd() |> Floki.raw_html() =~ "Select a branch" @@ -5367,7 +5367,7 @@ defmodule LightningWeb.ProjectLiveTest do # we should now have the branches listed floki_fragment = view |> render_async() |> Floki.parse_fragment!() [branches_input] = Floki.find(floki_fragment, "#select-branches-input") - options = Floki.children(branches_input) + options = Floki.find(branches_input, "li[role=option]") assert Enum.count(options) == 2 [default_option, branch_option] = options Floki.raw_html(default_option) =~ "Select a branch"