Files
Work/Frontend/src/components/shell/data/app-shell.context.tsx
2026-06-20 07:56:47 +01:00

353 lines
10 KiB
TypeScript

// 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;
name: 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[];
};
const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
installation: payload?.installation,
admin: payload?.admin,
organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
departments: Array.isArray(payload?.departments) ? payload.departments : [],
teams: Array.isArray(payload?.teams) ? payload.teams : [],
projects: Array.isArray(payload?.projects) ? payload.projects : [],
workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [],
});
type AppShellContextValue = {
status: Accessor<"idle" | "loading" | "success" | "error">;
error: Accessor<string>;
installation: Accessor<AppShellInstallation | undefined>;
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";
const serverName = payload.installation.name || payload.organizations[0]?.name || payload.installation.host;
return payload.organizations.map((organization, index) => ({
id: organization.id,
label: serverName || organization.name,
abbreviation: buildAbbreviation(serverName || 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";
const serverName = installation.name || organization.name || installation.host;
return {
id: installation.id,
name: serverName || fallbackActiveServer.name,
abbreviation: buildAbbreviation(serverName, 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",
groupLabel: payload.departments.find((department) => department.id === project.departmentId)?.name || "Projects",
parentLabel:
payload.teams.find((team) => team.id === project.teamId)?.name ||
payload.departments.find((department) => department.id === project.departmentId)?.name ||
"Shared project",
meta: (() => {
const workspaceCount = payload.workspaces.filter((workspace) => workspace.projectId === project.id).length;
return workspaceCount > 0 ? `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}` : undefined;
})(),
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.installation?.name || 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 } | string;
message?: string;
};
const errorMessage =
typeof body.message === "string"
? body.message
: typeof body.error === "string"
? body.error
: body.error?.message;
if (!response.ok || !body.data) {
throw new Error(errorMessage || "Failed to load app shell state.");
}
setPayload(normalizeAppShellPayload(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,
installation: createMemo(() => payload()?.installation),
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;
};