Feat: Hydrate shell from app state
This commit is contained in:
317
Frontend/src/components/shell/data/app-shell.context.tsx
Normal file
317
Frontend/src/components/shell/data/app-shell.context.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
// Path: Frontend/src/components/shell/data/app-shell.context.tsx
|
||||
|
||||
import {
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onMount,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js";
|
||||
import { Folder } from "../../../lib/icons";
|
||||
import { resolveAPIBase } from "../../../lib/api";
|
||||
import {
|
||||
activeDepartment as fallbackActiveDepartment,
|
||||
activeProject as fallbackActiveProject,
|
||||
activeServer as fallbackActiveServer,
|
||||
activeUserProfile as fallbackActiveUserProfile,
|
||||
departmentItems as fallbackDepartmentItems,
|
||||
organizationAdminDockActions,
|
||||
personalDockActions,
|
||||
projectItems as fallbackProjectItems,
|
||||
railItems as fallbackRailItems,
|
||||
workspaceTree as fallbackWorkspaceTree,
|
||||
type ActiveDepartment,
|
||||
type ActiveProject,
|
||||
type ActiveServer,
|
||||
type ActiveUserProfile,
|
||||
type DepartmentItem,
|
||||
type ProjectItem,
|
||||
type RailItem,
|
||||
type WorkspaceTreeNode,
|
||||
} from "./shell.data";
|
||||
|
||||
type AppShellInstallation = {
|
||||
id: string;
|
||||
mode: "personal" | "organizational" | string;
|
||||
access: string;
|
||||
protocol: string;
|
||||
host: string;
|
||||
isBootstrapped: boolean;
|
||||
};
|
||||
|
||||
type AppShellAdmin = {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
isInstanceAdmin: boolean;
|
||||
homeTitle: string;
|
||||
};
|
||||
|
||||
type AppShellOrganization = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type AppShellDepartment = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type AppShellTeam = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
departmentId?: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type AppShellProject = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
departmentId?: string;
|
||||
teamId?: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type AppShellWorkspace = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
kind: "organization" | "department" | "team" | "project" | string;
|
||||
departmentId?: string;
|
||||
teamId?: string;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
type AppShellPayload = {
|
||||
installation?: AppShellInstallation;
|
||||
admin?: AppShellAdmin;
|
||||
organizations: AppShellOrganization[];
|
||||
departments: AppShellDepartment[];
|
||||
teams: AppShellTeam[];
|
||||
projects: AppShellProject[];
|
||||
workspaces: AppShellWorkspace[];
|
||||
};
|
||||
|
||||
type AppShellContextValue = {
|
||||
status: Accessor<"idle" | "loading" | "success" | "error">;
|
||||
error: Accessor<string>;
|
||||
railItems: Accessor<readonly RailItem[]>;
|
||||
activeServer: Accessor<ActiveServer>;
|
||||
activeProject: Accessor<ActiveProject>;
|
||||
activeDepartment: Accessor<ActiveDepartment>;
|
||||
projectItems: Accessor<readonly ProjectItem[]>;
|
||||
departmentItems: Accessor<readonly DepartmentItem[]>;
|
||||
workspaceTree: Accessor<readonly WorkspaceTreeNode[]>;
|
||||
activeUserProfile: Accessor<ActiveUserProfile>;
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AppShellContext = createContext<AppShellContextValue>();
|
||||
|
||||
const buildAbbreviation = (name: string, fallback: string): string => {
|
||||
const parts = name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const abbreviation = parts
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
|
||||
return abbreviation || fallback;
|
||||
};
|
||||
|
||||
const buildRailItems = (payload: AppShellPayload | null): readonly RailItem[] => {
|
||||
if (!payload?.installation || payload.organizations.length === 0) {
|
||||
return fallbackRailItems;
|
||||
}
|
||||
|
||||
const kind = payload.installation.mode === "personal" ? "personal" : "organization";
|
||||
|
||||
return payload.organizations.map((organization, index) => ({
|
||||
id: organization.id,
|
||||
label: organization.name,
|
||||
abbreviation: buildAbbreviation(organization.name, kind === "personal" ? "P" : "O"),
|
||||
kind,
|
||||
active: index === 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const buildActiveServer = (payload: AppShellPayload | null): ActiveServer => {
|
||||
const installation = payload?.installation;
|
||||
const organization = payload?.organizations[0];
|
||||
|
||||
if (!installation || !organization) {
|
||||
return fallbackActiveServer;
|
||||
}
|
||||
|
||||
const kind = installation.mode === "personal" ? "personal" : "organization";
|
||||
|
||||
return {
|
||||
id: installation.id,
|
||||
name: organization.name || installation.host || fallbackActiveServer.name,
|
||||
abbreviation: buildAbbreviation(organization.name || installation.host, kind === "personal" ? "P" : "O"),
|
||||
kind,
|
||||
connectedLabel: kind === "organization" ? `${payload?.teams.length ?? 0} connected` : undefined,
|
||||
subtitle: kind === "personal" ? installation.host || payload?.admin?.homeTitle || "Personal home" : undefined,
|
||||
dockActions: kind === "personal" ? personalDockActions : organizationAdminDockActions,
|
||||
};
|
||||
};
|
||||
|
||||
const buildProjectItems = (payload: AppShellPayload | null): readonly ProjectItem[] => {
|
||||
if (!payload?.projects.length) {
|
||||
return fallbackProjectItems;
|
||||
}
|
||||
|
||||
return payload.projects.map((project, index) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
description: project.slug || "Persisted project workspace",
|
||||
active: index === 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const buildActiveProject = (payload: AppShellPayload | null): ActiveProject => {
|
||||
const firstProject = payload?.projects[0];
|
||||
|
||||
if (!firstProject) {
|
||||
return fallbackActiveProject;
|
||||
}
|
||||
|
||||
return {
|
||||
id: firstProject.id,
|
||||
name: firstProject.name,
|
||||
};
|
||||
};
|
||||
|
||||
const buildDepartmentItems = (payload: AppShellPayload | null): readonly DepartmentItem[] => {
|
||||
if (!payload?.departments.length) {
|
||||
return fallbackDepartmentItems;
|
||||
}
|
||||
|
||||
return payload.departments.map((department, index) => ({
|
||||
id: department.id,
|
||||
name: department.name,
|
||||
teams: payload.teams
|
||||
.filter((team) => team.departmentId === department.id)
|
||||
.map((team) => team.name),
|
||||
active: index === 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const buildActiveDepartment = (payload: AppShellPayload | null): ActiveDepartment => {
|
||||
const firstDepartment = payload?.departments[0];
|
||||
|
||||
if (!firstDepartment) {
|
||||
return fallbackActiveDepartment;
|
||||
}
|
||||
|
||||
const firstTeamName = payload?.teams.find((team) => team.departmentId === firstDepartment.id)?.name ?? "";
|
||||
|
||||
return {
|
||||
id: firstDepartment.id,
|
||||
name: firstDepartment.name,
|
||||
teamName: firstTeamName,
|
||||
};
|
||||
};
|
||||
|
||||
const buildWorkspaceTree = (payload: AppShellPayload | null): readonly WorkspaceTreeNode[] => {
|
||||
if (!payload?.projects.length) {
|
||||
return fallbackWorkspaceTree;
|
||||
}
|
||||
|
||||
// The workspace tree should represent items inside the current project, not the
|
||||
// project container itself. We do not have project-contents hydration yet, so
|
||||
// return an empty tree rather than showing the project root as a fake item.
|
||||
return [];
|
||||
};
|
||||
|
||||
const buildActiveUserProfile = (payload: AppShellPayload | null): ActiveUserProfile => {
|
||||
if (!payload?.admin) {
|
||||
return fallbackActiveUserProfile;
|
||||
}
|
||||
|
||||
const organizationName = payload.organizations[0]?.name ?? fallbackActiveServer.name;
|
||||
const departmentName = payload.departments[0]?.name;
|
||||
|
||||
return {
|
||||
name: payload.admin.displayName,
|
||||
email: payload.admin.email,
|
||||
roleLabel: payload.admin.isInstanceAdmin ? "Instance admin" : "Member",
|
||||
contextLabel: departmentName ? `${organizationName} • ${departmentName}` : organizationName,
|
||||
};
|
||||
};
|
||||
|
||||
export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Element => {
|
||||
const [status, setStatus] = createSignal<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [error, setError] = createSignal("");
|
||||
const [payload, setPayload] = createSignal<AppShellPayload | null>(null);
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
setStatus("loading");
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${resolveAPIBase()}/app-shell`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const body = (await response.json()) as { data?: AppShellPayload; error?: { message?: string } };
|
||||
|
||||
if (!response.ok || !body.data) {
|
||||
throw new Error(body.error?.message || "Failed to load app shell state.");
|
||||
}
|
||||
|
||||
setPayload(body.data);
|
||||
setStatus("success");
|
||||
} catch (loadError) {
|
||||
setStatus("error");
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load app shell state.");
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
const value: AppShellContextValue = {
|
||||
status,
|
||||
error,
|
||||
railItems: createMemo(() => buildRailItems(payload())),
|
||||
activeServer: createMemo(() => buildActiveServer(payload())),
|
||||
activeProject: createMemo(() => buildActiveProject(payload())),
|
||||
activeDepartment: createMemo(() => buildActiveDepartment(payload())),
|
||||
projectItems: createMemo(() => buildProjectItems(payload())),
|
||||
departmentItems: createMemo(() => buildDepartmentItems(payload())),
|
||||
workspaceTree: createMemo(() => buildWorkspaceTree(payload())),
|
||||
activeUserProfile: createMemo(() => buildActiveUserProfile(payload())),
|
||||
reload: load,
|
||||
};
|
||||
|
||||
return <AppShellContext.Provider value={value}>{props.children}</AppShellContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAppShellData = (): AppShellContextValue => {
|
||||
const context = useContext(AppShellContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useAppShellData must be used within AppShellDataProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
Reference in New Issue
Block a user