Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 29 additions & 2 deletions assets/js/collaborative-editor/CollaborativeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ import { VersionDropdown } from './components/VersionDropdown';
import { WorkflowEditor } from './components/WorkflowEditor';
import { YAMLImportModal } from './components/YAMLImportModal';
import { CredentialModalProvider } from './contexts/CredentialModalContext';
import { LiveViewActionsProvider } from './contexts/LiveViewActionsContext';
import {
LiveViewActionsProvider,
useLiveViewActions,
} from './contexts/LiveViewActionsContext';
import { MonacoRefProvider } from './contexts/MonacoRefContext';
import { SessionProvider } from './contexts/SessionProvider';
import { StoreProvider } from './contexts/StoreProvider';
import { useActionLock } from './hooks/useActionLock';
import {
useIsNewWorkflow,
useLatestSnapshotLockVersion,
Expand All @@ -36,6 +40,7 @@ import {
import { useVersionSelect } from './hooks/useVersionSelect';
import { useWorkflowState } from './hooks/useWorkflow';
import { KeyboardProvider } from './keyboard';
import { notifications } from './lib/notifications';

export interface CollaborativeEditorDataProps {
'data-workflow-id': string;
Expand Down Expand Up @@ -190,6 +195,27 @@ function LandingScreenWrapper({
dismissLandingScreen,
openAIAssistantPanel,
} = useUICommands();
const { pushEventTo } = useLiveViewActions();
const { run: runBuildFromScratch, isPending: isBuildingFromScratch } =
useActionLock(async () => {
try {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error('build_from_scratch timed out')),
10_000

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useActionLock requires a promise, so we create a timeout in order to only build from scratch if successful.

);
pushEventTo('build_from_scratch', {}, () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude tells me it's okay to use pushEventTo here in collaborative editor but would like confirmation that it's the right pattern to do stuff like that here.

clearTimeout(timeout);
resolve();
});
});
} catch {
notifications.alert({
title: 'Failed to create workflow',
description: 'Please check your connection and try again.',
});
}
});

if (!showLandingScreen) return null;

Expand All @@ -201,7 +227,8 @@ function LandingScreenWrapper({
dismissLandingScreen();
openAIAssistantPanel(prompt);
}}
onBuildFromScratch={() => {}}
onBuildFromScratch={() => void runBuildFromScratch()}
isBuildingFromScratch={isBuildingFromScratch}
onBrowseTemplates={openTemplateBrowserModal}
onImportYAML={openYAMLImportModal}
/>
Expand Down
8 changes: 7 additions & 1 deletion assets/js/collaborative-editor/components/LandingScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ interface WorkflowOptionCardProps {
description: string;
onClick: () => void;
testId: string;
disabled?: boolean;
}

