Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
80b5cbf
Move export into Sync tab on project settings
josephjclark Oct 8, 2026
547f8cc
Add CLI section to Sync tab
josephjclark Oct 8, 2026
9e3bd11
Refine GitHub sync form layout, copy and searchable selects
josephjclark Oct 8, 2026
dd3ca86
Default GitHub sync to v2 YAML with a legacy format switch
josephjclark Oct 8, 2026
f741910
Move GitHub docs banner into the connection form
josephjclark Oct 8, 2026
2c97e07
Tidy the connected GitHub sync panel
josephjclark Oct 8, 2026
1ac7f14
Update settings tests for the Sync tab changes
josephjclark Oct 9, 2026
00fd48e
Add tests for the Sync tab form, legacy switch and filter hook
josephjclark Oct 9, 2026
f74288c
Share the GitHub docs banner across Sync tab states
josephjclark Oct 9, 2026
fdfd620
Add changelog entries for the Sync tab changes (#5235)
josephjclark Oct 9, 2026
d216e07
Use shared components on the Sync tab
lmac-1 Oct 12, 2026
cdb5061
Tone down the GitHub sync confirm box
lmac-1 Oct 12, 2026
2649d8a
Tidy spacing and buttons on the Sync tab
lmac-1 Oct 12, 2026
95eece7
Add copy feedback to the CLI commands on the Sync tab
lmac-1 Oct 12, 2026
082193b
Centre the connection check banner on the Sync tab
lmac-1 Oct 12, 2026
90f807a
Tidy wording on the Sync tab
lmac-1 Oct 12, 2026
ab021a0
Show the sync confirm box only after a branch is picked
lmac-1 Oct 12, 2026
82a6a3b
Bring back the legacy format switch on the Sync tab
lmac-1 Oct 12, 2026
1e8c71c
hide config.json path in v2 and update legacy format label
josephjclark Oct 13, 2026
c2db6fc
tooltip for legacy formats
josephjclark Oct 13, 2026
8153983
better UI if permissions are disabled
josephjclark Oct 13, 2026
d63f065
link to more docs
josephjclark Oct 13, 2026
5e8ea0a
wording tweaks
josephjclark Oct 14, 2026
bf9a41b
more wording tweaks
josephjclark Oct 14, 2026
11b3d8e
add PAT note
josephjclark Oct 14, 2026
f800ee6
Merge branch 'main' of github.com:OpenFn/lightning into sandbox-sync-ui
midigofrank Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
59 changes: 59 additions & 0 deletions assets/js/hooks/SearchableSelect.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(
'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<HTMLElement>('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<HTMLElement>('li[role=option]').forEach(option => {
const label = (option.textContent ?? '').toLowerCase();
option.hidden = term !== '' && !label.includes(term);
});
},
} as SearchableSelect;

export { SearchableSelect };
2 changes: 2 additions & 0 deletions assets/js/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -34,6 +35,7 @@ export {
TemplateToWorkflow,
ElapsedIndicator,
TabbedContainer,
SearchableSelect,
SaveViaCtrlS,
InspectorSaveViaCtrlS,
OpenSyncModalViaCtrlShiftS,
Expand Down
125 changes: 125 additions & 0 deletions assets/test/hooks/SearchableSelect.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<div id="repo-searchable">
<button type="button">toggle</button>
<ul role="listbox">
<li role="presentation"><input data-select-search type="text" /></li>
${OPTIONS.map(o => `<li role="option">${o}</li>`).join('')}
</ul>
</div>
`;

const el = document.getElementById('repo-searchable') as HTMLElement;
const search = el.querySelector<HTMLInputElement>('input')!;
const hook = Object.assign(Object.create(SearchableSelect), { el });
hook.mounted();

const options = () =>
Array.from(el.querySelectorAll<HTMLElement>('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<HTMLElement>('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<HTMLElement>('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);
});
});
4 changes: 3 additions & 1 deletion lib/lightning/version_control/project_repo_connection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
[]
Expand Down
38 changes: 29 additions & 9 deletions lib/lightning_web/components/github_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
</.link>
</.button_link>
"""
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"""
<Common.alert id="github-sync-docs-banner" type="info" class={@class}>
<: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
</.link>
</:message>
</Common.alert>
"""
end

Expand Down
22 changes: 22 additions & 0 deletions lib/lightning_web/components/new_inputs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -524,7 +528,9 @@ defmodule LightningWeb.Components.NewInputs do
</.label>
<div class="flex w-full items-center">
<div
id={@searchable && "#{@id}-searchable"}
class="relative w-full"
phx-hook={@searchable && "SearchableSelect"}
phx-click={JS.show(to: {:inner, "ul[role='listbox']"})}
>
<button
Expand Down Expand Up @@ -559,6 +565,22 @@ defmodule LightningWeb.Components.NewInputs do
role="listbox"
phx-click-away={JS.hide()}
>
<li
:if={@searchable}
role="presentation"
class="sticky top-0 bg-white px-2 pt-1 pb-2"
>
<%!-- form="none" points at no real form, so typing here doesn't
fire the surrounding form's phx-change --%>
<input
type="text"
form="none"
data-select-search
autocomplete="off"
placeholder="Filter…"
class="block w-full rounded-md border border-secondary-300 py-1 text-sm focus:border-primary-300 focus:ring focus:ring-primary-200/50"
/>
</li>
<li
:for={option <- @options}
class="relative cursor-default py-2 pr-4 pl-8 text-gray-900 select-none group hover:bg-primary-600 hover:text-white hover:outline-hidden"
Expand Down
Loading
Loading