Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ae1f0cb
feat: unsaved changes
doc-han Jan 6, 2026
94886b9
fix: wording
doc-han Jan 6, 2026
2f4f8a8
feat: udpate
doc-han Jan 6, 2026
6d98fd5
fix: resolve new workflow from templates
doc-han Jan 6, 2026
ee8e7e0
feat: add workflow_transform to serializer
doc-han Jan 7, 2026
cfcbeef
feat: remove logs
doc-han Jan 7, 2026
c5c40f1
feat: transform workflow
doc-han Jan 7, 2026
e08442c
feat: remove transforms from serializer
doc-han Jan 7, 2026
43a2d19
feat: cleanup
doc-han Jan 7, 2026
b990b8a
fix: changes
doc-han Jan 7, 2026
45f76b1
tests: update tests
doc-han Jan 8, 2026
af1fd4a
chore: simplify expression
doc-han Jan 8, 2026
dd07f94
test: resolve failing test
doc-han Jan 8, 2026
1a718f7
chore: update changelog
doc-han Jan 8, 2026
3497712
feat: trim all user inputs
doc-han Jan 8, 2026
4d4826a
feat: support positions in changes
doc-han Jan 8, 2026
1ba573d
fix: set default cron expression
doc-han Jan 8, 2026
8316dcd
fix: default cron value
doc-han Jan 8, 2026
f48b3e1
chore: remove log
doc-han Jan 8, 2026
991118f
fix: allow job credentials through encoding
doc-han Jan 8, 2026
dbc429c
feat: consider concurrency & jobs log toggle
doc-han Jan 15, 2026
b16cf24
chore: resolve linting issue
doc-han Jan 15, 2026
9cf5313
feat: switch back to broadcast_from! and pass workflow in resp
doc-han Jan 15, 2026
43117f3
chore: add notify call to setBaseWorkflow
doc-han Jan 16, 2026
38a91ae
chore: use memoization
doc-han Jan 16, 2026
5d3cab8
tests: resolve
doc-han Jan 16, 2026
c8d7d08
test: resolve unsaved changes test
doc-han Jan 16, 2026
d32599e
fix: schema validation for base workflow
doc-han Jan 16, 2026
6f8b964
tests: undefined base
doc-han Jan 16, 2026
7efb05c
tests: fix job logs validation
doc-han Jan 16, 2026
99c0c8d
tests: broadcasts
doc-han Jan 16, 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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ and this project adheres to

### Added

