diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e82d0b6cf..ab6a5cc45df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ and this project adheres to ### Changed +- Project picker now shows sandbox hierarchy with parent project name (e.g. + `root:sandbox`), sandbox accent color, and nested tree view. Sandbox theming + removed from sidebar/navbar. + [#4510](https://github.com/OpenFn/lightning/issues/4510) + ### Fixed - Bump `@openfn/ws-worker` from diff --git a/assets/css/app.css b/assets/css/app.css index b42f26dddb7..9b9f81e08a9 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -443,17 +443,10 @@ opacity: 1; } - .project-picker-text, - .project-picker-chevron { - display: block; - } - - #project-picker-wrapper, #user-menu-wrapper { display: block; } - #project-picker-trigger, #user-menu-trigger { width: 100%; } @@ -498,9 +491,7 @@ display: flex; } - .user-menu-chevron, - .project-picker-text, - .project-picker-chevron { + .user-menu-chevron { display: block; } @@ -556,8 +547,6 @@ gap: 0; } - .project-picker-text, - .project-picker-chevron, .user-menu-text, .user-menu-chevron { display: none; @@ -609,7 +598,6 @@ } /* Wrappers stay block, elements handle centering internally */ - #project-picker-wrapper, #user-menu-wrapper { display: block; } diff --git a/assets/js/collaborative-editor/CollaborativeEditor.tsx b/assets/js/collaborative-editor/CollaborativeEditor.tsx index 37e8066834c..d1864f76a8b 100644 --- a/assets/js/collaborative-editor/CollaborativeEditor.tsx +++ b/assets/js/collaborative-editor/CollaborativeEditor.tsx @@ -6,11 +6,8 @@ import { SocketProvider } from '../react/contexts/SocketProvider'; import type { WithActionProps } from '../react/lib/with-props'; import { AIAssistantPanelWrapper } from './components/AIAssistantPanelWrapper'; -import { - BreadcrumbLink, - BreadcrumbProjectPicker, - BreadcrumbText, -} from './components/Breadcrumbs'; +import { BreadcrumbLink, BreadcrumbText } from './components/Breadcrumbs'; +import { ProjectPickerButton } from '../project-picker/ProjectPickerButton'; import type { MonacoHandle } from './components/CollaborativeMonaco'; import { Header } from './components/Header'; import { LoadingBoundary } from './components/LoadingBoundary'; @@ -37,10 +34,12 @@ export interface CollaborativeEditorDataProps { 'data-workflow-name': string; 'data-project-id': string; 'data-project-name'?: string; + 'data-project-display-name'?: string; + 'data-project-is-sandbox'?: string; 'data-project-color'?: string; - 'data-project-env'?: string; 'data-root-project-id'?: string; 'data-root-project-name'?: string; + 'data-project-env'?: string; 'data-is-new-workflow'?: string; 'data-ai-assistant-enabled'?: string; // Initial run data from server to avoid client-side race conditions @@ -64,6 +63,9 @@ interface BreadcrumbContentProps { workflowName: string; projectIdFallback?: string; projectNameFallback?: string; + projectDisplayNameFallback?: string | null; + projectIsSandboxFallback?: string; + projectColorFallback?: string | null; projectEnvFallback?: string; isNewWorkflow?: boolean; aiAssistantEnabled: boolean; @@ -74,6 +76,9 @@ function BreadcrumbContent({ workflowName, projectIdFallback, projectNameFallback, + projectDisplayNameFallback, + projectIsSandboxFallback, + projectColorFallback, projectEnvFallback, isNewWorkflow = false, aiAssistantEnabled, @@ -91,25 +96,22 @@ function BreadcrumbContent({ const projectId = projectFromStore?.id ?? projectIdFallback; const projectName = projectFromStore?.name ?? projectNameFallback; const projectEnv = projectFromStore?.env ?? projectEnvFallback; + const displayName = projectDisplayNameFallback ?? projectName; + const projectColor = projectColorFallback ?? null; + const isSandbox = projectIsSandboxFallback === 'true'; const currentWorkflowName = workflowFromStore?.name ?? workflowName; const handleVersionSelect = useVersionSelect(); - const handleProjectPickerClick = (e: React.MouseEvent) => { - e.preventDefault(); - // Dispatch the event that the global ProjectPicker listens for - document.body.dispatchEvent(new CustomEvent('open-project-picker')); - }; - const breadcrumbElements = useMemo(() => { return [ // Project name as picker trigger - - {projectName} - , + data-label={displayName ?? ''} + data-is-sandbox={isSandbox ? 'true' : 'false'} + data-color={projectColor ?? undefined} + />, Workflows , @@ -142,7 +144,9 @@ function BreadcrumbContent({ ]; }, [ projectId, - projectName, + displayName, + isSandbox, + projectColor, projectEnv, currentWorkflowName, workflowId, @@ -172,9 +176,12 @@ export const CollaborativeEditor: WithActionProps< const workflowName = props['data-workflow-name']; const projectId = props['data-project-id']; const projectName = props['data-project-name']; - const projectEnv = props['data-project-env']; + const projectDisplayName = props['data-project-display-name'] ?? null; + const projectIsSandbox = props['data-project-is-sandbox'] ?? 'false'; + const projectColor = props['data-project-color'] ?? null; const rootProjectId = props['data-root-project-id'] ?? null; const rootProjectName = props['data-root-project-name'] ?? null; + const projectEnv = props['data-project-env']; const isNewWorkflow = props['data-is-new-workflow'] === 'true'; const aiAssistantEnabled = props['data-ai-assistant-enabled'] === 'true'; const initialRunData = props['data-initial-run-data']; @@ -220,14 +227,15 @@ export const CollaborativeEditor: WithActionProps< {...(projectName !== undefined && { projectNameFallback: projectName, })} - {...(projectEnv !== undefined && { - projectEnvFallback: projectEnv, + {...(projectDisplayName !== null && { + projectDisplayNameFallback: projectDisplayName, })} - {...(rootProjectId !== null && { - rootProjectIdFallback: rootProjectId, + projectIsSandboxFallback={projectIsSandbox} + {...(projectColor !== null && { + projectColorFallback: projectColor, })} - {...(rootProjectName !== null && { - rootProjectNameFallback: rootProjectName, + {...(projectEnv !== undefined && { + projectEnvFallback: projectEnv, })} />
diff --git a/assets/js/collaborative-editor/components/Breadcrumbs.tsx b/assets/js/collaborative-editor/components/Breadcrumbs.tsx index a293c4cff8f..b82532f2bda 100644 --- a/assets/js/collaborative-editor/components/Breadcrumbs.tsx +++ b/assets/js/collaborative-editor/components/Breadcrumbs.tsx @@ -126,22 +126,3 @@ export function BreadcrumbText({ ); } - -export function BreadcrumbProjectPicker({ - children, - onClick, -}: { - children: React.ReactNode; - onClick?: (e: React.MouseEvent) => void; -}) { - return ( - - ); -} diff --git a/assets/js/project-picker/ProjectPicker.tsx b/assets/js/project-picker/ProjectPicker.tsx index 24b9bd9f4f5..6f6e2fadcbf 100644 --- a/assets/js/project-picker/ProjectPicker.tsx +++ b/assets/js/project-picker/ProjectPicker.tsx @@ -5,10 +5,24 @@ import { cn } from '../utils/cn'; export interface Project { id: string; name: string; + color?: string | null; + parent_id?: string | null; +} + +/** Flattened item for keyboard navigation and rendering. */ +interface PickerItem { + type: 'project' | 'sandbox'; + id: string; + /** Display label — just the project's own name. */ + label: string; + /** Full path (parent:child:...) used for search matching. */ + searchLabel: string; + depth: number; + color?: string | null | undefined; } interface ProjectPickerProps { - 'data-projects': string; // JSON-encoded array of {id, name} + 'data-projects': string; 'data-current-project-id'?: string; } @@ -17,6 +31,9 @@ interface ProjectPickerProps { * * Mounted via ReactComponent hook in LiveView layouts. * Opens with Cmd/Ctrl+P keyboard shortcut. + * + * Projects are listed at the top level. Sandboxes belonging to each project + * are nested underneath their parent as indented children. */ export function ProjectPicker(props: ProjectPickerProps) { const [isOpen, setIsOpen] = useState(false); @@ -25,7 +42,6 @@ export function ProjectPicker(props: ProjectPickerProps) { const inputRef = useRef(null); const listRef = useRef(null); - // Detect macOS for keyboard shortcut display const isMac = useMemo( () => typeof navigator !== 'undefined' && @@ -34,10 +50,10 @@ export function ProjectPicker(props: ProjectPickerProps) { ); const projects = useMemo(() => { - const projectsJson = props['data-projects']; - if (!projectsJson) return []; + const json = props['data-projects']; + if (!json) return []; try { - return JSON.parse(projectsJson) as Project[]; + return JSON.parse(json) as Project[]; } catch { return []; } @@ -45,19 +61,97 @@ export function ProjectPicker(props: ProjectPickerProps) { const currentProjectId = props['data-current-project-id']; - const filteredProjects = useMemo(() => { - if (!searchTerm) return projects; + /** + * Build a display label for a project by walking up the parent chain. + * e.g. "root:child:grandchild" + */ + const buildLabel = useCallback( + (project: Project, projectMap: Map): string => { + const parts: string[] = [project.name]; + let current = project; + while (current.parent_id) { + const parent = projectMap.get(current.parent_id); + if (!parent) break; + parts.unshift(parent.name); + current = parent; + } + return parts.join('/'); + }, + [] + ); + + /** + * Build a flat list of picker items from the project tree, + * filtered by search term. Children are nested after their parent. + */ + const items = useMemo(() => { const lower = searchTerm.toLowerCase(); - return projects.filter(p => p.name.toLowerCase().includes(lower)); - }, [projects, searchTerm]); + const projectMap = new Map(projects.map(p => [p.id, p])); + + // Group children by parent_id + const childrenOf = new Map(); + for (const p of projects) { + const parentId = p.parent_id ?? null; + if (!childrenOf.has(parentId)) { + childrenOf.set(parentId, []); + } + childrenOf.get(parentId)!.push(p); + } + + const result: PickerItem[] = []; + + // Recursively build the tree in display order + const walk = (parentId: string | null, depth: number) => { + const children = childrenOf.get(parentId) || []; + for (const project of children) { + const searchLabel = buildLabel(project, projectMap); + const isSandbox = project.parent_id != null; + const matches = + !searchTerm || searchLabel.toLowerCase().includes(lower); + + // Check if any descendant matches + const hasMatchingDescendant = (id: string): boolean => { + const desc = childrenOf.get(id) || []; + return desc.some( + d => + buildLabel(d, projectMap).toLowerCase().includes(lower) || + hasMatchingDescendant(d.id) + ); + }; + + if (matches || hasMatchingDescendant(project.id)) { + result.push({ + type: isSandbox ? 'sandbox' : 'project', + id: project.id, + label: project.name, + searchLabel, + depth, + color: project.color, + }); + walk(project.id, depth + 1); + } + } + }; + + walk(null, 0); + return result; + }, [projects, searchTerm, buildLabel]); + + // "View all" is always index 0; items start at index 1 + const totalItems = items.length + 1; const openPicker = useCallback(() => { setIsOpen(true); setSearchTerm(''); - // Start with first project selected (index 1), not "View all" (index 0) - setHighlightedIndex(projects.length > 0 ? 1 : 0); - setTimeout(() => inputRef.current?.focus(), 50); - }, [projects.length]); + setHighlightedIndex(items.length > 0 ? 1 : 0); + }, [items.length]); + + // Focus input after open (separate effect to avoid stale ref) + useEffect(() => { + if (isOpen) { + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [isOpen]); const closePicker = useCallback(() => { setIsOpen(false); @@ -80,7 +174,7 @@ export function ProjectPicker(props: ProjectPickerProps) { return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, openPicker, closePicker]); - // Global Escape key handler (capture phase to prevent propagation) + // Global Escape key handler (capture phase) useEffect(() => { if (!isOpen) return; @@ -98,20 +192,18 @@ export function ProjectPicker(props: ProjectPickerProps) { document.removeEventListener('keydown', handleGlobalKeyDown, true); }, [isOpen, closePicker]); - // Keep highlighted index in bounds (0 = "View all", 1+ = projects) + // Keep highlighted index in bounds useEffect(() => { - const maxIndex = filteredProjects.length; // 0 is "View all", so max is length not length-1 - if (highlightedIndex > maxIndex) { - setHighlightedIndex(Math.max(0, maxIndex)); + if (highlightedIndex >= totalItems) { + setHighlightedIndex(Math.max(0, totalItems - 1)); } - }, [filteredProjects.length, highlightedIndex]); + }, [totalItems, highlightedIndex]); // Scroll highlighted item into view useEffect(() => { if (!isOpen) return; const list = listRef.current; if (!list) return; - // Query by data-index to avoid separator element throwing off indexing const highlighted = list.querySelector( `[data-index="${highlightedIndex}"]` ) as HTMLElement; @@ -120,8 +212,7 @@ export function ProjectPicker(props: ProjectPickerProps) { } }, [isOpen, highlightedIndex]); - // Listen for custom event to open picker (from breadcrumb click) - // Phoenix JS.dispatch sends to body + // Listen for custom event from breadcrumb click useEffect(() => { const handleOpen = () => openPicker(); document.body.addEventListener('open-project-picker', handleOpen); @@ -129,8 +220,21 @@ export function ProjectPicker(props: ProjectPickerProps) { document.body.removeEventListener('open-project-picker', handleOpen); }, [openPicker]); - // Total items = "View all projects" + filtered projects - const totalItems = filteredProjects.length + 1; + const navigateToProjectsList = () => { + window.location.href = '/projects'; + }; + + const navigateToProject = (projectId: string) => { + const match = window.location.pathname.match(/^\/projects\/[^/]+\/(.*)/); + let rest = match?.[1] || 'w'; + + // Workflow paths contain project-specific IDs — keep only the section + if (rest.startsWith('w/')) { + rest = 'w'; + } + + window.location.href = `/projects/${projectId}/${rest}${window.location.search}${window.location.hash}`; + }; const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -148,26 +252,18 @@ export function ProjectPicker(props: ProjectPickerProps) { if (highlightedIndex === 0) { navigateToProjectsList(); } else { - const project = filteredProjects[highlightedIndex - 1]; - if (project) { - navigateToProject(project.id); + const item = items[highlightedIndex - 1]; + if (item) { + navigateToProject(item.id); } } break; } } }, - [filteredProjects, highlightedIndex, totalItems] + [items, highlightedIndex, totalItems] ); - const navigateToProjectsList = () => { - window.location.href = '/projects'; - }; - - const navigateToProject = (projectId: string) => { - window.location.href = `/projects/${projectId}/w`; - }; - if (!isOpen) return null; return ( @@ -175,7 +271,7 @@ export function ProjectPicker(props: ProjectPickerProps) { {/* Backdrop */}
- {/* Modal content - click outside closes */} + {/* Modal content */}
- {/* View all projects option */} + {/* View all projects */}
  • {/* Separator */} - {filteredProjects.length > 0 && ( + {items.length > 0 && (
  • )} - {/* Project list */} - {filteredProjects.map((project, index) => { - const isSelected = project.id === currentProjectId; - const itemIndex = index + 1; // +1 because 0 is "View all" + {/* Project and sandbox list */} + {items.map((item, index) => { + const itemIndex = index + 1; const isHighlighted = itemIndex === highlightedIndex; + const isSelected = item.id === currentProjectId; + const isSandbox = item.depth > 0; + const indentPx = item.depth * 10; return (
  • navigateToProject(project.id)} + onClick={() => navigateToProject(item.id)} onMouseEnter={() => setHighlightedIndex(itemIndex)} > - + {isSandbox ? ( + <> + + + + ) : ( + + )} - {project.name} + {item.label} {isSelected && ( ); })} - {filteredProjects.length === 0 && ( + {items.length === 0 && (
  • No projects found
  • diff --git a/assets/js/project-picker/ProjectPickerButton.tsx b/assets/js/project-picker/ProjectPickerButton.tsx new file mode 100644 index 00000000000..d2605eedf28 --- /dev/null +++ b/assets/js/project-picker/ProjectPickerButton.tsx @@ -0,0 +1,81 @@ +import { cn } from '../utils/cn'; +import { Tooltip } from '../collaborative-editor/components/Tooltip'; + +interface ProjectPickerButtonProps { + 'data-label': string; + 'data-is-sandbox'?: string | undefined; + 'data-color'?: string | undefined; +} + +/** + * Project picker trigger button. + * + * Mounted via ReactComponent hook in HEEx layouts and used directly + * in the collaborative editor. Clicking dispatches `open-project-picker` + * on document.body, which the global ProjectPicker modal listens for. + */ +export function ProjectPickerButton(props: ProjectPickerButtonProps) { + const label = props['data-label'] || ''; + const isSandbox = props['data-is-sandbox'] === 'true'; + const color = props['data-color'] || null; + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + document.body.dispatchEvent(new CustomEvent('open-project-picker')); + }; + + const parts = label.split('/'); + const truncated = isSandbox && parts.length > 2; + const showTooltip = truncated; + + const button = ( + + ); + + if (showTooltip) { + return ')}>{button}; + } + + return button; +} diff --git a/assets/js/project-picker/index.ts b/assets/js/project-picker/index.ts index 74226a982e2..209e359c54d 100644 --- a/assets/js/project-picker/index.ts +++ b/assets/js/project-picker/index.ts @@ -1 +1,2 @@ export { ProjectPicker } from './ProjectPicker'; +export { ProjectPickerButton } from './ProjectPickerButton'; diff --git a/config/config.exs b/config/config.exs index 906ce5193e4..067ec9c8947 100644 --- a/config/config.exs +++ b/config/config.exs @@ -113,6 +113,7 @@ config :esbuild, js/panel/panels/WorkflowRunPanel.tsx js/collaborative-editor/CollaborativeEditor.tsx js/project-picker/ProjectPicker.tsx + js/project-picker/ProjectPickerButton.tsx editor.worker=monaco-editor/esm/vs/editor/editor.worker.js json.worker=monaco-editor/esm/vs/language/json/json.worker.js css.worker=monaco-editor/esm/vs/language/css/css.worker.js diff --git a/lib/lightning/projects.ex b/lib/lightning/projects.ex index 32d663ac7e5..b5e3803aad7 100644 --- a/lib/lightning/projects.ex +++ b/lib/lightning/projects.ex @@ -254,6 +254,26 @@ defmodule Lightning.Projects do end end + @doc """ + Preloads the full ancestor chain on a project's `:parent` association, + so that `Project.display_name/1` can walk to the root. + """ + @spec preload_ancestors(Project.t()) :: Project.t() + def preload_ancestors(%Project{parent_id: nil} = p), do: p + + def preload_ancestors(%Project{parent: %Project{} = parent} = p) do + %{p | parent: preload_ancestors(parent)} + end + + def preload_ancestors(%Project{parent_id: parent_id} = p) + when is_binary(parent_id) do + parent = + Repo.get!(Project, parent_id) + |> preload_ancestors() + + %{p | parent: parent} + end + @doc """ Returns true if `child_project` is a descendant of `parent_project`. @@ -812,6 +832,33 @@ defmodule Lightning.Projects do |> Repo.all() end + @doc """ + Returns all projects the user can access, including sandboxes at any depth. + + Root projects are fetched via the user's project memberships, then all + descendants are included using a recursive CTE via `descendant_ids/1`. + """ + @spec get_project_tree_for_user(User.t()) :: [Project.t()] + def get_project_tree_for_user(%User{} = user) do + roots = get_projects_for_user(user) + root_ids = Enum.map(roots, & &1.id) + + case descendant_ids(root_ids) do + [] -> + roots + + desc_ids -> + descendants = + from(p in Project, + where: p.id in ^desc_ids and is_nil(p.scheduled_deletion), + order_by: [asc: p.name] + ) + |> Repo.all() + + roots ++ descendants + end + end + defp project_user_role_query(%User{id: user_id}, %Project{id: project_id}) do from(p in Project, join: pu in assoc(p, :project_users), diff --git a/lib/lightning/projects/project.ex b/lib/lightning/projects/project.ex index c88e11b2442..062585fc161 100644 --- a/lib/lightning/projects/project.ex +++ b/lib/lightning/projects/project.ex @@ -188,6 +188,33 @@ defmodule Lightning.Projects.Project do end end + @doc """ + Returns a display name for the project. + + Walks up the loaded parent chain to build the full path, + e.g. `"root:child:grandchild"`. Stops when parent is nil or not loaded. + """ + @spec display_name(t()) :: String.t() + def display_name(%__MODULE__{} = project) do + names = ancestor_names(project, []) + + case names do + [single] -> single + names -> Enum.join(names, "/") + end + end + + defp ancestor_names( + %__MODULE__{parent: %__MODULE__{} = parent, name: name}, + acc + ) do + ancestor_names(parent, [name | acc]) + end + + defp ancestor_names(%__MODULE__{name: name}, acc) do + [name | acc] + end + @doc """ Returns `true` if the project is a sandbox (i.e. `parent_id` is a UUID), `false` otherwise. diff --git a/lib/lightning_web/components/layout_components.ex b/lib/lightning_web/components/layout_components.ex index cc5c9024022..4e1f00a60bc 100644 --- a/lib/lightning_web/components/layout_components.ex +++ b/lib/lightning_web/components/layout_components.ex @@ -286,8 +286,8 @@ defmodule LightningWeb.LayoutComponents do ## Example <.breadcrumbs> - <.breadcrumb_project_picker label={@project.name} /> - <.breadcrumb_items items={[{"History", ~p"/projects/\#{@project}/history"}]} /> + <.breadcrumb_project_picker project={@project} /> + <.breadcrumb_items items={[{"History", "/projects/\#{@project}/history"}]} /> <.breadcrumb> <:label>{@page_title} @@ -331,27 +331,36 @@ defmodule LightningWeb.LayoutComponents do @doc """ Renders a project picker button styled as a breadcrumb element. + + Mounts the shared React `ProjectPickerButton` component via the + `ReactComponent` hook, so the same component is used in both standard + LiveView pages and the collaborative editor. """ - attr :label, :string, required: true + attr :project, Lightning.Projects.Project, required: true def breadcrumb_project_picker(assigns) do + alias Lightning.Projects + alias Lightning.Projects.Project + + project = Projects.preload_ancestors(assigns.project) + + assigns = + assigns + |> assign(:label, Project.display_name(project)) + |> assign(:is_sandbox, to_string(Project.sandbox?(project))) + |> assign(:color, project.color) + ~H"""
  • -
    - +
  • """ @@ -386,6 +395,50 @@ defmodule LightningWeb.LayoutComponents do """ end + @doc """ + Renders the global project picker React component. + + This is the single source of truth for the Cmd+P project picker modal, + used by both the `live` and `settings` layouts. + """ + def global_project_picker(assigns) do + all_projects = + if assigns[:current_user] do + Lightning.Projects.get_project_tree_for_user(assigns.current_user) + else + [] + end + + assigns = assign(assigns, :picker_projects, all_projects) + + ~H""" + <%= if assigns[:current_user] do %> +
    + %{ + id: p.id, + name: p.name, + color: p.color, + parent_id: p.parent_id + } + end) + ) + } + data-current-project-id={ + if assigns[:project], do: assigns[:project].id, else: nil + } + > +
    + <% end %> + """ + end + attr :title, :string, required: true attr :subtitle, :string, required: true attr :permissions_message, :string, required: true diff --git a/lib/lightning_web/components/layouts.ex b/lib/lightning_web/components/layouts.ex index 50c4d398c14..282421b8a7b 100644 --- a/lib/lightning_web/components/layouts.ex +++ b/lib/lightning_web/components/layouts.ex @@ -9,7 +9,6 @@ defmodule LightningWeb.Layouts do def root(assigns) attr :side_menu_theme, :string, default: "primary-theme" - attr :theme_style, :string, default: nil def live(assigns) attr :side_menu_theme, :string, default: "sudo-variant" diff --git a/lib/lightning_web/components/layouts/live.html.heex b/lib/lightning_web/components/layouts/live.html.heex index b8953b59a3e..05d1fa432c5 100644 --- a/lib/lightning_web/components/layouts/live.html.heex +++ b/lib/lightning_web/components/layouts/live.html.heex @@ -1,4 +1,4 @@ -
    +
    <%!-- Header --%>
    - <%!-- Global project picker (React component) --%> - <%= if assigns[:current_user] && !assigns[:is_first_setup] do %> -
    - %{id: p.id, name: p.name} - end) - ) - } - data-current-project-id={ - if assigns[:project], do: assigns[:project].id, else: nil - } - > -
    + <%= unless assigns[:is_first_setup] do %> + <% end %> diff --git a/lib/lightning_web/components/layouts/settings.html.heex b/lib/lightning_web/components/layouts/settings.html.heex index 93c629d184c..c4c7ecf8fa4 100644 --- a/lib/lightning_web/components/layouts/settings.html.heex +++ b/lib/lightning_web/components/layouts/settings.html.heex @@ -89,24 +89,5 @@ {@inner_content}
    - <%!-- Global project picker (React component) --%> - <%= if assigns[:current_user] do %> -
    - %{id: p.id, name: p.name} - end) - ) - } - data-current-project-id={ - if assigns[:project], do: assigns[:project].id, else: nil - } - /> - <% end %> + diff --git a/lib/lightning_web/hooks.ex b/lib/lightning_web/hooks.ex index 5fc09a2ad83..193be11ceb1 100644 --- a/lib/lightning_web/hooks.ex +++ b/lib/lightning_web/hooks.ex @@ -14,7 +14,6 @@ defmodule LightningWeb.Hooks do alias Lightning.Projects.ProjectLimiter alias Lightning.Services.UsageLimiter alias Lightning.VersionControl.VersionControlUsageLimiter - alias LightningWeb.Live.Helpers.ProjectTheme alias LightningWeb.LiveHelpers @doc """ @@ -56,20 +55,12 @@ defmodule LightningWeb.Hooks do {:halt, redirect(socket, to: ~p"/mfa_required")} can_access_project -> - scale = ProjectTheme.inline_primary_scale(project) - - theme_style = - [scale, ProjectTheme.inline_sidebar_vars()] - |> Enum.reject(&is_nil/1) - |> Enum.join(" ") - {:cont, socket |> assign(:side_menu_theme, "primary-theme") - |> assign(:theme_style, theme_style) - |> assign_new(:project_user, fn -> project_user end) - |> assign_new(:project, fn -> project end) - |> assign_new(:projects, fn -> projects end)} + |> assign(:project_user, project_user) + |> assign(:project, project) + |> assign(:projects, projects)} true -> {:halt, redirect(socket, to: "/projects") |> put_flash(:nav, :not_found)} @@ -77,7 +68,7 @@ defmodule LightningWeb.Hooks do end def on_mount(:project_scope, _, _session, socket) do - {:cont, assign_new(socket, :theme_style, fn -> nil end)} + {:cont, socket} end def on_mount(:assign_projects, _, _session, socket) do diff --git a/lib/lightning_web/live/channel_live/index.ex b/lib/lightning_web/live/channel_live/index.ex index b0a80d236b4..e6483b215b4 100644 --- a/lib/lightning_web/live/channel_live/index.ex +++ b/lib/lightning_web/live/channel_live/index.ex @@ -28,7 +28,7 @@ defmodule LightningWeb.ChannelLive.Index do <:breadcrumbs> - + <:label>{@page_title} diff --git a/lib/lightning_web/live/dataclip_live/show.ex b/lib/lightning_web/live/dataclip_live/show.ex index 22fe15f4ada..6bb576d6df4 100644 --- a/lib/lightning_web/live/dataclip_live/show.ex +++ b/lib/lightning_web/live/dataclip_live/show.ex @@ -40,7 +40,7 @@ defmodule LightningWeb.DataclipLive.Show do <:breadcrumbs> - + <:label> {@page_title} diff --git a/lib/lightning_web/live/helpers/project_theme.ex b/lib/lightning_web/live/helpers/project_theme.ex deleted file mode 100644 index a1664723b39..00000000000 --- a/lib/lightning_web/live/helpers/project_theme.ex +++ /dev/null @@ -1,100 +0,0 @@ -defmodule LightningWeb.Live.Helpers.ProjectTheme do - @moduledoc """ - Runtime theme utilities. Builds a full primary scale (50..950) from a base hex - and returns inline CSS variable overrides for Tailwind v4's `--color-primary-*`. - """ - - alias Lightning.Projects.Project - - @stops [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] - @openfn_blue "#6366f1" - - @doc """ - If the project is a sandbox with a color, returns a string of CSS custom - properties that override the full `--color-primary-*` scale. - Returns `nil` for non-sandboxes or missing color. - """ - @spec inline_primary_scale(Project.t() | nil) :: String.t() | nil - def inline_primary_scale(%Project{} = p) do - cond do - not Project.sandbox?(p) -> - nil - - is_nil(p.color) or String.trim(to_string(p.color)) == "" -> - nil - - true -> - scale = build_scale(p.color) - - @stops - |> Enum.map_join(" ", fn stop -> - "--color-primary-#{stop}: #{Map.fetch!(scale, stop)};" - end) - end - end - - def inline_primary_scale(_), do: nil - - @doc """ - Returns the sidebar variables that your CSS reads (`--primary-*`), pointing at the - primary scale. Safe to append anywhere you put `inline_primary_scale/1`. - """ - @spec inline_sidebar_vars() :: String.t() - def inline_sidebar_vars do - """ - --primary-bg: var(--color-primary-800); - --primary-text: white; - --primary-bg-lighter: var(--color-primary-600); - --primary-bg-dark: var(--color-primary-900); - --primary-text-light: var(--color-primary-300); - --primary-text-lighter: var(--color-primary-200); - --primary-ring: var(--color-gray-300); - --primary-ring-focus: var(--color-primary-600); - """ - |> String.replace("\n", " ") - end - - defp build_scale(hex) do - %{h: h, s: s, l: _l} = Chameleon.convert(hex, Chameleon.HSL) - build_scale_with_hsl(h, s) - rescue - _ -> build_default_scale() - end - - defp build_default_scale do - %{h: h, s: s, l: _l} = Chameleon.convert(@openfn_blue, Chameleon.HSL) - build_scale_with_hsl(h, s) - end - - defp build_scale_with_hsl(h, s) do - targets = %{ - 50 => 0.98, - 100 => 0.95, - 200 => 0.90, - 300 => 0.82, - 400 => 0.70, - 500 => 0.60, - 600 => 0.50, - 700 => 0.42, - 800 => 0.35, - 900 => 0.28, - 950 => 0.20 - } - - Enum.reduce(targets, %{}, fn {stop, target_lightness}, acc -> - adjusted_saturation = - cond do - target_lightness >= 0.9 -> s * 0.75 - target_lightness >= 0.7 -> s * 0.9 - target_lightness >= 0.5 -> s - true -> min(100, s * 1.05) - end - - color = - %Chameleon.HSL{h: h, s: adjusted_saturation, l: target_lightness * 100} - |> Chameleon.convert(Chameleon.Hex) - - Map.put(acc, stop, "##{color.hex}") - end) - end -end diff --git a/lib/lightning_web/live/project_live/settings.html.heex b/lib/lightning_web/live/project_live/settings.html.heex index 5653d3b2a22..406429de50d 100644 --- a/lib/lightning_web/live/project_live/settings.html.heex +++ b/lib/lightning_web/live/project_live/settings.html.heex @@ -10,7 +10,7 @@ <:breadcrumbs> - + <:label>{@page_title} diff --git a/lib/lightning_web/live/run_live/index.html.heex b/lib/lightning_web/live/run_live/index.html.heex index acb6adde08c..d3414c9b5c0 100644 --- a/lib/lightning_web/live/run_live/index.html.heex +++ b/lib/lightning_web/live/run_live/index.html.heex @@ -10,7 +10,7 @@ <:breadcrumbs> - + <:label>{@page_title} diff --git a/lib/lightning_web/live/run_live/show.ex b/lib/lightning_web/live/run_live/show.ex index 030148c2ea2..6f198f317a9 100644 --- a/lib/lightning_web/live/run_live/show.ex +++ b/lib/lightning_web/live/run_live/show.ex @@ -64,7 +64,7 @@ defmodule LightningWeb.RunLive.Show do <:breadcrumbs> - + diff --git a/lib/lightning_web/live/sandbox_live/form_component.ex b/lib/lightning_web/live/sandbox_live/form_component.ex index ce40b72407e..5f5de389df8 100644 --- a/lib/lightning_web/live/sandbox_live/form_component.ex +++ b/lib/lightning_web/live/sandbox_live/form_component.ex @@ -6,7 +6,6 @@ defmodule LightningWeb.SandboxLive.FormComponent do alias Lightning.Projects alias Lightning.Projects.Project alias Lightning.Projects.ProjectLimiter - alias LightningWeb.Live.Helpers.ProjectTheme alias LightningWeb.SandboxLive.Components require Logger @@ -23,23 +22,15 @@ defmodule LightningWeb.SandboxLive.FormComponent do |> form_changeset(initial_params(assigns), parent_id) |> Map.put(:action, :validate) - initial_color = Changeset.get_field(changeset, :color) - - if should_preview_theme?(initial_color, nil) do - send_theme_preview(assigns.parent, initial_color) - end - {:ok, socket |> assign(assigns) - |> assign(:last_preview_color, initial_color) |> assign(:changeset, changeset) |> assign(:name, Changeset.get_field(changeset, :name))} end @impl true def handle_event("close_modal", _params, socket) do - reset_theme_preview() {:noreply, push_navigate(socket, to: return_path(socket))} end @@ -57,18 +48,10 @@ defmodule LightningWeb.SandboxLive.FormComponent do |> form_changeset(params, parent_id) |> Map.put(:action, :validate) - new_color = params["color"] - last_color = socket.assigns[:last_preview_color] - - if should_preview_theme?(new_color, last_color) do - send_theme_preview(assigns.parent, new_color) - end - {:noreply, socket |> assign(:changeset, changeset) - |> assign(:name, Changeset.get_field(changeset, :name)) - |> assign(:last_preview_color, new_color)} + |> assign(:name, Changeset.get_field(changeset, :name))} end @impl true @@ -349,40 +332,6 @@ defmodule LightningWeb.SandboxLive.FormComponent do } end - defp generate_theme_preview(%Project{id: parent_id}, color) - when is_binary(parent_id) and is_binary(color) and color != "" do - temp_project = %Project{ - id: Ecto.UUID.generate(), - color: String.trim(color), - parent_id: parent_id - } - - case ProjectTheme.inline_primary_scale(temp_project) do - nil -> - nil - - scale -> - [scale, ProjectTheme.inline_sidebar_vars()] - |> Enum.reject(&is_nil/1) - |> Enum.join(" ") - end - end - - defp generate_theme_preview(_parent, _color), do: nil - - defp should_preview_theme?(new_color, last_color) do - is_binary(new_color) and new_color != last_color - end - - defp send_theme_preview(parent, color) do - theme = generate_theme_preview(parent, color) - send(self(), {:preview_theme, theme}) - end - - defp reset_theme_preview do - send(self(), {:preview_theme, nil}) - end - defp get_random_color do Components.color_palette_hex_colors() |> Enum.random() end diff --git a/lib/lightning_web/live/sandbox_live/index.ex b/lib/lightning_web/live/sandbox_live/index.ex index 33110a574d3..b3eca8f3349 100644 --- a/lib/lightning_web/live/sandbox_live/index.ex +++ b/lib/lightning_web/live/sandbox_live/index.ex @@ -383,17 +383,6 @@ defmodule LightningWeb.SandboxLive.Index do end end - @impl true - def handle_info({:preview_theme, preview_style}, socket) do - original_theme = - socket.assigns[:original_theme_style] || socket.assigns.theme_style - - {:noreply, - socket - |> assign(:theme_style, preview_style || original_theme) - |> assign(:original_theme_style, original_theme)} - end - @impl true def render(assigns) do ~H""" @@ -409,7 +398,7 @@ defmodule LightningWeb.SandboxLive.Index do <:breadcrumbs> - + <:label>Sandboxes diff --git a/lib/lightning_web/live/workflow_live/collaborate.ex b/lib/lightning_web/live/workflow_live/collaborate.ex index 8de4936c544..05b2eeef4eb 100644 --- a/lib/lightning_web/live/workflow_live/collaborate.ex +++ b/lib/lightning_web/live/workflow_live/collaborate.ex @@ -181,12 +181,24 @@ defmodule LightningWeb.WorkflowLive.Collaborate do data-workflow-name={@workflow.name} data-project-id={@workflow.project_id} data-project-name={@project.name} + data-project-display-name={ + Lightning.Projects.Project.display_name( + Lightning.Projects.preload_ancestors(@project) + ) + } + data-project-is-sandbox={ + to_string(Lightning.Projects.Project.sandbox?(@project)) + } data-project-color={@project.color} data-root-project-id={ - if @project.parent, do: Lightning.Projects.root_of(@project).id, else: nil + if Lightning.Projects.Project.sandbox?(@project), + do: Lightning.Projects.root_of(@project).id, + else: nil } data-root-project-name={ - if @project.parent, do: Lightning.Projects.root_of(@project).name, else: nil + if Lightning.Projects.Project.sandbox?(@project), + do: Lightning.Projects.root_of(@project).name, + else: nil } data-project-env={@project.env} data-is-new-workflow={if @is_new_workflow, do: "true", else: nil} diff --git a/lib/lightning_web/live/workflow_live/edit.ex b/lib/lightning_web/live/workflow_live/edit.ex index 6fe805d3d31..c9b90187099 100644 --- a/lib/lightning_web/live/workflow_live/edit.ex +++ b/lib/lightning_web/live/workflow_live/edit.ex @@ -95,7 +95,7 @@ defmodule LightningWeb.WorkflowLive.Edit do <:breadcrumbs> - + diff --git a/lib/lightning_web/live/workflow_live/index.ex b/lib/lightning_web/live/workflow_live/index.ex index 46216ee6976..f332f086b10 100644 --- a/lib/lightning_web/live/workflow_live/index.ex +++ b/lib/lightning_web/live/workflow_live/index.ex @@ -39,7 +39,7 @@ defmodule LightningWeb.WorkflowLive.Index do <:breadcrumbs> - + <:label>{@page_title} diff --git a/test/lightning_web/components/layout_components_test.exs b/test/lightning_web/components/layout_components_test.exs index a4d062add78..dc44f15a0c6 100644 --- a/test/lightning_web/components/layout_components_test.exs +++ b/test/lightning_web/components/layout_components_test.exs @@ -61,14 +61,61 @@ defmodule LightningWeb.LayoutComponentsTest do end describe "breadcrumb_project_picker/1" do - test "renders project picker button with label" do + test "renders ReactComponent mount point for a root project" do + project = %Lightning.Projects.Project{ + id: Ecto.UUID.generate(), + name: "my-project", + parent_id: nil, + parent: %Ecto.Association.NotLoaded{ + __field__: :parent, + __owner__: Lightning.Projects.Project, + __cardinality__: :one + } + } + + html = + (&LayoutComponents.breadcrumb_project_picker/1) + |> render_component(%{project: project}) + + assert html =~ "breadcrumb-project-picker-trigger" + assert html =~ ~s(data-react-name="ProjectPickerButton") + assert html =~ ~s(data-label="my-project") + assert html =~ ~s(data-is-sandbox="false") + end + + test "renders ReactComponent mount point with sandbox data" do + parent = %Lightning.Projects.Project{ + id: Ecto.UUID.generate(), + name: "parent-project" + } + + project = %Lightning.Projects.Project{ + id: Ecto.UUID.generate(), + name: "my-sandbox", + parent_id: parent.id, + parent: parent, + color: "#E33D63" + } + html = (&LayoutComponents.breadcrumb_project_picker/1) - |> render_component(%{label: "My Project"}) + |> render_component(%{project: project}) assert html =~ "breadcrumb-project-picker-trigger" - assert html =~ "My Project" - assert html =~ "open-project-picker" + assert html =~ ~s(data-react-name="ProjectPickerButton") + assert html =~ ~s(data-label="parent-project/my-sandbox") + assert html =~ ~s(data-is-sandbox="true") + assert html =~ ~s(data-color="#E33D63") + end + end + + describe "global_project_picker/1" do + test "renders nothing when no current_user" do + html = + (&LayoutComponents.global_project_picker/1) + |> render_component(%{}) + + refute html =~ "global-project-picker" end end diff --git a/test/lightning_web/live/project_live_test.exs b/test/lightning_web/live/project_live_test.exs index 9d21cfe93ed..f513e72c7d8 100644 --- a/test/lightning_web/live/project_live_test.exs +++ b/test/lightning_web/live/project_live_test.exs @@ -844,11 +844,10 @@ defmodule LightningWeb.ProjectLiveTest do {:ok, view, _html} = live(conn, ~p"/projects/#{project_1}/w", on_error: :raise) - # Current project shown in breadcrumb project picker button + # Current project shown in breadcrumb project picker button (React mount point) assert view |> element( - "#breadcrumb-project-picker-trigger", - ~r/project-1/ + "#breadcrumb-project-picker-trigger[data-label='project-1']" ) |> has_element?() diff --git a/test/lightning_web/live/project_theme_test.exs b/test/lightning_web/live/project_theme_test.exs deleted file mode 100644 index 27464c1b570..00000000000 --- a/test/lightning_web/live/project_theme_test.exs +++ /dev/null @@ -1,130 +0,0 @@ -defmodule LightningWeb.ProjectThemeTest do - use ExUnit.Case, async: true - - import Lightning.Factories - alias LightningWeb.Live.Helpers.ProjectTheme - alias Lightning.Projects.Project - - @stops [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] - - describe "inline_primary_scale/1" do - test "returns nil for non-Project inputs" do - assert ProjectTheme.inline_primary_scale(nil) == nil - assert ProjectTheme.inline_primary_scale(%{}) == nil - assert ProjectTheme.inline_primary_scale(:nope) == nil - end - - test "returns nil for non-sandbox project" do - p = build(:project, color: "#6366f1", parent: nil, parent_id: nil) - refute Project.sandbox?(p) - assert ProjectTheme.inline_primary_scale(p) == nil - end - - test "returns nil for sandbox without color or blank color" do - s1 = build(:sandbox, color: nil) - s2 = build(:sandbox, color: "") - s3 = build(:sandbox, color: " ") - - assert ProjectTheme.inline_primary_scale(s1) == nil - assert ProjectTheme.inline_primary_scale(s2) == nil - assert ProjectTheme.inline_primary_scale(s3) == nil - end - - test "produces 11 CSS vars for sandbox with color" do - css = - build(:project, parent_id: Ecto.UUID.generate(), color: "#6366f1") - |> ProjectTheme.inline_primary_scale() - - decls = css |> String.trim() |> String.split(~r/;\s*/, trim: true) - assert length(decls) == length(@stops) - - for {decl, stop} <- Enum.zip(decls, @stops) do - assert Regex.match?(~r/^--color-primary-#{stop}: #[0-9a-f]{6}$/i, decl) - end - end - - test "emits declarations in exact @stops order" do - css = - build(:project, parent_id: Ecto.UUID.generate(), color: "#6366f1") - |> ProjectTheme.inline_primary_scale() - - decls = css |> String.trim() |> String.split(~r/;\s*/, trim: true) - - got_stops = - for <<"--color-primary-", rest::binary>> <- decls do - [num | _] = String.split(rest, ":") - String.to_integer(num) - end - - assert got_stops == @stops - end - - test "lower/upper-case input produce identical scales" do - s1 = build(:project, parent_id: Ecto.UUID.generate(), color: "#6366F1") - s2 = build(:project, parent_id: Ecto.UUID.generate(), color: "#6366f1") - - assert ProjectTheme.inline_primary_scale(s1) == - ProjectTheme.inline_primary_scale(s2) - end - - test "supports 3/6/8-digit inputs" do - for color <- ["#63f", "#6633ff", "#6633ffcc"] do - s = build(:project, parent_id: Ecto.UUID.generate(), color: color) - css = ProjectTheme.inline_primary_scale(s) - decls = css |> String.trim() |> String.split(~r/;\s*/, trim: true) - - assert length(decls) == 11 - assert Regex.match?(~r/^--color-primary-50: #[0-9a-f]{6}$/i, hd(decls)) - - assert Regex.match?( - ~r/^--color-primary-950: #[0-9a-f]{6}$/i, - List.last(decls) - ) - end - end - - test "short/invalid-length hex (e.g. #12) falls back but still yields 11 vars" do - s = build(:project, parent_id: Ecto.UUID.generate(), color: "#12") - css = ProjectTheme.inline_primary_scale(s) - decls = css |> String.trim() |> String.split(~r/;\s*/, trim: true) - assert length(decls) == 11 - end - - test "covers red-sector hue paths (max==r and h<60)" do - s = build(:project, parent_id: Ecto.UUID.generate(), color: "#ff0000") - css = ProjectTheme.inline_primary_scale(s) - decls = css |> String.trim() |> String.split(~r/;\s*/, trim: true) - assert length(decls) == 11 - end - - test "covers green-sector hue path (max==g)" do - s = build(:project, parent_id: Ecto.UUID.generate(), color: "#00ff00") - css = ProjectTheme.inline_primary_scale(s) - decls = css |> String.trim() |> String.split(~r/;\s*/, trim: true) - assert length(decls) == 11 - end - - test "covers magenta-sector hue path (h≥300, true branch in hsl_to_rgb)" do - s = build(:project, parent_id: Ecto.UUID.generate(), color: "#ff00ff") - css = ProjectTheme.inline_primary_scale(s) - decls = css |> String.trim() |> String.split(~r/;\s*/, trim: true) - assert length(decls) == 11 - end - end - - describe "inline_sidebar_vars/0" do - test "returns single-line vars, safe to append to scale" do - s = build(:project, parent_id: Ecto.UUID.generate(), color: "#0ea5e9") - scale = ProjectTheme.inline_primary_scale(s) - vars = ProjectTheme.inline_sidebar_vars() - - refute String.contains?(vars, "\n") - assert String.contains?(vars, "--primary-bg: var(--color-primary-800);") - assert String.contains?(vars, "--primary-ring: var(--color-gray-300);") - - combo = scale <> " " <> vars - assert String.contains?(combo, "--color-primary-600:") - assert String.contains?(combo, "--primary-bg:") - end - end -end diff --git a/test/lightning_web/live/sandbox_live/form_component_test.exs b/test/lightning_web/live/sandbox_live/form_component_test.exs index f3bbab06082..503b32a2488 100644 --- a/test/lightning_web/live/sandbox_live/form_component_test.exs +++ b/test/lightning_web/live/sandbox_live/form_component_test.exs @@ -8,7 +8,6 @@ defmodule LightningWeb.SandboxLive.FormComponentTest do setup_all do Mimic.copy(Lightning.Projects) Mimic.copy(Lightning.Projects.Sandboxes) - Mimic.copy(LightningWeb.Live.Helpers.ProjectTheme) :ok end @@ -419,56 +418,6 @@ defmodule LightningWeb.SandboxLive.FormComponentTest do end end - describe "theme preview edge cases" do - setup %{user: user} do - parent = insert(:project, project_users: [%{user: user, role: :owner}]) - - Mimic.stub( - LightningWeb.Live.Helpers.ProjectTheme, - :inline_primary_scale, - fn _project -> - nil - end - ) - - {:ok, parent: parent} - end - - test "generate_theme_preview returns nil when inline_primary_scale returns nil", - %{ - conn: conn, - parent: parent - } do - Mimic.allow( - LightningWeb.Live.Helpers.ProjectTheme, - self(), - spawn(fn -> :ok end) - ) - - {:ok, view, _} = live(conn, ~p"/projects/#{parent.id}/sandboxes/new") - - view - |> element("#sandbox-form-new") - |> render_change(%{"project" => %{"color" => "#ff0000"}}) - - html = render(view) - - assert html =~ "Create a new sandbox" - - assert html =~ ~s(#ff0000) - - assert html =~ ~s(name="project[color]") - assert html =~ ~s(Selected: #ff0000) - - view - |> element("#sandbox-form-new") - |> render_change(%{"project" => %{"color" => "#00ff00"}}) - - updated_html = render(view) - assert updated_html =~ ~s(#00ff00) - end - end - defp assert_redirect_or_patch(res_or_html, view, conn, to) do case res_or_html do {:error, {:redirect, _}} -> diff --git a/test/lightning_web/live/workflow_live/collaborate_new_test.exs b/test/lightning_web/live/workflow_live/collaborate_new_test.exs index 1bdcc76d3fd..5cda912158c 100644 --- a/test/lightning_web/live/workflow_live/collaborate_new_test.exs +++ b/test/lightning_web/live/workflow_live/collaborate_new_test.exs @@ -81,7 +81,9 @@ defmodule LightningWeb.WorkflowLive.CollaborateNewTest do assert html =~ "data-root-project-id=\"#{root_project.id}\"" assert html =~ "data-root-project-name=\"#{root_project.name}\"" - refute html =~ sandbox_a.name + + assert html =~ + "data-project-display-name=\"#{root_project.name}/#{sandbox_a.name}/#{sandbox_b.name}\"" end end end diff --git a/test/lightning_web/live/workflow_live/collaborate_test.exs b/test/lightning_web/live/workflow_live/collaborate_test.exs index fa8c179d8ca..4acb9fda264 100644 --- a/test/lightning_web/live/workflow_live/collaborate_test.exs +++ b/test/lightning_web/live/workflow_live/collaborate_test.exs @@ -87,7 +87,9 @@ defmodule LightningWeb.WorkflowLive.CollaborateTest do assert html =~ "data-root-project-id=\"#{root_project.id}\"" assert html =~ "data-root-project-name=\"#{root_project.name}\"" - refute html =~ sandbox_a.name + + assert html =~ + "data-project-display-name=\"#{root_project.name}/#{sandbox_a.name}/#{sandbox_b.name}\"" end end