mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Add company settings plugin slot
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
860a5d0e8b
commit
67f90ccde6
18 files changed
+438
-5
No files matched your search
@@ -364,13 +364,16 @@ export interface PaperclipPluginManifestV1 {
|
||||
| "contextMenuItem"
|
||||
| "commentAnnotation"
|
||||
| "commentContextMenuItem"
|
||||
| "settingsPage";
|
||||
| "settingsPage"
|
||||
| "companySettingsPage";
|
||||
id: string;
|
||||
displayName: string;
|
||||
/** Which export name in the UI bundle provides this component */
|
||||
exportName: string;
|
||||
/** For detailTab: which entity types this tab appears on */
|
||||
entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">;
|
||||
/** For page and companySettingsPage: single route segment */
|
||||
routePath?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -1206,6 +1209,8 @@ For plugins that need richer settings UX beyond what JSON Schema can express, th
|
||||
|
||||
Both approaches coexist: a plugin can use the auto-generated form for simple config and add a custom settings page slot for advanced configuration or operational dashboards.
|
||||
|
||||
For plugins that need a company-scoped settings surface, declare a `companySettingsPage` slot with a `routePath`. The host renders a sidebar item under Company Settings and mounts the component at `/:companyPrefix/company/settings/:routePath`. The page receives `companyId` and `companyPrefix` in its host context. Core settings routes such as `access`, `invites`, `environments`, and `secrets` are reserved and cannot be shadowed by plugin declarations.
|
||||
|
||||
## 20. Local Tooling
|
||||
|
||||
Plugins that need filesystem, git, terminal, or process operations implement those directly. The host does not wrap or proxy these operations.
|
||||
@@ -1455,6 +1460,14 @@ Each plugin may expose a company-context main page:
|
||||
|
||||
This page is where board users do most day-to-day work.
|
||||
|
||||
## 24.4 Company Settings Plugin Page
|
||||
|
||||
Each ready plugin may expose a company settings page:
|
||||
|
||||
- `/:companyPrefix/company/settings/:routePath`
|
||||
|
||||
The host adds a matching Company Settings sidebar item using the slot `displayName`. Plugin settings route segments are single-segment slugs and must not collide with core company settings pages.
|
||||
|
||||
## 25. Uninstall And Data Lifecycle
|
||||
|
||||
When a plugin is uninstalled, the host must handle plugin-owned data explicitly.
|
||||
|
||||
@@ -7,6 +7,7 @@ export const PAGE_ROUTE = "kitchensink";
|
||||
export const SLOT_IDS = {
|
||||
page: "kitchen-sink-page",
|
||||
settingsPage: "kitchen-sink-settings-page",
|
||||
companySettingsPage: "kitchen-sink-company-settings-page",
|
||||
dashboardWidget: "kitchen-sink-dashboard-widget",
|
||||
sidebar: "kitchen-sink-sidebar-link",
|
||||
sidebarPanel: "kitchen-sink-sidebar-panel",
|
||||
@@ -23,6 +24,7 @@ export const SLOT_IDS = {
|
||||
export const EXPORT_NAMES = {
|
||||
page: "KitchenSinkPage",
|
||||
settingsPage: "KitchenSinkSettingsPage",
|
||||
companySettingsPage: "KitchenSinkCompanySettingsPage",
|
||||
dashboardWidget: "KitchenSinkDashboardWidget",
|
||||
sidebar: "KitchenSinkSidebarLink",
|
||||
sidebarPanel: "KitchenSinkSidebarPanel",
|
||||
|
||||
@@ -194,6 +194,13 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
displayName: "Kitchen Sink Settings",
|
||||
exportName: EXPORT_NAMES.settingsPage,
|
||||
},
|
||||
{
|
||||
type: "companySettingsPage",
|
||||
id: SLOT_IDS.companySettingsPage,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.companySettingsPage,
|
||||
routePath: "kitchen-sink",
|
||||
},
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: SLOT_IDS.dashboardWidget,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
usePluginToast,
|
||||
type PluginCommentAnnotationProps,
|
||||
type PluginCommentContextMenuItemProps,
|
||||
type PluginCompanySettingsPageProps,
|
||||
type PluginDetailTabProps,
|
||||
type PluginPageProps,
|
||||
type PluginProjectSidebarItemProps,
|
||||
@@ -2236,6 +2237,33 @@ export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function KitchenSinkCompanySettingsPage({ context }: PluginCompanySettingsPageProps) {
|
||||
const hostNavigation = useHostNavigation();
|
||||
const overview = usePluginOverview(context.companyId);
|
||||
const href = hostNavigation.resolveHref("/company/settings/kitchen-sink");
|
||||
|
||||
return (
|
||||
<div style={layoutStack}>
|
||||
<Section title="Company Settings Slot">
|
||||
<div style={subtleCardStyle}>
|
||||
<div style={{ display: "grid", gap: "8px" }}>
|
||||
<strong>Mounted inside company settings</strong>
|
||||
<div style={mutedTextStyle}>
|
||||
This fixture proves a ready plugin can add a settings sidebar item and render with company context.
|
||||
</div>
|
||||
<JsonBlock value={{
|
||||
companyId: context.companyId,
|
||||
companyPrefix: context.companyPrefix,
|
||||
route: href,
|
||||
pluginId: overview.data?.pluginId ?? PLUGIN_ID,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
|
||||
const hostNavigation = useHostNavigation();
|
||||
const overview = usePluginOverview(context.companyId);
|
||||
|
||||
@@ -353,6 +353,7 @@ export {
|
||||
PLUGIN_CAPABILITIES,
|
||||
PLUGIN_UI_SLOT_TYPES,
|
||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
|
||||
PLUGIN_STATE_SCOPE_KINDS,
|
||||
PLUGIN_JOB_STATUSES,
|
||||
PLUGIN_JOB_RUN_STATUSES,
|
||||
|
||||
@@ -146,6 +146,7 @@ export type {
|
||||
// Slot component prop interfaces
|
||||
export type {
|
||||
PluginPageProps,
|
||||
PluginCompanySettingsPageProps,
|
||||
PluginWidgetProps,
|
||||
PluginDetailTabProps,
|
||||
PluginSidebarProps,
|
||||
|
||||
@@ -229,6 +229,18 @@ export interface PluginPageProps {
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin company settings page component.
|
||||
*
|
||||
* A company settings page is mounted at
|
||||
* `/:companyPrefix/company/settings/:routePath` and always receives the active
|
||||
* company id and prefix when available.
|
||||
*/
|
||||
export interface PluginCompanySettingsPageProps {
|
||||
/** The current host context, including company id and prefix. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin dashboard widget component.
|
||||
*
|
||||
|
||||
@@ -857,6 +857,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
|
||||
"commentAnnotation",
|
||||
"commentContextMenuItem",
|
||||
"settingsPage",
|
||||
"companySettingsPage",
|
||||
] as const;
|
||||
export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number];
|
||||
|
||||
@@ -887,6 +888,20 @@ export const PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS = [
|
||||
export type PluginReservedCompanyRouteSegment =
|
||||
(typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number];
|
||||
|
||||
/**
|
||||
* Reserved route segments under `/:companyPrefix/company/settings/...` that
|
||||
* plugin company settings pages may not claim.
|
||||
*/
|
||||
export const PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS = [
|
||||
"general",
|
||||
"environments",
|
||||
"access",
|
||||
"invites",
|
||||
"secrets",
|
||||
] as const;
|
||||
export type PluginReservedCompanySettingsRouteSegment =
|
||||
(typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number];
|
||||
|
||||
/**
|
||||
* Launcher placement zones describe where a plugin-owned launcher can appear
|
||||
* in the host UI. These are intentionally aligned with current slot surfaces
|
||||
|
||||
@@ -111,6 +111,7 @@ export {
|
||||
PLUGIN_CAPABILITIES,
|
||||
PLUGIN_UI_SLOT_TYPES,
|
||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
|
||||
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
|
||||
PLUGIN_LAUNCHER_ACTIONS,
|
||||
PLUGIN_LAUNCHER_BOUNDS,
|
||||
@@ -226,6 +227,7 @@ export {
|
||||
type PluginCapability,
|
||||
type PluginUiSlotType,
|
||||
type PluginUiSlotEntityType,
|
||||
type PluginReservedCompanySettingsRouteSegment,
|
||||
type PluginLauncherPlacementZone,
|
||||
type PluginLauncherAction,
|
||||
type PluginLauncherBounds,
|
||||
|
||||
@@ -346,8 +346,11 @@ export interface PluginUiSlotDeclaration {
|
||||
*/
|
||||
entityTypes?: PluginUiSlotEntityType[];
|
||||
/**
|
||||
* Optional company-scoped route segment for page and routeSidebar slots.
|
||||
* Optional company-scoped route segment for page, routeSidebar, and
|
||||
* companySettingsPage slots.
|
||||
* Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
|
||||
* For companySettingsPage, `permissions` becomes
|
||||
* `/:companyPrefix/company/settings/permissions`.
|
||||
*/
|
||||
routePath?: string;
|
||||
/**
|
||||
|
||||
@@ -128,4 +128,30 @@ describe("plugin UI slot validators", () => {
|
||||
|
||||
expect(parsed.entityTypes).toEqual(["execution_workspace"]);
|
||||
});
|
||||
|
||||
it("accepts company settings page slots with a non-core settings route", () => {
|
||||
const parsed = pluginUiSlotDeclarationSchema.parse({
|
||||
type: "companySettingsPage",
|
||||
id: "permissions-settings",
|
||||
displayName: "Permissions",
|
||||
exportName: "PermissionsSettingsPage",
|
||||
routePath: "permissions",
|
||||
});
|
||||
|
||||
expect(parsed.routePath).toBe("permissions");
|
||||
});
|
||||
|
||||
it("prevents company settings page slots from shadowing core settings routes", () => {
|
||||
const parsed = pluginUiSlotDeclarationSchema.safeParse({
|
||||
type: "companySettingsPage",
|
||||
id: "access-settings",
|
||||
displayName: "Access",
|
||||
exportName: "AccessSettingsPage",
|
||||
routePath: "access",
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PLUGIN_UI_SLOT_TYPES,
|
||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||
PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS,
|
||||
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
|
||||
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
|
||||
PLUGIN_LAUNCHER_ACTIONS,
|
||||
PLUGIN_LAUNCHER_BOUNDS,
|
||||
@@ -322,10 +323,10 @@ export const pluginUiSlotDeclarationSchema = z.object({
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") {
|
||||
if (value.routePath && value.type !== "page" && value.type !== "routeSidebar" && value.type !== "companySettingsPage") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "routePath is only supported for page and routeSidebar slots",
|
||||
message: "routePath is only supported for page, routeSidebar, and companySettingsPage slots",
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
@@ -336,6 +337,13 @@ export const pluginUiSlotDeclarationSchema = z.object({
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
if (value.type === "companySettingsPage" && !value.routePath) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "companySettingsPage slots require routePath",
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
if (value.routePath && PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number])) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -343,6 +351,17 @@ export const pluginUiSlotDeclarationSchema = z.object({
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
value.type === "companySettingsPage"
|
||||
&& value.routePath
|
||||
&& PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number])
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `company settings routePath "${value.routePath}" is reserved by the host`,
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type PluginUiSlotDeclarationInput = z.infer<typeof pluginUiSlotDeclarationSchema>;
|
||||
|
||||
@@ -149,6 +149,7 @@ const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
|
||||
commentAnnotation: "ui.commentAnnotation.register",
|
||||
commentContextMenuItem: "ui.action.register",
|
||||
settingsPage: "instance.settings.register",
|
||||
companySettingsPage: "instance.settings.register",
|
||||
routeSidebar: "ui.sidebar.register",
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Activity } from "./pages/Activity";
|
||||
import { Inbox } from "./pages/Inbox";
|
||||
import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
|
||||
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
|
||||
import { CompanyAccess } from "./pages/CompanyAccess";
|
||||
import { CompanyInvites } from "./pages/CompanyInvites";
|
||||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
@@ -74,6 +75,7 @@ function boardRoutes() {
|
||||
<Route path="company/export/*" element={<CompanyExport />} />
|
||||
<Route path="company/import" element={<CompanyImport />} />
|
||||
<Route path="company/settings/secrets" element={<Secrets />} />
|
||||
<Route path="company/settings/:settingsRoutePath/*" element={<CompanySettingsPluginPage />} />
|
||||
<Route path="skills/*" element={<CompanySkills />} />
|
||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||
|
||||
@@ -10,6 +10,7 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn());
|
||||
const mockSidebarBadgesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
@@ -61,6 +62,10 @@ vi.mock("@/api/sidebarBadges", () => ({
|
||||
sidebarBadgesApi: mockSidebarBadgesApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
usePluginSlots: mockUsePluginSlots,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -83,6 +88,11 @@ describe("CompanySettingsSidebar", () => {
|
||||
failedRuns: 0,
|
||||
joinRequests: 2,
|
||||
});
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [],
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -154,4 +164,50 @@ describe("CompanySettingsSidebar", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders company settings pages contributed by ready plugins", async () => {
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [
|
||||
{
|
||||
type: "companySettingsPage",
|
||||
id: "permissions",
|
||||
displayName: "Permissions",
|
||||
exportName: "PermissionsPage",
|
||||
routePath: "permissions",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "paperclip-ee",
|
||||
pluginDisplayName: "Paperclip EE",
|
||||
pluginVersion: "0.1.0",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanySettingsSidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Permissions");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/permissions",
|
||||
label: "Permissions",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,22 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react";
|
||||
import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, Shield, SlidersHorizontal } from "lucide-react";
|
||||
import { sidebarBadgesApi } from "@/api/sidebarBadges";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Link } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { usePluginSlots } from "@/plugins/slots";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
|
||||
export function CompanySettingsSidebar() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { slots: companySettingsPluginSlots } = usePluginSlots({
|
||||
slotTypes: ["companySettingsPage"],
|
||||
companyId: selectedCompanyId,
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: badges } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.sidebarBadges(selectedCompanyId)
|
||||
@@ -67,6 +73,17 @@ export function CompanySettingsSidebar() {
|
||||
badge={badges?.joinRequests ?? 0}
|
||||
end
|
||||
/>
|
||||
{companySettingsPluginSlots
|
||||
.filter((slot) => slot.routePath)
|
||||
.map((slot) => (
|
||||
<SidebarNavItem
|
||||
key={`${slot.pluginKey}:${slot.id}`}
|
||||
to={`/company/settings/${slot.routePath}`}
|
||||
label={slot.displayName}
|
||||
icon={Puzzle}
|
||||
end
|
||||
/>
|
||||
))}
|
||||
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
|
||||
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CompanySettingsPluginPage } from "./CompanySettingsPluginPage";
|
||||
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||
const mockParams = vi.hoisted(() => ({
|
||||
companyPrefix: "PAP" as string | undefined,
|
||||
settingsRoutePath: "permissions" as string | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({
|
||||
setBreadcrumbs: mockSetBreadcrumbs,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }],
|
||||
selectedCompanyId: "company-1",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
|
||||
useLocation: () => ({ pathname: "/PAP/company/settings/permissions", search: "", hash: "" }),
|
||||
useParams: () => mockParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
usePluginSlots: mockUsePluginSlots,
|
||||
PluginSlotMount: ({
|
||||
slot,
|
||||
context,
|
||||
}: {
|
||||
slot: { displayName: string };
|
||||
context: { companyId: string | null; companyPrefix: string | null };
|
||||
}) => (
|
||||
<div data-testid="plugin-slot-mount">
|
||||
{slot.displayName}:{context.companyId}:{context.companyPrefix}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPage(container: HTMLDivElement) {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanySettingsPluginPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("CompanySettingsPluginPage", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockParams.companyPrefix = "PAP";
|
||||
mockParams.settingsRoutePath = "permissions";
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [
|
||||
{
|
||||
type: "companySettingsPage",
|
||||
id: "permissions",
|
||||
displayName: "Permissions",
|
||||
exportName: "PermissionsPage",
|
||||
routePath: "permissions",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "paperclip-ee",
|
||||
pluginDisplayName: "Paperclip EE",
|
||||
pluginVersion: "0.1.0",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("mounts the matching company settings slot with company context", async () => {
|
||||
const root = await renderPage(container);
|
||||
|
||||
expect(container.querySelector('[data-testid="plugin-slot-mount"]')?.textContent).toBe(
|
||||
"Permissions:company-1:PAP",
|
||||
);
|
||||
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Permissions" },
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when no ready plugin declares the route", async () => {
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [],
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
const root = await renderPage(container);
|
||||
|
||||
expect(container.textContent).toContain("Page not found");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useParams } from "@/lib/router";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||
import { NotFoundPage } from "./NotFound";
|
||||
|
||||
export function CompanySettingsPluginPage() {
|
||||
const params = useParams<{
|
||||
companyPrefix?: string;
|
||||
settingsRoutePath?: string;
|
||||
}>();
|
||||
const { companyPrefix: routeCompanyPrefix, settingsRoutePath } = params;
|
||||
const { companies, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
const routeCompany = useMemo(() => {
|
||||
if (!routeCompanyPrefix) return null;
|
||||
const requested = routeCompanyPrefix.toUpperCase();
|
||||
return companies.find((company) => company.issuePrefix.toUpperCase() === requested) ?? null;
|
||||
}, [companies, routeCompanyPrefix]);
|
||||
const hasInvalidCompanyPrefix = Boolean(routeCompanyPrefix) && !routeCompany;
|
||||
const resolvedCompanyId = routeCompany?.id ?? (routeCompanyPrefix ? null : selectedCompanyId ?? null);
|
||||
const companyPrefix = resolvedCompanyId
|
||||
? companies.find((company) => company.id === resolvedCompanyId)?.issuePrefix ?? null
|
||||
: null;
|
||||
|
||||
const { slots, isLoading, errorMessage } = usePluginSlots({
|
||||
slotTypes: ["companySettingsPage"],
|
||||
companyId: resolvedCompanyId,
|
||||
enabled: Boolean(resolvedCompanyId && settingsRoutePath),
|
||||
});
|
||||
|
||||
const pageSlots = useMemo(() => {
|
||||
if (!settingsRoutePath) return [];
|
||||
return slots.filter((slot) => slot.routePath === settingsRoutePath);
|
||||
}, [settingsRoutePath, slots]);
|
||||
|
||||
const pageSlot = pageSlots.length === 1 ? pageSlots[0] : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageSlot) return;
|
||||
setBreadcrumbs([
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: pageSlot.displayName },
|
||||
]);
|
||||
}, [pageSlot, setBreadcrumbs]);
|
||||
|
||||
if (!resolvedCompanyId) {
|
||||
if (hasInvalidCompanyPrefix) {
|
||||
return <NotFoundPage scope="invalid_company_prefix" requestedPrefix={routeCompanyPrefix} />;
|
||||
}
|
||||
return <div className="text-sm text-muted-foreground">Select a company to view this page.</div>;
|
||||
}
|
||||
|
||||
if (!settingsRoutePath || isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
Plugin extensions unavailable: {errorMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageSlots.length > 1) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
Multiple plugins declare the company settings route <code>{settingsRoutePath}</code>. Disable one plugin or change its route.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pageSlot) {
|
||||
return <NotFoundPage scope="board" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PluginSlotMount
|
||||
slot={pageSlot}
|
||||
context={{ companyId: resolvedCompanyId, companyPrefix }}
|
||||
className="min-h-[200px]"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user