From 8dc92e331633ae7362d35bb888ba1207baabc7fb Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Fri, 26 Jun 2026 11:07:25 +0000 Subject: [PATCH] Add Edit in sandbox: create a new sandbox or join an active one From a live workflow, "Edit in sandbox" opens a picker to either branch a new sandbox from the current live version or join an active one, then lands the user in the sandbox editor with that workflow's trigger live and its own endpoint. The parent stays untouched until a change is promoted back (a later slice of the epic). - New channel events: list a parent's active sandboxes (with collaborators and the joinable workflow, sorted by last edited) and create-and-open a sandbox. - Creating clones the parent project, then promotes the edited workflow to live inside the sandbox; the other cloned workflows stay draft so state and triggers remain coherent. The sandbox copy stays editable because the read-only lock only applies to live workflows outside a sandbox. - A sandbox badge in the editor header. Server-side errors (permission, usage limit) surface their real message in the picker rather than a generic one. --- .../CollaborativeEditor.tsx | 3 +- .../components/EditInSandboxPicker.tsx | 346 ++++++++++++++++++ .../components/Header.tsx | 40 +- .../hooks/useWorkflow.tsx | 6 + .../stores/createWorkflowStore.ts | 44 ++- .../js/collaborative-editor/types/workflow.ts | 20 + .../components/EditInSandboxPicker.test.tsx | 158 ++++++++ .../components/Header.sandbox.test.tsx | 188 ++++++++++ lib/lightning/projects.ex | 41 +++ lib/lightning/projects/sandboxes.ex | 4 + .../channels/workflow_channel.ex | 128 +++++++ test/lightning/sandboxes_test.exs | 4 + .../channels/workflow_channel_test.exs | 185 ++++++++++ 13 files changed, 1163 insertions(+), 4 deletions(-) create mode 100644 assets/js/collaborative-editor/components/EditInSandboxPicker.tsx create mode 100644 assets/test/collaborative-editor/components/EditInSandboxPicker.test.tsx create mode 100644 assets/test/collaborative-editor/components/Header.sandbox.test.tsx diff --git a/assets/js/collaborative-editor/CollaborativeEditor.tsx b/assets/js/collaborative-editor/CollaborativeEditor.tsx index a86a7030972..e7205409541 100644 --- a/assets/js/collaborative-editor/CollaborativeEditor.tsx +++ b/assets/js/collaborative-editor/CollaborativeEditor.tsx @@ -2,12 +2,12 @@ import { useMemo, useRef } from 'react'; import { useURLState } from '#/react/lib/use-url-state'; +import { PickerButton } from '../picker/PickerButton'; import { SocketProvider } from '../react/contexts/SocketProvider'; import type { WithActionProps } from '../react/lib/with-props'; import { AIAssistantPanelWrapper } from './components/AIAssistantPanelWrapper'; import { BreadcrumbLink, BreadcrumbText } from './components/Breadcrumbs'; -import { PickerButton } from '../picker/PickerButton'; import type { MonacoHandle } from './components/CollaborativeMonaco'; import { Header } from './components/Header'; import { LoadingBoundary } from './components/LoadingBoundary'; @@ -163,6 +163,7 @@ function BreadcrumbContent({ key="canvas-header" {...(projectId !== undefined && { projectId })} workflowId={workflowId} + isSandbox={isSandbox} isRunPanelOpen={isRunPanelOpen} isIDEOpen={isIDEOpen} aiAssistantEnabled={aiAssistantEnabled} diff --git a/assets/js/collaborative-editor/components/EditInSandboxPicker.tsx b/assets/js/collaborative-editor/components/EditInSandboxPicker.tsx new file mode 100644 index 00000000000..6e9516d7918 --- /dev/null +++ b/assets/js/collaborative-editor/components/EditInSandboxPicker.tsx @@ -0,0 +1,346 @@ +/** + * # Edit in sandbox picker + * + * Modal shown from a live workflow on a non-sandbox project. It offers two + * ways to start editing in a sandbox: + * + * 1. Create a new sandbox branched from the current live version. + * 2. Join an existing active sandbox. + * + * A sandbox is a separate project, so both paths hard-navigate to the sandbox + * project's editor (a new project means a new Y.Doc session), consistent with + * the legacy-editor switch. The list is fetched when the dialog opens and is + * rendered in the order returned by the server (last-edited first). + */ + +import { + Dialog, + DialogBackdrop, + DialogPanel, + DialogTitle, +} from '@headlessui/react'; +import { formatDistanceToNow } from 'date-fns'; +import { useCallback, useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +import { cn } from '../../utils/cn'; +import { useWorkflowActions } from '../hooks/useWorkflow'; +import { ChannelRequestError } from '../lib/errors'; +import type { Sandbox, SandboxCollaborator } from '../types/workflow'; + +interface EditInSandboxPickerProps { + isOpen: boolean; + onClose: () => void; +} + +/** + * The server returns a human-readable reason in `errors.base` (a usage-limit + * upsell, a permission message, etc.). Surface it rather than a generic toast. + */ +function serverMessage(error: unknown): string | null { + if (error instanceof ChannelRequestError) { + return error.errors['base']?.[0] ?? null; + } + return null; +} + +function collaboratorInitials(collaborator: SandboxCollaborator): string { + const source = collaborator.name?.trim() || collaborator.email?.trim() || ''; + if (!source) return '?'; + + const parts = source.split(/\s+/).filter(Boolean); + if (parts.length >= 2 && parts[0] && parts[1]) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + return source.slice(0, 2).toUpperCase(); +} + +function CollaboratorAvatars({ + collaborators, +}: { + collaborators: SandboxCollaborator[]; +}) { + if (collaborators.length === 0) return null; + + const visible = collaborators.slice(0, 4); + const overflow = collaborators.length - visible.length; + + return ( +
+ {visible.map(collaborator => ( + + {collaboratorInitials(collaborator)} + + ))} + {overflow > 0 && ( + + +{overflow} + + )} +
+ ); +} + +function formatUpdatedAt(updatedAt: string): string { + const date = new Date(updatedAt); + if (Number.isNaN(date.getTime())) return ''; + return `edited ${formatDistanceToNow(date, { addSuffix: true })}`; +} + +const navigateToSandbox = (projectId: string, workflowId: string) => { + window.location.href = `/projects/${projectId}/w/${workflowId}`; +}; + +export function EditInSandboxPicker({ + isOpen, + onClose, +}: EditInSandboxPickerProps) { + const { listSandboxes, editInSandbox } = useWorkflowActions(); + + const [name, setName] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const [isLoadingList, setIsLoadingList] = useState(false); + const [sandboxes, setSandboxes] = useState([]); + + useEffect(() => { + if (!isOpen) return; + + let cancelled = false; + setIsLoadingList(true); + setSandboxes([]); + + const load = async () => { + try { + const result = await listSandboxes(); + if (!cancelled) setSandboxes(result); + } catch (error) { + if (!cancelled) { + toast.error( + serverMessage(error) ?? + 'Could not load sandboxes. Please try again.' + ); + } + } finally { + if (!cancelled) setIsLoadingList(false); + } + }; + + void load(); + + return () => { + cancelled = true; + }; + }, [isOpen, listSandboxes]); + + const handleCreate = useCallback(() => { + setIsCreating(true); + const trimmed = name.trim(); + + const create = async () => { + try { + const { project_id, workflow_id } = await editInSandbox( + trimmed || undefined + ); + navigateToSandbox(project_id, workflow_id); + } catch (error) { + toast.error( + serverMessage(error) ?? + 'Could not create a sandbox. Please try again.' + ); + setIsCreating(false); + } + }; + + void create(); + }, [name, editInSandbox]); + + const handleJoin = useCallback((sandbox: Sandbox) => { + if (!sandbox.workflow_id) return; + navigateToSandbox(sandbox.id, sandbox.workflow_id); + }, []); + + return ( + + + +
+
+ + + Edit in sandbox + +

+ Make changes safely in a sandbox without affecting this live + workflow. +

+ + {/* Create a new sandbox */} +
+ +

+ Branches from the current live version. +

+
+ { + setName(event.target.value); + }} + placeholder="Sandbox name (optional)" + disabled={isCreating} + className="block w-full rounded-md border-0 px-3 py-2 text-sm + text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 + placeholder:text-gray-400 focus:ring-2 focus:ring-inset + focus:ring-primary-600 disabled:cursor-not-allowed + disabled:opacity-50" + /> + +
+
+ + {/* Join an existing sandbox */} +
+

+ Join an active sandbox +

+ + {isLoadingList ? ( +

+ Loading sandboxes... +

+ ) : sandboxes.length === 0 ? ( +

+ No active sandboxes yet. +

+ ) : ( +
    + {sandboxes.map(sandbox => { + const canJoin = sandbox.workflow_id !== null; + return ( +
  • +
    +

    + {sandbox.name} +

    +

    + {formatUpdatedAt(sandbox.updated_at)} +

    +
    +
    + + +
    +
  • + ); + })} +
+ )} +
+ +
+ +
+
+
+
+
+ ); +} diff --git a/assets/js/collaborative-editor/components/Header.tsx b/assets/js/collaborative-editor/components/Header.tsx index d42344841f5..af8a7e65c02 100644 --- a/assets/js/collaborative-editor/components/Header.tsx +++ b/assets/js/collaborative-editor/components/Header.tsx @@ -4,11 +4,12 @@ import { toast } from 'sonner'; import { useURLState } from '#/react/lib/use-url-state'; +import { Tooltip } from '../../components/Tooltip'; +import { cn } from '../../utils/cn'; import { buildClassicalEditorUrl } from '../../utils/editorUrlConversion'; import * as dataclipApi from '../api/dataclips'; import { StoreContext } from '../contexts/StoreProvider'; import { channelRequest } from '../hooks/useChannel'; -import { getCsrfToken } from '../lib/csrf'; import { useActiveRun } from '../hooks/useHistory'; import { useSession } from '../hooks/useSession'; import { @@ -34,6 +35,7 @@ import { useWorkflowState, } from '../hooks/useWorkflow'; import { useKeyboardShortcut } from '../keyboard'; +import { getCsrfToken } from '../lib/csrf'; import { notifications } from '../lib/notifications'; import { isFinalState } from '../types/history'; @@ -41,12 +43,12 @@ import { ActiveCollaborators } from './ActiveCollaborators'; import { AIButton } from './AIButton'; import { AlertDialog } from './AlertDialog'; import { Breadcrumbs } from './Breadcrumbs'; +import { EditInSandboxPicker } from './EditInSandboxPicker'; import { EmailVerificationBanner } from './EmailVerificationBanner'; import { GitHubSyncModal } from './GitHubSyncModal'; import { NewRunButton } from './NewRunButton'; import { ReadOnlyWarning } from './ReadOnlyWarning'; import { ShortcutKeys } from './ShortcutKeys'; -import { Tooltip } from '../../components/Tooltip'; /** * Save button component - visible in React DevTools @@ -202,6 +204,7 @@ export function Header({ children, projectId, workflowId, + isSandbox = false, isRunPanelOpen = false, isIDEOpen = false, aiAssistantEnabled = false, @@ -209,6 +212,7 @@ export function Header({ children: React.ReactNode[]; projectId?: string; workflowId?: string; + isSandbox?: boolean; isRunPanelOpen?: boolean; isIDEOpen?: boolean; aiAssistantEnabled?: boolean; @@ -239,6 +243,7 @@ export function Header({ const lifecycleState = sessionWorkflow?.state; const [isTransitioning, setIsTransitioning] = useState(false); const [showSwitchToDraftDialog, setShowSwitchToDraftDialog] = useState(false); + const [showEditInSandboxPicker, setShowEditInSandboxPicker] = useState(false); const activeRun = useActiveRun(); const runIsProcessing = activeRun ? !isFinalState(activeRun.state) : false; const followedRunId = params.run ?? null; @@ -521,6 +526,15 @@ export function Header({
+ {isSandbox && ( + + + sandbox + + )} {lifecycleState && !isNewWorkflow && ( )} + {lifecycleState === 'live' && !isSandbox && !isNewWorkflow && ( + + )} {projectId && workflowId && firstTriggerId && @@ -662,6 +691,13 @@ export function Header({ confirmLabel="Switch to draft" variant="danger" /> + + { + setShowEditInSandboxPicker(false); + }} + />
diff --git a/assets/js/collaborative-editor/hooks/useWorkflow.tsx b/assets/js/collaborative-editor/hooks/useWorkflow.tsx index 909d4c86490..9701229df00 100644 --- a/assets/js/collaborative-editor/hooks/useWorkflow.tsx +++ b/assets/js/collaborative-editor/hooks/useWorkflow.tsx @@ -674,6 +674,12 @@ export const useWorkflowActions = () => { return wrappedSaveAndSyncWorkflow(); }, + // Sandbox editing. listSandboxes is a query (fetch candidates) and + // editInSandbox is a command (provision + clone). Navigation lives in the + // calling component, consistent with the legacy-editor switch. + listSandboxes: store.listSandboxes, + editInSandbox: store.editInSandbox, + resetWorkflow: store.resetWorkflow, importWorkflow: store.importWorkflow, diff --git a/assets/js/collaborative-editor/stores/createWorkflowStore.ts b/assets/js/collaborative-editor/stores/createWorkflowStore.ts index e6fcb16c916..8522950d46f 100644 --- a/assets/js/collaborative-editor/stores/createWorkflowStore.ts +++ b/assets/js/collaborative-editor/stores/createWorkflowStore.ts @@ -144,7 +144,7 @@ import { notifications } from '../lib/notifications'; import { EdgeSchema } from '../types/edge'; import { JobSchema } from '../types/job'; import type { Session } from '../types/session'; -import type { BaseWorkflow, Workflow } from '../types/workflow'; +import type { BaseWorkflow, Sandbox, Workflow } from '../types/workflow'; import { getIncomingEdgeIndices } from '../utils/workflowGraph'; import { createWithSelector } from './common'; @@ -1452,6 +1452,46 @@ export const createWorkflowStore = () => { const goLive = async () => setLifecycleState('go_live'); const switchToDraft = async () => setLifecycleState('switch_to_draft'); + // Sandbox editing. From a live workflow on a non-sandbox project, a user can + // either branch the current live version into a freshly provisioned sandbox + // or join an existing sandbox. The server owns provisioning and cloning; the + // client only lists candidates and requests creation, then hard-navigates + // into the resulting sandbox project (a new project = a new Y.Doc session). + const listSandboxes = async (): Promise => { + const { provider } = ensureConnected(); + + try { + const response = await channelRequest<{ sandboxes: Sandbox[] }>( + provider.channel, + 'list_sandboxes', + {} + ); + return response.sandboxes; + } catch (error) { + logger.error('Failed to list sandboxes', error); + throw error; + } + }; + + const editInSandbox = async ( + name?: string + ): Promise<{ project_id: string; workflow_id: string }> => { + const { provider } = ensureConnected(); + + const payload = name ? { name } : {}; + + try { + return await channelRequest<{ project_id: string; workflow_id: string }>( + provider.channel, + 'edit_in_sandbox', + payload + ); + } catch (error) { + logger.error('Failed to edit in sandbox', error); + throw error; + } + }; + const saveAndSyncWorkflow = async ( commitMessage: string ): Promise<{ @@ -1948,6 +1988,8 @@ export const createWorkflowStore = () => { saveWorkflow, goLive, switchToDraft, + listSandboxes, + editInSandbox, saveAndSyncWorkflow, resetWorkflow, validateWorkflowName, diff --git a/assets/js/collaborative-editor/types/workflow.ts b/assets/js/collaborative-editor/types/workflow.ts index ce27994be61..4ad7f53392a 100644 --- a/assets/js/collaborative-editor/types/workflow.ts +++ b/assets/js/collaborative-editor/types/workflow.ts @@ -55,6 +55,26 @@ export const BaseWorkflowSchema = z.object({ export type BaseWorkflow = z.infer; +/** + * A sandbox project that can be joined or branched from when editing a live + * workflow. `workflow_id` is the id of this workflow's clone inside the + * sandbox, and is null when the workflow does not exist in that sandbox. + */ +export interface SandboxCollaborator { + id: string; + name?: string; + email?: string; +} + +export interface Sandbox { + id: string; + name: string; + color: string | null; + updated_at: string; + collaborators: SandboxCollaborator[]; + workflow_id: string | null; +} + /** * Creates a workflow schema with dynamic project concurrency validation * diff --git a/assets/test/collaborative-editor/components/EditInSandboxPicker.test.tsx b/assets/test/collaborative-editor/components/EditInSandboxPicker.test.tsx new file mode 100644 index 00000000000..0774ba5a837 --- /dev/null +++ b/assets/test/collaborative-editor/components/EditInSandboxPicker.test.tsx @@ -0,0 +1,158 @@ +/** + * EditInSandboxPicker Component Tests + * + * Focused tests for the sandbox picker modal: + * - Renders the "create new sandbox" option. + * - Lists active sandboxes returned by the server (in order). + * - Disables the join action when a sandbox lacks this workflow's clone. + * - Creating a sandbox navigates to the new project's editor. + * + * The picker's only collaboration dependency is useWorkflowActions, which we + * mock so we can drive listSandboxes/editInSandbox deterministically. + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { EditInSandboxPicker } from '../../../js/collaborative-editor/components/EditInSandboxPicker'; +import type { Sandbox } from '../../../js/collaborative-editor/types/workflow'; + +const listSandboxes = vi.fn<() => Promise>(); +const editInSandbox = + vi.fn< + (name?: string) => Promise<{ project_id: string; workflow_id: string }> + >(); + +vi.mock('../../../js/collaborative-editor/hooks/useWorkflow', () => ({ + useWorkflowActions: () => ({ listSandboxes, editInSandbox }), +})); + +const sandboxes: Sandbox[] = [ + { + id: 'sandbox-a', + name: 'Alpha sandbox', + color: null, + updated_at: new Date().toISOString(), + collaborators: [ + { id: 'u1', name: 'Ada Lovelace' }, + { id: 'u2', email: 'grace@example.com' }, + ], + workflow_id: 'wf-clone-a', + }, + { + id: 'sandbox-b', + name: 'Beta sandbox', + color: '#ff0000', + updated_at: new Date().toISOString(), + collaborators: [], + workflow_id: null, + }, +]; + +describe('EditInSandboxPicker', () => { + beforeEach(() => { + listSandboxes.mockReset(); + editInSandbox.mockReset(); + listSandboxes.mockResolvedValue([]); + }); + + test('renders the create option and fetches sandboxes on open', async () => { + listSandboxes.mockResolvedValue([]); + + render( {}} />); + + expect(screen.getByText('Create a new sandbox')).toBeInTheDocument(); + expect(screen.getByTestId('create-sandbox-button')).toBeInTheDocument(); + + await waitFor(() => { + expect(listSandboxes).toHaveBeenCalledTimes(1); + }); + + // Empty state when no sandboxes exist + await waitFor(() => { + expect(screen.getByTestId('sandbox-list-empty')).toBeInTheDocument(); + }); + }); + + test('does not fetch when closed', () => { + render( {}} />); + expect(listSandboxes).not.toHaveBeenCalled(); + }); + + test('lists active sandboxes in returned order', async () => { + listSandboxes.mockResolvedValue(sandboxes); + + render( {}} />); + + await waitFor(() => { + expect(screen.getByTestId('sandbox-list')).toBeInTheDocument(); + }); + + const rows = screen.getAllByTestId('sandbox-row'); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveTextContent('Alpha sandbox'); + expect(rows[1]).toHaveTextContent('Beta sandbox'); + }); + + test('disables join when the workflow is not in that sandbox', async () => { + listSandboxes.mockResolvedValue(sandboxes); + + render( {}} />); + + await waitFor(() => { + expect(screen.getByTestId('sandbox-list')).toBeInTheDocument(); + }); + + const joinButtons = screen.getAllByTestId('join-sandbox-button'); + // First sandbox has a workflow clone -> enabled + expect(joinButtons[0]).toBeEnabled(); + // Second sandbox has workflow_id null -> disabled with explanation + expect(joinButtons[1]).toBeDisabled(); + expect(joinButtons[1]).toHaveAttribute( + 'title', + "This workflow isn't in that sandbox" + ); + }); + + test('creating a sandbox navigates to the new project editor', async () => { + const user = userEvent.setup(); + listSandboxes.mockResolvedValue([]); + editInSandbox.mockResolvedValue({ + project_id: 'new-project', + workflow_id: 'new-workflow', + }); + + const originalLocation = window.location; + const hrefSetter = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + set href(value: string) { + hrefSetter(value); + }, + }, + }); + + try { + render( {}} />); + + await user.click(screen.getByTestId('create-sandbox-button')); + + await waitFor(() => { + expect(editInSandbox).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(hrefSetter).toHaveBeenCalledWith( + '/projects/new-project/w/new-workflow' + ); + }); + } finally { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + } + }); +}); diff --git a/assets/test/collaborative-editor/components/Header.sandbox.test.tsx b/assets/test/collaborative-editor/components/Header.sandbox.test.tsx new file mode 100644 index 00000000000..1cad02d8af5 --- /dev/null +++ b/assets/test/collaborative-editor/components/Header.sandbox.test.tsx @@ -0,0 +1,188 @@ +/** + * Header sandbox-affordance tests + * + * Focused on the gating rules introduced for "Edit in sandbox": + * - The "Edit in sandbox" button appears only when the workflow is live, + * the current project is not itself a sandbox, and the workflow is saved + * (not new). + * - The sandbox badge appears when editing inside a sandbox. + * + * The Header pulls in many collaboration hooks and child components; those are + * mocked to neutral defaults so each test exercises only the gating logic. + */ + +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { Header } from '../../../js/collaborative-editor/components/Header'; + +// --------------------------------------------------------------------------- +// Hook + child-component mocks +// --------------------------------------------------------------------------- + +let lifecycleState: 'draft' | 'live' | undefined = 'live'; +let isNewWorkflow = false; + +vi.mock('../../../js/react/lib/use-url-state', () => ({ + useURLState: () => ({ params: {}, updateSearchParams: vi.fn() }), +})); + +vi.mock('../../../js/collaborative-editor/hooks/useHistory', () => ({ + useActiveRun: () => null, +})); + +vi.mock('../../../js/collaborative-editor/hooks/useSession', () => ({ + useSession: () => ({ provider: null, isSynced: true }), +})); + +vi.mock('../../../js/collaborative-editor/hooks/useSessionContext', () => ({ + useIsNewWorkflow: () => isNewWorkflow, + useLimits: () => ({}), + useProjectRepoConnection: () => null, + useSessionWorkflow: () => ({ state: lifecycleState }), +})); + +vi.mock('../../../js/collaborative-editor/hooks/useUI', () => ({ + useImportPanelState: () => null, + useIsCreateWorkflowPanelCollapsed: () => true, + useTemplatePanel: () => ({ selectedTemplate: null }), + useUICommands: () => ({ + openRunPanel: vi.fn(), + openGitHubSyncModal: vi.fn(), + }), +})); + +vi.mock('../../../js/collaborative-editor/hooks/useUnsavedChanges', () => ({ + useUnsavedChanges: () => ({ hasChanges: false }), +})); + +vi.mock('../../../js/collaborative-editor/hooks/useWorkflow', () => ({ + useCanRun: () => ({ canRun: true }), + useCanSave: () => ({ canSave: true, tooltipMessage: '' }), + useNodeSelection: () => ({ selectNode: vi.fn() }), + useWorkflowActions: () => ({ + saveWorkflow: vi.fn(), + goLive: vi.fn(), + switchToDraft: vi.fn(), + listSandboxes: vi.fn(), + editInSandbox: vi.fn(), + }), + useWorkflowReadOnly: () => ({ isReadOnly: false }), + useWorkflowSettingsErrors: () => ({ hasErrors: false }), + useWorkflowState: (selector: (state: unknown) => unknown) => + selector({ triggers: [], jobs: [] }), +})); + +vi.mock('../../../js/collaborative-editor/keyboard', () => ({ + useKeyboardShortcut: vi.fn(), +})); + +// Header reads StoreContext via useContext with optional chaining, so leaving +// it unprovided (undefined) is handled gracefully and avoids extra wiring. + +// Child components rendered by Header that are irrelevant to the gating logic. +vi.mock( + '../../../js/collaborative-editor/components/ActiveCollaborators', + () => ({ + ActiveCollaborators: () =>
, + }) +); +vi.mock('../../../js/collaborative-editor/components/AIButton', () => ({ + AIButton: () =>
, +})); +vi.mock( + '../../../js/collaborative-editor/components/EmailVerificationBanner', + () => ({ EmailVerificationBanner: () =>
}) +); +vi.mock('../../../js/collaborative-editor/components/GitHubSyncModal', () => ({ + GitHubSyncModal: () =>
, +})); +vi.mock('../../../js/collaborative-editor/components/NewRunButton', () => ({ + NewRunButton: () =>
, +})); +vi.mock('../../../js/collaborative-editor/components/ReadOnlyWarning', () => ({ + ReadOnlyWarning: () =>
, +})); +vi.mock( + '../../../js/collaborative-editor/components/EditInSandboxPicker', + () => ({ + EditInSandboxPicker: () =>
, + }) +); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const renderHeader = ( + props: Partial> = {} +) => + render( +
+ {[]} +
+ ); + +describe('Header - Edit in sandbox button gating', () => { + beforeEach(() => { + lifecycleState = 'live'; + isNewWorkflow = false; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('shows the button for a live, saved workflow on a non-sandbox project', () => { + renderHeader({ isSandbox: false }); + expect(screen.getByTestId('edit-in-sandbox-button')).toBeInTheDocument(); + }); + + test('hides the button when the workflow is in draft', () => { + lifecycleState = 'draft'; + renderHeader({ isSandbox: false }); + expect( + screen.queryByTestId('edit-in-sandbox-button') + ).not.toBeInTheDocument(); + }); + + test('hides the button when already inside a sandbox', () => { + renderHeader({ isSandbox: true }); + expect( + screen.queryByTestId('edit-in-sandbox-button') + ).not.toBeInTheDocument(); + }); + + test('hides the button for a new (unsaved) workflow', () => { + isNewWorkflow = true; + renderHeader({ isSandbox: false }); + expect( + screen.queryByTestId('edit-in-sandbox-button') + ).not.toBeInTheDocument(); + }); +}); + +describe('Header - sandbox badge', () => { + beforeEach(() => { + lifecycleState = 'live'; + isNewWorkflow = false; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('renders the sandbox badge when editing inside a sandbox', () => { + renderHeader({ isSandbox: true }); + const badge = screen.getByTestId('workflow-sandbox-badge'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent('sandbox'); + }); + + test('does not render the sandbox badge outside a sandbox', () => { + renderHeader({ isSandbox: false }); + expect( + screen.queryByTestId('workflow-sandbox-badge') + ).not.toBeInTheDocument(); + }); +}); diff --git a/lib/lightning/projects.ex b/lib/lightning/projects.ex index f23e74dba1b..25cb0f0ea9c 100644 --- a/lib/lightning/projects.ex +++ b/lib/lightning/projects.ex @@ -1896,6 +1896,47 @@ defmodule Lightning.Projects do |> Repo.all() end + @doc """ + Lists a parent project's active sandboxes for the "Edit in sandbox" picker. + + Returns only the direct children of `parent_id` that are not scheduled for + deletion, sorted by `updated_at` descending as a "last edited" proxy (there is + no dedicated last-editor field). Each sandbox preloads its `project_users` (and + their users) for collaborator display, and resolves the clone of the workflow + named `workflow_name` so the caller can offer a direct "join" target. The + resolved workflow id is returned as `:joinable_workflow_id`, or `nil` when the + sandbox has no workflow with that name. + """ + @spec list_active_sandboxes_for_editing(Ecto.UUID.t(), String.t()) :: [ + {Project.t(), Ecto.UUID.t() | nil} + ] + def list_active_sandboxes_for_editing(parent_id, workflow_name) + when is_binary(parent_id) and is_binary(workflow_name) do + sandboxes = + from(p in Project, + where: p.parent_id == ^parent_id and is_nil(p.scheduled_deletion), + order_by: [desc: p.updated_at], + preload: [project_users: :user] + ) + |> Repo.all() + + sandbox_ids = Enum.map(sandboxes, & &1.id) + + joinable_workflow_ids = + from(w in Workflow, + where: + w.project_id in ^sandbox_ids and w.name == ^workflow_name and + is_nil(w.deleted_at), + select: {w.project_id, w.id} + ) + |> Repo.all() + |> Map.new() + + Enum.map(sandboxes, fn sandbox -> + {sandbox, Map.get(joinable_workflow_ids, sandbox.id)} + end) + end + @doc """ Checks if a sandbox with the given name exists under the parent project. diff --git a/lib/lightning/projects/sandboxes.ex b/lib/lightning/projects/sandboxes.ex index cd0f93f38bf..72e5d0184da 100644 --- a/lib/lightning/projects/sandboxes.ex +++ b/lib/lightning/projects/sandboxes.ex @@ -761,6 +761,10 @@ defmodule Lightning.Projects.Sandboxes do enable_job_logs: parent_workflow.enable_job_logs, positions: %{} }) + # Cloned triggers are inserted disabled, so the workflow must start as a + # draft to keep state and trigger-enabled status coherent. The caller can + # promote a specific clone to :live afterwards (e.g. "Edit in sandbox"). + |> Ecto.Changeset.put_change(:state, :draft) |> Repo.insert() Map.put(mapping, parent_workflow.id, sandbox_workflow.id) diff --git a/lib/lightning_web/channels/workflow_channel.ex b/lib/lightning_web/channels/workflow_channel.ex index 5ff6449ce66..8b5b11a8c62 100644 --- a/lib/lightning_web/channels/workflow_channel.ex +++ b/lib/lightning_web/channels/workflow_channel.ex @@ -14,9 +14,12 @@ defmodule LightningWeb.WorkflowChannel do alias Lightning.Collaboration.Utils alias Lightning.Collaboration.WorkflowResolver alias Lightning.Policies.Permissions + alias Lightning.Projects + alias Lightning.Projects.ProjectLimiter alias Lightning.Repo alias Lightning.VersionControl alias Lightning.VersionControl.VersionControlUsageLimiter + alias Lightning.Workflows alias Lightning.Workflows.Job alias Lightning.Workflows.WorkflowUsageLimiter alias Lightning.WorkOrders @@ -383,6 +386,44 @@ defmodule LightningWeb.WorkflowChannel do transition_lifecycle_state(socket, :draft) end + @impl true + def handle_in("list_sandboxes", _params, socket) do + project = socket.assigns.project + workflow = socket.assigns.workflow + + sandboxes = + project.id + |> Projects.list_active_sandboxes_for_editing(workflow.name) + |> Enum.map(&render_editable_sandbox/1) + + {:reply, {:ok, %{sandboxes: sandboxes}}, socket} + end + + @impl true + def handle_in("edit_in_sandbox", params, socket) do + parent = socket.assigns.project + workflow = socket.assigns.workflow + user = socket.assigns.current_user + + name = sandbox_name(params, workflow, parent) + + with :ok <- authorize_provision_sandbox(user, parent), + :ok <- limit_new_sandbox(parent), + {:ok, sandbox} <- + Projects.provision_sandbox(parent, user, %{ + name: name, + env: "dev", + color: random_sandbox_color() + }), + {:ok, cloned_workflow} <- + promote_cloned_workflow(sandbox, workflow.name, user) do + {:reply, {:ok, %{project_id: sandbox.id, workflow_id: cloned_workflow.id}}, + socket} + else + error -> workflow_error_reply(socket, error) + end + end + @impl true def handle_in("save_and_sync", params, socket) when not is_map_key(params, "commit_message") do @@ -1149,6 +1190,93 @@ defmodule LightningWeb.WorkflowChannel do end end + defp authorize_provision_sandbox(user, parent) do + if Permissions.can?(:sandboxes, :provision_sandbox, user, parent) do + :ok + else + {:error, + %{ + type: "unauthorized", + message: "You don't have permission to create a sandbox here" + }} + end + end + + defp limit_new_sandbox(parent) do + case ProjectLimiter.limit_new_sandbox(parent.id) do + :ok -> :ok + {:error, _reason, message} -> {:error, message} + end + end + + defp promote_cloned_workflow(sandbox, workflow_name, user) do + cloned = + from(w in Lightning.Workflows.Workflow, + where: + w.project_id == ^sandbox.id and w.name == ^workflow_name and + is_nil(w.deleted_at), + limit: 1 + ) + |> Repo.one() + + case cloned do + %Lightning.Workflows.Workflow{} = workflow -> + Workflows.go_live(workflow, user) + + nil -> + {:error, :internal_error} + end + end + + defp sandbox_name(params, workflow, parent) do + raw = + case params do + %{"name" => name} when is_binary(name) and name != "" -> name + _ -> default_sandbox_name(workflow, parent) + end + + Lightning.Helpers.url_safe_name(raw) + end + + defp default_sandbox_name(workflow, parent) do + base = workflow.name || parent.name || "sandbox" + "#{base}-sandbox" + end + + defp random_sandbox_color do + LightningWeb.SandboxLive.Components.color_palette_hex_colors() + |> Enum.random() + end + + defp render_editable_sandbox({sandbox, joinable_workflow_id}) do + %{ + id: sandbox.id, + name: sandbox.name, + color: sandbox.color, + updated_at: sandbox.updated_at, + collaborators: Enum.map(sandbox.project_users, &render_collaborator/1), + workflow_id: joinable_workflow_id + } + end + + defp render_collaborator(%{user: user}) do + %{ + id: user.id, + name: collaborator_name(user), + email: user.email + } + end + + defp collaborator_name(user) do + [user.first_name, user.last_name] + |> Enum.reject(&(is_nil(&1) or &1 == "")) + |> Enum.join(" ") + |> case do + "" -> user.email + name -> name + end + end + defp authorize_publish_template(socket) do user = socket.assigns.current_user project = socket.assigns.project diff --git a/test/lightning/sandboxes_test.exs b/test/lightning/sandboxes_test.exs index bdf9b240433..e55a125e494 100644 --- a/test/lightning/sandboxes_test.exs +++ b/test/lightning/sandboxes_test.exs @@ -389,6 +389,10 @@ defmodule Lightning.Projects.SandboxesTest do assert s_triggers != [] assert Enum.all?(s_triggers, &match?(false, &1.enabled)) + # Cloned workflows must start as drafts so their :state stays coherent with + # their disabled triggers. + assert Enum.all?(s_wfs, &(&1.state == :draft)) + s_edges = from(e in Edge, join: w in assoc(e, :workflow), diff --git a/test/lightning_web/channels/workflow_channel_test.exs b/test/lightning_web/channels/workflow_channel_test.exs index afb0759483a..517b5c0e2ad 100644 --- a/test/lightning_web/channels/workflow_channel_test.exs +++ b/test/lightning_web/channels/workflow_channel_test.exs @@ -78,6 +78,191 @@ defmodule LightningWeb.WorkflowChannelTest do end end + describe "list_sandboxes" do + test "returns active sandboxes sorted by last edited with joinable workflow and collaborators", + %{socket: socket, project: project, workflow: workflow} do + collaborator = + insert(:user, first_name: "Ada", last_name: "Lovelace") + + # Older sandbox with a matching workflow clone (joinable). + older = + insert(:project, + parent: project, + color: "#111111", + project_users: [%{user: collaborator, role: :editor}] + ) + + joinable = + insert(:workflow, project: older, name: workflow.name) + + # Newer sandbox without a matching workflow name (not joinable). + newer = insert(:project, parent: project, color: "#222222") + insert(:workflow, project: newer, name: "something-else") + + # Bump newer's updated_at so it sorts first. + Lightning.Repo.update!( + Ecto.Changeset.change(newer, updated_at: ~U[2030-01-01 00:00:00Z]) + ) + + Lightning.Repo.update!( + Ecto.Changeset.change(older, updated_at: ~U[2020-01-01 00:00:00Z]) + ) + + ref = push(socket, "list_sandboxes", %{}) + assert_reply ref, :ok, %{sandboxes: sandboxes} + + assert [first, second] = sandboxes + assert first.id == newer.id + assert first.workflow_id == nil + assert second.id == older.id + assert second.workflow_id == joinable.id + + assert [%{name: "Ada Lovelace", email: collaborator_email}] = + second.collaborators + + assert collaborator_email == collaborator.email + end + + test "excludes sandboxes scheduled for deletion", %{ + socket: socket, + project: project + } do + active = insert(:project, parent: project) + + scheduled = + insert(:project, + parent: project, + scheduled_deletion: ~U[2030-01-01 00:00:00Z] + ) + + ref = push(socket, "list_sandboxes", %{}) + assert_reply ref, :ok, %{sandboxes: sandboxes} + + ids = Enum.map(sandboxes, & &1.id) + assert active.id in ids + refute scheduled.id in ids + end + end + + describe "edit_in_sandbox" do + setup %{project: project, workflow: workflow} do + Mox.stub_with( + Lightning.Extensions.MockProjectHook, + Lightning.Extensions.ProjectHook + ) + + # Give the workflow a real trigger so the clone has something to enable. + trigger = + insert(:trigger, workflow: workflow, type: :webhook, enabled: true) + + job = insert(:job, workflow: workflow) + + insert(:edge, + workflow: workflow, + source_trigger: trigger, + target_job: job, + condition_type: :always + ) + + # A second, unrelated workflow in the same project: its clone must stay + # a draft with disabled triggers. + other_workflow = insert(:workflow, project: project, name: "other-wf") + insert(:trigger, workflow: other_workflow, type: :webhook, enabled: true) + + %{trigger: trigger, other_workflow: other_workflow} + end + + test "provisions a sandbox, promotes the edited workflow, leaves others as drafts", + %{ + socket: socket, + project: project, + workflow: workflow, + other_workflow: other_workflow + } do + ref = push(socket, "edit_in_sandbox", %{}) + assert_reply ref, :ok, %{project_id: sandbox_id, workflow_id: cloned_id} + + sandbox = Lightning.Projects.get_project!(sandbox_id) + assert sandbox.parent_id == project.id + + # Edited clone is live with an enabled trigger. + cloned = + Lightning.Workflows.get_workflow!(cloned_id, include: [:triggers]) + + assert cloned.name == workflow.name + assert cloned.state == :live + assert Enum.all?(cloned.triggers, & &1.enabled) + + # The other clone stays a draft with disabled triggers. + other_clone = + Lightning.Workflows.Workflow + |> Lightning.Repo.get_by( + project_id: sandbox_id, + name: other_workflow.name + ) + |> Lightning.Repo.preload(:triggers) + + assert other_clone.state == :draft + refute Enum.any?(other_clone.triggers, & &1.enabled) + + # Parent workflow is untouched. + parent_workflow = + Lightning.Workflows.get_workflow!(workflow.id, include: [:triggers]) + + assert parent_workflow.state == workflow.state + end + + test "uses a provided name", %{socket: socket} do + ref = push(socket, "edit_in_sandbox", %{"name" => "My Custom Name"}) + assert_reply ref, :ok, %{project_id: sandbox_id} + + sandbox = Lightning.Projects.get_project!(sandbox_id) + assert sandbox.name == "my-custom-name" + end + + test "respects the new-sandbox usage limit", %{socket: socket} do + message = %Lightning.Extensions.Message{text: "Sandbox limit reached"} + + Mox.stub( + Lightning.Extensions.MockUsageLimiter, + :limit_action, + fn + %{type: :new_sandbox}, _ctx -> {:error, :too_many_sandboxes, message} + _action, _ctx -> :ok + end + ) + + ref = push(socket, "edit_in_sandbox", %{}) + + assert_reply ref, :error, %{ + type: "limit_error", + errors: %{base: ["Sandbox limit reached"]} + } + end + + test "rejects users without provision permission", %{ + project: project, + workflow: workflow + } do + viewer = insert(:user) + insert(:project_user, project: project, user: viewer, role: :viewer) + + {:ok, _, viewer_socket} = + LightningWeb.UserSocket + |> socket("user_#{viewer.id}", %{current_user: viewer}) + |> subscribe_and_join( + LightningWeb.WorkflowChannel, + "workflow:collaborate:#{workflow.id}", + %{"project_id" => project.id, "action" => "edit"} + ) + + on_exit(fn -> ensure_doc_supervisor_stopped(workflow.id) end) + + ref = push(viewer_socket, "edit_in_sandbox", %{}) + assert_reply ref, :error, %{type: "unauthorized"} + end + end + describe "join authorization" do test "rejects unauthorized users", %{workflow: workflow, project: project} do unauthorized_user = insert(:user)