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 (
-
-
- {children}
-
- );
-}
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 = (
+
+
+
+ {isSandbox && parts.length > 1
+ ? (() => {
+ const visible = truncated ? parts.slice(-2) : parts;
+ return (
+ <>
+ {truncated && … }
+ {visible.map((part, i) => (
+
+ {(i > 0 || truncated) && : }
+ {part}
+
+ ))}
+ >
+ );
+ })()
+ : label}
+
+
+
+ );
+
+ 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"""
-
-
- <.icon name="hero-folder" class="h-4 w-4 text-gray-500" />
- {@label}
-
+
"""
@@ -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 @@
-
+
- <%!-- 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