interface LandingScreenProps {
aiAssistantEnabled: boolean;
onBuildWithAI: (prompt: string) => void;
onBuildFromScratch: () => void;
isBuildingFromScratch: boolean;
onBrowseTemplates: () => void;
onImportYAML: () => void;
}
Expand All @@ -22,6 +24,7 @@ export function LandingScreen({
aiAssistantEnabled,
onBuildWithAI,
onBuildFromScratch,
isBuildingFromScratch,
onBrowseTemplates,
onImportYAML,
}: LandingScreenProps) {
Expand Down Expand Up @@ -118,6 +121,7 @@ export function LandingScreen({
title="Build from scratch"
description="Start with an empty canvas and pick a trigger as your first step."
onClick={onBuildFromScratch}
disabled={isBuildingFromScratch}
/>
<WorkflowOptionCard
testId="browse-templates-card"
Expand Down Expand Up @@ -150,13 +154,15 @@ function WorkflowOptionCard({
description,
onClick,
testId,
disabled = false,
}: WorkflowOptionCardProps) {
return (
<button
data-testid={testId}
type="button"
onClick={onClick}
className="rounded-xl flex flex-col border border-border-subtle bg-white p-5 text-left hover:border-gray-300 hover:bg-gray-50 transition-colors focus:outline-none focus-visible:ring focus-visible:ring-gray-300"
disabled={disabled}
className="rounded-xl flex flex-col border border-border-subtle bg-white p-5 text-left hover:border-gray-300 hover:bg-gray-50 transition-colors focus:outline-none focus-visible:ring focus-visible:ring-gray-300 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-border-subtle disabled:hover:bg-white"
>
<span className="w-fit inline-flex items-center justify-center rounded-lg bg-gray-100 p-2 mb-3">
<span className={cn('h-5 w-5 text-gray-700', icon)} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';

import { useURLState } from '#/react/lib/use-url-state';

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

import {
Expand Down Expand Up @@ -27,21 +29,47 @@ export function TriggerInspector({
onClose,
onOpenRunPanel: _onOpenRunPanel,
}: TriggerInspectorProps) {
const { params, updateSearchParams } = useURLState();

// `?trigger_view=picker` is a one-shot launch signal (set by the
// build-from-scratch redirect), not durable UI state: it only decides what
// this component mounts into on its very first render. Read with a lazy
// initializer so it's captured once, then cleared below (both from the URL
// and from this flag) — otherwise every later "Edit" click within the same
// TriggerInspector instance would keep re-opening the picker instead of the
// normal Choose step.
const [startedOnPicker, setStartedOnPicker] = useState(
() => params['trigger_view'] === 'picker'
);

// View-state machine. The read-only "show" panel is the resting state for a
// typed trigger; "edit" hands off to the unified wizard (Choose → Configure
// over a local draft).
const [view, setView] = useState<'show' | 'edit'>('show');
// over a local draft). A fresh instance is mounted per trigger id (keyed by
// the caller), so this only ever needs to compute its starting state once.
const [view, setView] = useState<'show' | 'edit'>(() =>
startedOnPicker ? 'edit' : 'show'
);
// When the user enters edit via an inline deep link ("Add authentication" /
// "Configure default response status"), jump straight to Configure with that
// section expanded. `undefined` = the plain Edit button → Choose step. Only
// the webhook show panel produces a non-undefined focus.
const [editFocus, setEditFocus] = useState<EditFocus | undefined>(undefined);

// Reset to the resting state whenever a different trigger is selected.
useEffect(() => {
setView('show');
setEditFocus(undefined);
}, [trigger.id]);
if (!startedOnPicker) return;
// Strip the param with `replace: true` so it patches the current history
// entry in place, rather than pushing a new one that would leave a
// phantom Back-button stop pointing right back at the picker.
updateSearchParams({ trigger_view: null }, { replace: true });
// TriggerEditWizard already captured startOnPicker in its own initial
// state by the time this effect runs, so clearing the flag here doesn't
// affect the picker that's already open — it only prevents a *future*
// Edit click (via the show panel) from reopening the picker again.
setStartedOnPicker(false);
// Run once on mount only: this is consuming the one-shot signal captured
// above, not reacting to subsequent param changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// The "edit" view is reachable only via a show panel's Edit button, which is
// already disabled when the user can't edit the workflow, so no extra
Expand All @@ -51,6 +79,7 @@ export function TriggerInspector({
<TriggerEditWizard
trigger={trigger}
initialFocus={editFocus}
startOnPicker={startedOnPicker}
onClose={onClose}
onDone={() => {
setView('show');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ interface TriggerEditWizardProps {
* start at Choose.
*/
initialFocus?: 'authentication' | 'response' | undefined;
/**
* Open directly on the Picker step ("What triggers this workflow?")
* instead of Choose. Independent of `initialFocus` — the two are never set
* together, since this is only used for the one-shot build-from-scratch
* entry point, not the in-app deep links that produce `initialFocus`.
*/
startOnPicker?: boolean;
/** Close the inspector entirely. */
onClose: () => void;
/**
Expand Down Expand Up @@ -49,6 +56,7 @@ type Step = 'choose' | 'picker' | 'configure';
export function TriggerEditWizard({
trigger,
initialFocus,
startOnPicker,
onClose,
onDone,
}: TriggerEditWizardProps) {
Expand Down Expand Up @@ -82,9 +90,13 @@ export function TriggerEditWizard({
}
);

// Initial step: rest on Choose, or jump straight to Configure on a deep-link.
// (The picker is still reachable mid-flow via the "Change" button.)
const [step, setStep] = useState<Step>(initialFocus ? 'configure' : 'choose');
// Initial step: rest on Choose, jump straight to Configure on a deep-link,
// or jump straight to Picker on the build-from-scratch entry point. Once
// mounted, all navigation (back/pick-type/close) uses the wizard's normal
// step transitions regardless of how this initial step was chosen.
const [step, setStep] = useState<Step>(
startOnPicker ? 'picker' : initialFocus ? 'configure' : 'choose'
);

const finish = useCallback(async () => {
const result = await commit();
Expand Down
33 changes: 24 additions & 9 deletions assets/js/react/lib/use-url-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,28 @@ class URLStore {
// Skip no-op writes so mount-time normalization doesn't stack duplicate
// browser history entries (a no-op pushState never notifies subscribers
// anyway, due to the guard in updateParams). Param order is ignored so a
// reorder-only write is also treated as a no-op.
private pushIfChanged = (newURL: URL) => {
// reorder-only write is also treated as a no-op. Pass `replace: true` to
// patch the current history entry in place instead of pushing a new one —
// for one-shot signals that shouldn't leave a Back-button stop pointing
// back at themselves.
private pushIfChanged = (newURL: URL, options?: { replace?: boolean }) => {
if (this.urlsAreEquivalent(newURL, this.currentURL())) return;
history.pushState({}, '', newURL);
if (options?.replace) {
history.replaceState({}, '', newURL);
} else {
history.pushState({}, '', newURL);
}
};

/**
* Update URL search params (merges with existing params).
* Accepts strings, numbers, booleans; null removes param.
* Pass `{ replace: true }` to patch the current history entry instead of
* pushing a new one.
*/
updateSearchParams = (
updates: Record<string, string | number | boolean | null>
updates: Record<string, string | number | boolean | null>,
options?: { replace?: boolean }
) => {
const newURL = this.currentURL();

Expand All @@ -122,15 +132,18 @@ class URLStore {
}
});

this.pushIfChanged(newURL);
this.pushIfChanged(newURL, options);
};

/**
* Replace all URL search params (clears existing params).
* Accepts strings, numbers, booleans; null skips param.
* Pass `{ replace: true }` to patch the current history entry instead of
* pushing a new one.
*/
replaceSearchParams = (
newParams: Record<string, string | number | boolean | null>
newParams: Record<string, string | number | boolean | null>,
options?: { replace?: boolean }
) => {
const newURL = this.currentURL();
newURL.search = '';
Expand All @@ -139,17 +152,19 @@ class URLStore {
newURL.searchParams.set(key, String(value));
}
});
this.pushIfChanged(newURL);
this.pushIfChanged(newURL, options);
};

/**
* Update the URL hash fragment.
* Pass null to remove hash.
* Pass `{ replace: true }` to patch the current history entry instead of
* pushing a new one.
*/
updateHash = (hash: string | null) => {
updateHash = (hash: string | null, options?: { replace?: boolean }) => {
const newURL = this.currentURL();
newURL.hash = hash ? `#${hash}` : '';
this.pushIfChanged(newURL);
this.pushIfChanged(newURL, options);
};
}

Expand Down
14 changes: 14 additions & 0 deletions assets/test/collaborative-editor/components/LandingScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ function renderLandingScreen(props: {
aiAssistantEnabled?: boolean;
onBuildWithAI?: (prompt: string) => void;
onBuildFromScratch?: () => void;
isBuildingFromScratch?: boolean;
onBrowseTemplates?: () => void;
onImportYAML?: () => void;
}) {
const defaults = {
aiAssistantEnabled: true,
onBuildWithAI: vi.fn(),
onBuildFromScratch: vi.fn(),
isBuildingFromScratch: false,
onBrowseTemplates: vi.fn(),
onImportYAML: vi.fn(),
};
Expand Down Expand Up @@ -174,4 +176,16 @@ describe('LandingScreen - Card click handlers', () => {
await user.click(screen.getByTestId('import-yaml-link'));
expect(onImportYAML).toHaveBeenCalledTimes(1);
});

test('build-from-scratch card is disabled while isBuildingFromScratch and does not call the handler', async () => {
const user = userEvent.setup();
const onBuildFromScratch = vi.fn();
renderLandingScreen({ onBuildFromScratch, isBuildingFromScratch: true });

const card = screen.getByTestId('build-from-scratch-card');
expect(card).toBeDisabled();

await user.click(card);
expect(onBuildFromScratch).not.toHaveBeenCalled();
});
});
Loading