353 lines
10 KiB
TypeScript
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;
|
|
};
|