- Add Unsaved Changes Indicator
[#3682](https://github.com/OpenFn/lightning/issues/3682)

### Changed

### Fixed
Expand Down
155 changes: 89 additions & 66 deletions assets/js/collaborative-editor/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
useTemplatePanel,
useUICommands,
} from '../hooks/useUI';
import { useUnsavedChanges } from '../hooks/useUnsavedChanges';
import {
useCanRun,
useCanSave,
Expand Down Expand Up @@ -54,6 +55,7 @@ export function SaveButton({
label = 'Save',
canSync,
syncTooltipMessage,
hasChanges,
}: {
canSave: boolean;
tooltipMessage: string;
Expand All @@ -63,11 +65,49 @@ export function SaveButton({
label?: string;
canSync: boolean;
syncTooltipMessage: string | null;
hasChanges: boolean;
}) {
const hasGitHubIntegration = repoConnection !== null;

if (!hasGitHubIntegration) {
return (
<div className="relative">
<div className="inline-flex rounded-md shadow-xs z-5">
<Tooltip
content={
canSave ? <ShortcutKeys keys={['mod', 's']} /> : tooltipMessage
}
side="bottom"
>
<button
type="button"
data-testid="save-workflow-button"
className="rounded-md text-sm font-semibold shadow-xs
phx-submit-loading:opacity-75 cursor-pointer
disabled:cursor-not-allowed disabled:bg-primary-300 px-3 py-2
bg-primary-600 hover:bg-primary-500
disabled:hover:bg-primary-300 text-white
focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-primary-600 focus:ring-transparent"
onClick={onClick}
disabled={!canSave}
>
{label}
</button>
</Tooltip>
</div>
{hasChanges ? (
<div
className="absolute -m-1 top-0 right-0 z-10 size-3 bg-danger-500 rounded-full"
data-is-dirty
></div>
) : null}
</div>
);
}

return (
<div className="relative">
<div className="inline-flex rounded-md shadow-xs z-5">
<Tooltip
content={
Expand All @@ -78,7 +118,7 @@ export function SaveButton({
<button
type="button"
data-testid="save-workflow-button"
className="rounded-md text-sm font-semibold shadow-xs
className="rounded-l-md text-sm font-semibold shadow-xs
phx-submit-loading:opacity-75 cursor-pointer
disabled:cursor-not-allowed disabled:bg-primary-300 px-3 py-2
bg-primary-600 hover:bg-primary-500
Expand All @@ -91,82 +131,61 @@ export function SaveButton({
{label}
</button>
</Tooltip>
</div>
);
}

return (
<div className="inline-flex rounded-md shadow-xs z-5">
<Tooltip
content={
canSave ? <ShortcutKeys keys={['mod', 's']} /> : tooltipMessage
}
side="bottom"
>
<button
type="button"
data-testid="save-workflow-button"
className="rounded-l-md text-sm font-semibold shadow-xs
phx-submit-loading:opacity-75 cursor-pointer
disabled:cursor-not-allowed disabled:bg-primary-300 px-3 py-2
bg-primary-600 hover:bg-primary-500
disabled:hover:bg-primary-300 text-white
focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-primary-600 focus:ring-transparent"
onClick={onClick}
disabled={!canSave}
>
{label}
</button>
</Tooltip>
<Menu as="div" className="relative -ml-px block">
<MenuButton
disabled={!canSave}
className="h-full rounded-r-md pr-2 pl-2 text-sm font-semibold
<Menu as="div" className="relative -ml-px block">
<MenuButton
disabled={!canSave}
className="h-full rounded-r-md pr-2 pl-2 text-sm font-semibold
shadow-xs cursor-pointer disabled:cursor-not-allowed
bg-primary-600 hover:bg-primary-500
disabled:bg-primary-300 disabled:hover:bg-primary-300 text-white
focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-primary-600 focus:ring-transparent"
>
<span className="sr-only">Open sync options</span>
<span className="hero-chevron-down w-4 h-4" />
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-[100] mt-2 w-max origin-top-right
>
<span className="sr-only">Open sync options</span>
<span className="hero-chevron-down w-4 h-4" />
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-[100] mt-2 w-max origin-top-right
rounded-md bg-white py-1 shadow-lg outline outline-black/5
transition data-closed:scale-95 data-closed:transform
data-closed:opacity-0 data-enter:duration-200 data-enter:ease-out
data-leave:duration-75 data-leave:ease-in"
>
<MenuItem>
<Tooltip
content={
canSave && canSync ? (
<ShortcutKeys keys={['mod', 'shift', 's']} />
) : !canSync && syncTooltipMessage ? (
syncTooltipMessage
) : (
tooltipMessage
)
}
side="bottom"
>
<button
type="button"
onClick={onSyncClick}
disabled={!canSave || !canSync}
className="block w-full text-left px-4 py-2 text-sm text-gray-700
>
<MenuItem>
<Tooltip
content={
canSave && canSync ? (
<ShortcutKeys keys={['mod', 'shift', 's']} />
) : !canSync && syncTooltipMessage ? (
syncTooltipMessage
) : (
tooltipMessage
)
}
side="bottom"
>
<button
type="button"
onClick={onSyncClick}
disabled={!canSave || !canSync}
className="block w-full text-left px-4 py-2 text-sm text-gray-700
data-focus:bg-gray-100 data-focus:outline-hidden
disabled:opacity-50 disabled:cursor-not-allowed"
>
Save & Sync
</button>
</Tooltip>
</MenuItem>
</MenuItems>
</Menu>
>
Save & Sync
</button>
</Tooltip>
</MenuItem>
</MenuItems>
</Menu>
</div>
{hasChanges ? (
<div
className="absolute -m-1 top-0 right-0 z-10 size-3 bg-danger-500 rounded-full"
data-is-dirty
></div>
) : null}
</div>
);
}
Expand Down Expand Up @@ -204,6 +223,7 @@ export function Header({
const { provider } = useSession();
const limits = useLimits();
const { isReadOnly } = useWorkflowReadOnly();
const { hasChanges } = useUnsavedChanges();

// Check GitHub sync limit
const githubSyncLimit = limits.github_sync ?? {
Expand All @@ -220,6 +240,8 @@ export function Header({
// When ?v= is present, user is viewing a specific version (even if latest)
const isPinnedVersion = params['v'] !== undefined && params['v'] !== null;

const showChangeIndicator = hasChanges && canSave && !isNewWorkflow;

const handleRunClick = useCallback(() => {
if (firstTriggerId) {
// select the first trigger
Expand Down Expand Up @@ -410,6 +432,7 @@ export function Header({
label={isNewWorkflow ? 'Create' : 'Save'}
canSync={githubSyncLimit.allowed}
syncTooltipMessage={githubSyncLimit.message}
hasChanges={showChangeIndicator}
/>
</div>
</div>
Expand Down
140 changes: 140 additions & 0 deletions assets/js/collaborative-editor/hooks/useUnsavedChanges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useMemo } from 'react';

import type { Trigger } from '../types/trigger';
import type { Workflow } from '../types/workflow';

import { useSessionContext } from './useSessionContext';
import { useWorkflowState } from './useWorkflow';

export function useUnsavedChanges() {
const { workflow } = useSessionContext();

// Individual selectors - stable references
const jobs = useWorkflowState(state => state.jobs);
const triggers = useWorkflowState(state => state.triggers);
const edges = useWorkflowState(state => state.edges);
const positions = useWorkflowState(state => state.positions);
const name = useWorkflowState(state => state.workflow?.name);
const concurrency = useWorkflowState(state => state.workflow?.concurrency);
const enable_job_logs = useWorkflowState(
state => state.workflow?.enable_job_logs
);

// Memoize store workflow object to prevent recreating on every render
const storeWorkflow = useMemo(
() => ({
jobs,
triggers,
edges,
positions: positions || {},
name,
concurrency,
enable_job_logs,
}),
[jobs, triggers, edges, positions, name, concurrency, enable_job_logs]
);

// Memoize transformed base workflow (from session context)
const transformedBaseWorkflow = useMemo(
() => (workflow ? transformWorkflow(workflow) : null),
[workflow]
);

// Memoize transformed store workflow
const transformedStoreWorkflow = useMemo(
() => transformWorkflow(storeWorkflow as Workflow),
[storeWorkflow]
);

// Memoize comparison
const hasChanges = useMemo(() => {
if (!transformedBaseWorkflow) return false;
return isDiffWorkflow(transformedBaseWorkflow, transformedStoreWorkflow);
}, [transformedBaseWorkflow, transformedStoreWorkflow]);

return { hasChanges };
}

// transform workflow to normalized structure for comparison
function transformWorkflow(workflow: Workflow) {
return {
name: workflow.name,
jobs: (workflow.jobs || [])
.map(job => ({
id: job.id,
name: job.name.trim(),
body: job.body.trim(),
adaptor: job.adaptor,
project_credential_id: job.project_credential_id,
keychain_credential_id: job.keychain_credential_id,
}))
.sort((a, b) => a.id.localeCompare(b.id)),
edges: (workflow.edges || [])
.map(edge => ({
id: edge.id,
source_job_id: edge.source_job_id,
source_trigger_id: edge.source_trigger_id,
target_job_id: edge.target_job_id,
enabled: edge.enabled || false,
condition_type: edge.condition_type,
condition_label: edge.condition_label?.trim(),
condition_expression: edge.condition_expression?.trim(),
}))
.sort((a, b) => a.id.localeCompare(b.id)),
triggers: (workflow.triggers || []).map(trigger =>
transformTrigger(trigger)
),
positions: workflow.positions || {},
concurrency: workflow.concurrency,
enable_job_logs: workflow.enable_job_logs,
};
}

function transformTrigger(trigger: Trigger) {
const output: Partial<Trigger> = {
id: trigger.id,
type: trigger.type,
enabled: trigger.enabled,
};
switch (trigger.type) {
case 'cron':
output.cron_expression = trigger.cron_expression ?? '0 0 * * *'; // default cron expression
break;
case 'kafka':
output.kafka_configuration = trigger.kafka_configuration;
break;
case 'webhook':
break;
}
return output;
}

// deep comparison to detect workflow changes
function isDiffWorkflow(base: unknown, target: unknown): boolean {
const isNullish = (v: unknown) => v === undefined || v === null || v === '';
if (isNullish(base) && isNullish(target)) return false;
if (typeof base !== typeof target) return true;

if (Array.isArray(base) && Array.isArray(target)) {
return (
base.length !== target.length ||
base.some((v, i) => isDiffWorkflow(v, target[i]))
);
}

if (
base &&
target &&
typeof base === 'object' &&
typeof target === 'object'
) {
const baseObj = base as Record<string, unknown>;
const targetObj = target as Record<string, unknown>;
const keys = [
...new Set(Object.keys(baseObj).concat(Object.keys(targetObj))),
];
return keys.some(k => isDiffWorkflow(baseObj[k], targetObj[k]));
}

return base !== target;
}
Loading