web: register sidebar snippets from layouts
This commit is contained in:
parent
b3854004a1
commit
fb4d3c3f1f
|
|
@ -214,7 +214,7 @@
|
||||||
grid-template-rows: auto auto 1fr;
|
grid-template-rows: auto auto 1fr;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 0;
|
min-height: 100dvh;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.workspace-layout.sidebar-collapsed {
|
.workspace-layout.sidebar-collapsed {
|
||||||
|
|
|
||||||
|
|
@ -393,6 +393,15 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
||||||
const globalSidebar = await Deno.readTextFile(
|
const globalSidebar = await Deno.readTextFile(
|
||||||
new URL("../sidebar/GlobalSidebar.svelte", import.meta.url),
|
new URL("../sidebar/GlobalSidebar.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
|
const workspaceLayout = await Deno.readTextFile(
|
||||||
|
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
const workspaceLayoutLoad = await Deno.readTextFile(
|
||||||
|
new URL("./../../../routes/w/[workspaceId]/+layout.ts", import.meta.url),
|
||||||
|
);
|
||||||
|
const sidebarOverride = await Deno.readTextFile(
|
||||||
|
new URL("../sidebar/SidebarOverride.svelte", import.meta.url),
|
||||||
|
);
|
||||||
const sidebar = await Deno.readTextFile(
|
const sidebar = await Deno.readTextFile(
|
||||||
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
|
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
|
|
@ -421,16 +430,17 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
||||||
"Auth model should stay on Backend auth APIs rather than workspace authorization APIs",
|
"Auth model should stay on Backend auth APIs rather than workspace authorization APIs",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
rootLayout.includes("GlobalSidebar") &&
|
rootLayout.includes("SIDEBAR_CONTEXT") &&
|
||||||
rootLayout.includes("WorkspaceSidebar") &&
|
rootLayout.includes("GlobalSidebar") &&
|
||||||
rootLayout.includes("data.workspaceScoped") &&
|
rootLayout.includes("{@render sidebar()}") &&
|
||||||
|
!rootLayout.includes("WorkspaceSidebar") &&
|
||||||
rootLayout.includes("workspace-topbar") &&
|
rootLayout.includes("workspace-topbar") &&
|
||||||
rootLayout.includes("topbar-icon-button") &&
|
rootLayout.includes("topbar-icon-button") &&
|
||||||
rootLayout.includes('href="/account"') &&
|
rootLayout.includes('href="/account"') &&
|
||||||
rootLayout.includes("Open Account") &&
|
rootLayout.includes("Open Account") &&
|
||||||
!sidebar.includes("accountHref") &&
|
!sidebar.includes("accountHref") &&
|
||||||
!sidebar.includes("Open Account"),
|
!sidebar.includes("Open Account"),
|
||||||
"Root layout chrome should choose GlobalSidebar or WorkspaceSidebar while account navigation stays in the header",
|
"Root layout chrome should render a registered sidebar snippet or default global sidebar while account navigation stays in the header",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
globalSidebar.includes("Global") &&
|
globalSidebar.includes("Global") &&
|
||||||
|
|
@ -441,10 +451,17 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
||||||
"Root default sidebar should contain only global navigation, not workspace-scoped sections",
|
"Root default sidebar should contain only global navigation, not workspace-scoped sections",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
rootLayoutLoad.includes("params.workspaceId") &&
|
workspaceLayout.includes("{#snippet workspaceSidebar()}") &&
|
||||||
rootLayoutLoad.includes("workspaceApiPath(workspaceId") &&
|
workspaceLayout.includes("WorkspaceSidebar") &&
|
||||||
rootLayoutLoad.includes("workspaceScoped: true"),
|
workspaceLayout.includes("<SidebarOverride sidebar={workspaceSidebar} />") &&
|
||||||
"Root layout load should provide workspace-scoped sidebar data when a workspace route is active",
|
workspaceLayoutLoad.includes("params.workspaceId") &&
|
||||||
|
workspaceLayoutLoad.includes("workspaceApiPath(workspaceId"),
|
||||||
|
"Workspace layout should load workspace data and register a WorkspaceSidebar snippet",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sidebarOverride.includes("controller.setSidebar(sidebar)") &&
|
||||||
|
sidebarOverride.includes("controller.clearSidebar(sidebar)"),
|
||||||
|
"SidebarOverride should register and clean up the child-provided sidebar snippet",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
rootLayoutLoad.includes('"/account"') && rootLayoutLoad.includes('"/login/device"'),
|
rootLayoutLoad.includes('"/account"') && rootLayoutLoad.includes('"/login/device"'),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getSidebarController, type SidebarSnippet } from './context';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
sidebar: SidebarSnippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { sidebar }: Props = $props();
|
||||||
|
const controller = getSidebarController();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
controller.setSidebar(sidebar);
|
||||||
|
return () => controller.clearSidebar(sidebar);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
16
web/workspace/src/lib/workspace/sidebar/context.ts
Normal file
16
web/workspace/src/lib/workspace/sidebar/context.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
export type SidebarSnippet = Snippet<[]>;
|
||||||
|
|
||||||
|
export type SidebarController = {
|
||||||
|
setSidebar(snippet: SidebarSnippet): void;
|
||||||
|
clearSidebar(snippet: SidebarSnippet): void;
|
||||||
|
setCollapsed(collapsed: boolean): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SIDEBAR_CONTEXT = Symbol("yoi-sidebar-context");
|
||||||
|
|
||||||
|
export function getSidebarController(): SidebarController {
|
||||||
|
return getContext<SidebarController>(SIDEBAR_CONTEXT);
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,35 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { setContext } from 'svelte';
|
||||||
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
|
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
|
||||||
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
|
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
|
||||||
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
import { SIDEBAR_CONTEXT, type SidebarSnippet } from '$lib/workspace/sidebar/context';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import type { LayoutProps } from './$types';
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
let { data, children }: LayoutProps = $props();
|
let { children }: LayoutProps = $props();
|
||||||
|
let sidebar = $state<SidebarSnippet | null>(null);
|
||||||
let sidebarCollapsed = $state(false);
|
let sidebarCollapsed = $state(false);
|
||||||
|
|
||||||
|
setContext(SIDEBAR_CONTEXT, {
|
||||||
|
setSidebar(snippet: SidebarSnippet) {
|
||||||
|
sidebar = snippet;
|
||||||
|
},
|
||||||
|
clearSidebar(snippet: SidebarSnippet) {
|
||||||
|
if (sidebar === snippet) sidebar = null;
|
||||||
|
sidebarCollapsed = false;
|
||||||
|
},
|
||||||
|
setCollapsed(collapsed: boolean) {
|
||||||
|
sidebarCollapsed = collapsed;
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<WorkspaceAlerts />
|
<WorkspaceAlerts />
|
||||||
|
|
||||||
<div class:sidebar-collapsed={data.workspaceScoped && sidebarCollapsed} class="workspace-layout">
|
<div class:sidebar-collapsed={sidebarCollapsed} class="workspace-layout">
|
||||||
{#if data.workspaceScoped}
|
{#if sidebar}
|
||||||
<WorkspaceSidebar
|
{@render sidebar()}
|
||||||
workspace={data.workspace ?? null}
|
|
||||||
workspaceError={data.workspaceError ?? null}
|
|
||||||
repositories={data.repositories ?? null}
|
|
||||||
repositoriesError={data.repositoriesError ?? null}
|
|
||||||
currentPath={page.url.pathname}
|
|
||||||
collapsed={sidebarCollapsed}
|
|
||||||
onToggleCollapsed={() => (sidebarCollapsed = !sidebarCollapsed)}
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<GlobalSidebar currentPath={page.url.pathname} />
|
<GlobalSidebar currentPath={page.url.pathname} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,19 @@
|
||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import { loadJson, workspaceApiPath, workspaceRoute } from "$lib/workspace/api/http";
|
import { loadJson, workspaceRoute } from "$lib/workspace/api/http";
|
||||||
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace/sidebar/types";
|
import type { WorkspaceResponse } from "$lib/workspace/sidebar/types";
|
||||||
import type { LayoutLoad } from "./$types";
|
import type { LayoutLoad } from "./$types";
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
export const load: LayoutLoad = async ({ fetch, params, url }) => {
|
export const load: LayoutLoad = async ({ fetch, params, url }) => {
|
||||||
const workspaceId = params.workspaceId;
|
if (params.workspaceId) {
|
||||||
if (workspaceId) {
|
return {};
|
||||||
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
|
|
||||||
const [workspace, repositories] = await Promise.all([
|
|
||||||
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
|
|
||||||
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
workspaceScoped: true,
|
|
||||||
workspace: workspace.data,
|
|
||||||
workspaceError: workspace.error,
|
|
||||||
repositories: repositories.data,
|
|
||||||
repositoriesError: repositories.error,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicRoutes = new Set(["/account", "/login/device"]);
|
const publicRoutes = new Set(["/account", "/login/device"]);
|
||||||
if (publicRoutes.has(url.pathname)) {
|
if (publicRoutes.has(url.pathname)) {
|
||||||
return { workspaceScoped: false };
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
|
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
|
||||||
|
|
@ -33,5 +21,5 @@ export const load: LayoutLoad = async ({ fetch, params, url }) => {
|
||||||
const scopedPath = workspaceRoute(workspace.data.workspace_id);
|
const scopedPath = workspaceRoute(workspace.data.workspace_id);
|
||||||
throw redirect(307, `${scopedPath}${url.search}`);
|
throw redirect(307, `${scopedPath}${url.search}`);
|
||||||
}
|
}
|
||||||
return { workspaceScoped: false };
|
return {};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
35
web/workspace/src/routes/w/[workspaceId]/+layout.svelte
Normal file
35
web/workspace/src/routes/w/[workspaceId]/+layout.svelte
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
|
||||||
|
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
||||||
|
import { getSidebarController } from '$lib/workspace/sidebar/context';
|
||||||
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
|
let { data, children }: LayoutProps = $props();
|
||||||
|
let sidebarCollapsed = $state(false);
|
||||||
|
const sidebarController = getSidebarController();
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
sidebarCollapsed = !sidebarCollapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
sidebarController.setCollapsed(sidebarCollapsed);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet workspaceSidebar()}
|
||||||
|
<WorkspaceSidebar
|
||||||
|
workspace={data.workspace ?? null}
|
||||||
|
workspaceError={data.workspaceError ?? null}
|
||||||
|
repositories={data.repositories ?? null}
|
||||||
|
repositoriesError={data.repositoriesError ?? null}
|
||||||
|
currentPath={page.url.pathname}
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggleCollapsed={toggleSidebar}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<SidebarOverride sidebar={workspaceSidebar} />
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
19
web/workspace/src/routes/w/[workspaceId]/+layout.ts
Normal file
19
web/workspace/src/routes/w/[workspaceId]/+layout.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace/sidebar/types";
|
||||||
|
import type { LayoutLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: LayoutLoad = async ({ fetch, params }) => {
|
||||||
|
const workspaceId = params.workspaceId;
|
||||||
|
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
|
||||||
|
const [workspace, repositories] = await Promise.all([
|
||||||
|
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
|
||||||
|
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspace: workspace.data,
|
||||||
|
workspaceError: workspace.error,
|
||||||
|
repositories: repositories.data,
|
||||||
|
repositoriesError: repositories.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user