diff --git a/assets/js/collaborative-editor/CollaborativeEditor.tsx b/assets/js/collaborative-editor/CollaborativeEditor.tsx index a86a703097..e720540954 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 0000000000..6e9516d791 --- /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 d42344841f..af8a7e65c0 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 909d4c8649..9701229df0 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 e6fcb16c91..8522950d46 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 ce27994be6..4ad7f53392 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 0000000000..0774ba5a83 --- /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 0000000000..1cad02d8af --- /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 f23e74dba1..25cb0f0ea9 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 cd0f93f38b..72e5d0184d 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 5ff6449ce6..8b5b11a8c6 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 bdf9b24043..e55a125e49 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 afb0759483..517b5c0e2a 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)