mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
Scope plugin config save/test by company
This commit is contained in:
parent
4272b31136
commit
5317029ef4
6 changed files with 348 additions and 43 deletions
|
|
@ -23,6 +23,11 @@ const mockLifecycle = vi.hoisted(() => ({
|
||||||
disable: vi.fn(),
|
disable: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockWorkerManager = vi.hoisted(() => ({
|
||||||
|
call: vi.fn(),
|
||||||
|
isRunning: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../services/plugin-registry.js", () => ({
|
vi.mock("../services/plugin-registry.js", () => ({
|
||||||
pluginRegistryService: () => mockRegistry,
|
pluginRegistryService: () => mockRegistry,
|
||||||
}));
|
}));
|
||||||
|
|
@ -437,6 +442,61 @@ describe.sequential("plugin install and upgrade authz", () => {
|
||||||
expect(res.body.configJson).toEqual({ botName: "company-a" });
|
expect(res.body.configJson).toEqual({ botName: "company-a" });
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
|
it("rejects plugin config tests with secret refs when company scope is missing", async () => {
|
||||||
|
readyPlugin();
|
||||||
|
|
||||||
|
const { app } = await createApp(boardActor({
|
||||||
|
isInstanceAdmin: true,
|
||||||
|
companyIds: [companyA],
|
||||||
|
}), {}, {
|
||||||
|
bridgeDeps: { workerManager: mockWorkerManager },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/plugins/${pluginId}/config/test`)
|
||||||
|
.send({
|
||||||
|
configJson: {
|
||||||
|
apiKeyRef: "77777777-7777-4777-8777-777777777777",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
expect(res.body.error).toMatch(/secret references require companyId/i);
|
||||||
|
expect(mockWorkerManager.call).not.toHaveBeenCalled();
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it("passes company-scoped plugin config tests through when companyId is provided", async () => {
|
||||||
|
readyPlugin();
|
||||||
|
mockWorkerManager.call.mockResolvedValueOnce({ ok: true, warnings: [] });
|
||||||
|
|
||||||
|
const { app } = await createApp(boardActor({
|
||||||
|
isInstanceAdmin: true,
|
||||||
|
companyIds: [companyA],
|
||||||
|
}), {}, {
|
||||||
|
bridgeDeps: { workerManager: mockWorkerManager },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/plugins/${pluginId}/config/test`)
|
||||||
|
.send({
|
||||||
|
companyId: companyA,
|
||||||
|
configJson: {
|
||||||
|
apiKeyRef: "77777777-7777-4777-8777-777777777777",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockWorkerManager.call).toHaveBeenCalledWith(
|
||||||
|
pluginId,
|
||||||
|
"validateConfig",
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
apiKeyRef: "77777777-7777-4777-8777-777777777777",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
it("allows instance admins to upgrade plugins", async () => {
|
it("allows instance admins to upgrade plugins", async () => {
|
||||||
const pluginId = "11111111-1111-4111-8111-111111111111";
|
const pluginId = "11111111-1111-4111-8111-111111111111";
|
||||||
mockRegistry.getById.mockResolvedValue({
|
mockRegistry.getById.mockResolvedValue({
|
||||||
|
|
|
||||||
|
|
@ -2298,11 +2298,12 @@ export function pluginRoutes(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = req.body as { configJson?: Record<string, unknown> } | undefined;
|
const body = req.body as { configJson?: Record<string, unknown>; companyId?: string } | undefined;
|
||||||
if (!body?.configJson || typeof body.configJson !== "object") {
|
if (!body?.configJson || typeof body.configJson !== "object") {
|
||||||
res.status(400).json({ error: '"configJson" is required and must be an object' });
|
res.status(400).json({ error: '"configJson" is required and must be an object' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const companyId = resolvePluginConfigCompanyId(req);
|
||||||
|
|
||||||
// Fast schema-level rejection before hitting the worker RPC.
|
// Fast schema-level rejection before hitting the worker RPC.
|
||||||
const schema = plugin.manifestJson?.instanceConfigSchema;
|
const schema = plugin.manifestJson?.instanceConfigSchema;
|
||||||
|
|
@ -2318,6 +2319,11 @@ export function pluginRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema);
|
||||||
|
if (secretRefsByPath.size > 0 && !companyId) {
|
||||||
|
res.status(422).json({ error: "Plugin secret references require companyId" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const result = await bridgeDeps.workerManager.call(
|
const result = await bridgeDeps.workerManager.call(
|
||||||
plugin.id,
|
plugin.id,
|
||||||
"validateConfig",
|
"validateConfig",
|
||||||
|
|
|
||||||
|
|
@ -61,4 +61,16 @@ describe("pluginsApi local folders", () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes company scope through config test requests", async () => {
|
||||||
|
await pluginsApi.testConfig("plugin-1", { defaultCompanyId: "company-1" }, "company-1");
|
||||||
|
|
||||||
|
expect(mockApi.post).toHaveBeenCalledWith(
|
||||||
|
"/plugins/plugin-1/config/test",
|
||||||
|
{
|
||||||
|
configJson: { defaultCompanyId: "company-1" },
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -386,8 +386,11 @@ export const pluginsApi = {
|
||||||
* @param pluginId - UUID of the plugin.
|
* @param pluginId - UUID of the plugin.
|
||||||
* @param configJson - Configuration values to validate.
|
* @param configJson - Configuration values to validate.
|
||||||
*/
|
*/
|
||||||
testConfig: (pluginId: string, configJson: Record<string, unknown>) =>
|
testConfig: (pluginId: string, configJson: Record<string, unknown>, companyId?: string | null) =>
|
||||||
api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { configJson }),
|
api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, {
|
||||||
|
configJson,
|
||||||
|
companyId: companyId ?? undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List manifest-declared and stored company-scoped local folders for a plugin.
|
* List manifest-declared and stored company-scoped local folders for a plugin.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
@ -12,6 +11,8 @@ const mockPluginsApi = vi.hoisted(() => ({
|
||||||
dashboard: vi.fn(),
|
dashboard: vi.fn(),
|
||||||
logs: vi.fn(),
|
logs: vi.fn(),
|
||||||
getConfig: vi.fn(),
|
getConfig: vi.fn(),
|
||||||
|
saveConfig: vi.fn(),
|
||||||
|
testConfig: vi.fn(),
|
||||||
listLocalFolders: vi.fn(),
|
listLocalFolders: vi.fn(),
|
||||||
configureLocalFolder: vi.fn(),
|
configureLocalFolder: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
@ -30,6 +31,7 @@ vi.mock("@/context/BreadcrumbContext", () => ({
|
||||||
|
|
||||||
vi.mock("@/context/CompanyContext", () => ({
|
vi.mock("@/context/CompanyContext", () => ({
|
||||||
useCompany: () => ({
|
useCompany: () => ({
|
||||||
|
companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }],
|
||||||
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" },
|
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" },
|
||||||
selectedCompanyId: "company-1",
|
selectedCompanyId: "company-1",
|
||||||
}),
|
}),
|
||||||
|
|
@ -54,10 +56,17 @@ vi.mock("@/components/PageTabBar", () => ({
|
||||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
async function flushReact() {
|
async function flushReact() {
|
||||||
await act(async () => {
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await new Promise((resolve) => window.setTimeout(resolve, 10));
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
}
|
||||||
});
|
|
||||||
|
async function waitFor(predicate: () => boolean, timeoutMs = 500) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
if (predicate()) return;
|
||||||
|
await flushReact();
|
||||||
|
}
|
||||||
|
throw new Error("Timed out waiting for UI to settle");
|
||||||
}
|
}
|
||||||
|
|
||||||
function basePlugin(overrides: Record<string, unknown> = {}) {
|
function basePlugin(overrides: Record<string, unknown> = {}) {
|
||||||
|
|
@ -124,13 +133,12 @@ async function renderSettings(container: HTMLDivElement) {
|
||||||
defaultOptions: { queries: { retry: false } },
|
defaultOptions: { queries: { retry: false } },
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
root.render(
|
||||||
root.render(
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<PluginSettings />
|
||||||
<PluginSettings />
|
</QueryClientProvider>,
|
||||||
</QueryClientProvider>,
|
);
|
||||||
);
|
await waitFor(() => !container.textContent?.includes("Loading plugin details..."));
|
||||||
});
|
|
||||||
await flushReact();
|
await flushReact();
|
||||||
await flushReact();
|
await flushReact();
|
||||||
return root;
|
return root;
|
||||||
|
|
@ -147,6 +155,9 @@ describe("PluginSettings", () => {
|
||||||
mockPluginsApi.dashboard.mockResolvedValue(null);
|
mockPluginsApi.dashboard.mockResolvedValue(null);
|
||||||
mockPluginsApi.health.mockResolvedValue({ pluginId: "plugin-1", status: "ready", healthy: true, checks: [] });
|
mockPluginsApi.health.mockResolvedValue({ pluginId: "plugin-1", status: "ready", healthy: true, checks: [] });
|
||||||
mockPluginsApi.logs.mockResolvedValue([]);
|
mockPluginsApi.logs.mockResolvedValue([]);
|
||||||
|
mockPluginsApi.getConfig.mockResolvedValue(null);
|
||||||
|
mockPluginsApi.saveConfig.mockResolvedValue({});
|
||||||
|
mockPluginsApi.testConfig.mockResolvedValue({ valid: true });
|
||||||
mockPluginsApi.listLocalFolders.mockResolvedValue({
|
mockPluginsApi.listLocalFolders.mockResolvedValue({
|
||||||
pluginId: "plugin-1",
|
pluginId: "plugin-1",
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
|
|
@ -169,9 +180,7 @@ describe("PluginSettings", () => {
|
||||||
const link = container.querySelector('a[href="/company/settings/environments"]');
|
const link = container.querySelector('a[href="/company/settings/environments"]');
|
||||||
expect(link?.textContent).toContain("Open Company Environments");
|
expect(link?.textContent).toContain("Open Company Environments");
|
||||||
|
|
||||||
await act(async () => {
|
root.unmount();
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders unconfigured manifest local folders with required paths", async () => {
|
it("renders unconfigured manifest local folders with required paths", async () => {
|
||||||
|
|
@ -205,9 +214,7 @@ describe("PluginSettings", () => {
|
||||||
expect(container.textContent).toContain("Missing directories: raw, wiki");
|
expect(container.textContent).toContain("Missing directories: raw, wiki");
|
||||||
expect(container.textContent).toContain("Missing files: WIKI.md, index.md");
|
expect(container.textContent).toContain("Missing files: WIKI.md, index.md");
|
||||||
|
|
||||||
await act(async () => {
|
root.unmount();
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders invalid configured folders with validation problems", async () => {
|
it("renders invalid configured folders with validation problems", async () => {
|
||||||
|
|
@ -247,9 +254,7 @@ describe("PluginSettings", () => {
|
||||||
expect(container.textContent).toContain("Required file is missing.");
|
expect(container.textContent).toContain("Required file is missing.");
|
||||||
expect(container.textContent).toContain("Missing files: WIKI.md");
|
expect(container.textContent).toContain("Missing files: WIKI.md");
|
||||||
|
|
||||||
await act(async () => {
|
root.unmount();
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render required paths as present when the configured root cannot be inspected", async () => {
|
it("does not render required paths as present when the configured root cannot be inspected", async () => {
|
||||||
|
|
@ -286,9 +291,7 @@ describe("PluginSettings", () => {
|
||||||
expect(container.textContent).toContain("Configured root was not inspected.");
|
expect(container.textContent).toContain("Configured root was not inspected.");
|
||||||
expect(container.textContent).not.toContain("Present");
|
expect(container.textContent).not.toContain("Present");
|
||||||
|
|
||||||
await act(async () => {
|
root.unmount();
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders healthy folders without validation problems", async () => {
|
it("renders healthy folders without validation problems", async () => {
|
||||||
|
|
@ -330,8 +333,72 @@ describe("PluginSettings", () => {
|
||||||
expect(container.textContent).toContain("Present");
|
expect(container.textContent).toContain("Present");
|
||||||
expect(container.textContent).not.toContain("Validation problems");
|
expect(container.textContent).not.toContain("Validation problems");
|
||||||
|
|
||||||
await act(async () => {
|
root.unmount();
|
||||||
root.unmount();
|
});
|
||||||
});
|
|
||||||
|
it("renders company-like config fields as selectors and scopes save/test requests", async () => {
|
||||||
|
mockPluginsApi.get.mockResolvedValue(basePlugin({
|
||||||
|
status: "ready",
|
||||||
|
supportsConfigTest: true,
|
||||||
|
manifestJson: {
|
||||||
|
displayName: "Forgejo Sync",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "Syncs Forgejo issues.",
|
||||||
|
author: "Paperclip",
|
||||||
|
capabilities: [],
|
||||||
|
instanceConfigSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["defaultCompanyId"],
|
||||||
|
properties: {
|
||||||
|
defaultCompanyId: {
|
||||||
|
type: "string",
|
||||||
|
title: "Default Company ID",
|
||||||
|
description: "Which company this plugin should target by default.",
|
||||||
|
},
|
||||||
|
baseUrl: {
|
||||||
|
type: "string",
|
||||||
|
title: "Base URL",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const root = await renderSettings(container);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Paperclip");
|
||||||
|
expect(container.querySelector('input[aria-label="Default Company ID"]')).toBeNull();
|
||||||
|
const selector = container.querySelector('[aria-label="Default Company ID"]');
|
||||||
|
expect(selector?.textContent).toContain("Paperclip");
|
||||||
|
|
||||||
|
const buttons = Array.from(container.querySelectorAll("button"));
|
||||||
|
const saveButton = buttons.find((button) => button.textContent?.includes("Save Configuration"));
|
||||||
|
const testButton = buttons.find((button) => button.textContent?.includes("Test Configuration"));
|
||||||
|
expect(saveButton).toBeTruthy();
|
||||||
|
expect(testButton).toBeTruthy();
|
||||||
|
|
||||||
|
saveButton?.click();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(mockPluginsApi.saveConfig).toHaveBeenCalledWith(
|
||||||
|
"plugin-1",
|
||||||
|
{
|
||||||
|
defaultCompanyId: "company-1",
|
||||||
|
},
|
||||||
|
"company-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
testButton?.click();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(mockPluginsApi.testConfig).toHaveBeenCalledWith(
|
||||||
|
"plugin-1",
|
||||||
|
{
|
||||||
|
defaultCompanyId: "company-1",
|
||||||
|
},
|
||||||
|
"company-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { pluginsApi, type PluginLocalFolderStatus } from "@/api/plugins";
|
||||||
import { queryKeys } from "@/lib/queryKeys";
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { ChoosePathButton } from "@/components/PathInstructionsModal";
|
import { ChoosePathButton } from "@/components/PathInstructionsModal";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -19,6 +20,7 @@ import {
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
import { PageTabBar } from "@/components/PageTabBar";
|
import { PageTabBar } from "@/components/PageTabBar";
|
||||||
import {
|
import {
|
||||||
|
|
@ -60,7 +62,7 @@ import {
|
||||||
* @see doc/plugins/PLUGIN_SPEC.md §19.8 — Plugin Settings UI.
|
* @see doc/plugins/PLUGIN_SPEC.md §19.8 — Plugin Settings UI.
|
||||||
*/
|
*/
|
||||||
export function PluginSettings() {
|
export function PluginSettings() {
|
||||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
const { companies, selectedCompany, selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const { companyPrefix, pluginId } = useParams<{ companyPrefix?: string; pluginId: string }>();
|
const { companyPrefix, pluginId } = useParams<{ companyPrefix?: string; pluginId: string }>();
|
||||||
const [activeTab, setActiveTab] = useState<"configuration" | "status">("configuration");
|
const [activeTab, setActiveTab] = useState<"configuration" | "status">("configuration");
|
||||||
|
|
@ -246,6 +248,7 @@ export function PluginSettings() {
|
||||||
<PluginConfigForm
|
<PluginConfigForm
|
||||||
pluginId={pluginId!}
|
pluginId={pluginId!}
|
||||||
companyId={selectedCompanyId}
|
companyId={selectedCompanyId}
|
||||||
|
companies={companiesForSelector(companies)}
|
||||||
schema={configSchema!}
|
schema={configSchema!}
|
||||||
initialValues={configData?.configJson}
|
initialValues={configData?.configJson}
|
||||||
isLoading={configLoading}
|
isLoading={configLoading}
|
||||||
|
|
@ -658,7 +661,7 @@ function PluginLocalFolderRow({ pluginId, companyId, declaration, status }: Plug
|
||||||
requiredDirectories: declaration.requiredDirectories,
|
requiredDirectories: declaration.requiredDirectories,
|
||||||
requiredFiles: declaration.requiredFiles,
|
requiredFiles: declaration.requiredFiles,
|
||||||
}),
|
}),
|
||||||
onSuccess: (nextStatus) => {
|
onSuccess: (nextStatus: PluginLocalFolderStatus) => {
|
||||||
setMessage({
|
setMessage({
|
||||||
type: nextStatus.healthy ? "success" : "error",
|
type: nextStatus.healthy ? "success" : "error",
|
||||||
text: nextStatus.healthy
|
text: nextStatus.healthy
|
||||||
|
|
@ -921,6 +924,7 @@ function isLikelyAbsolutePath(pathValue: string) {
|
||||||
interface PluginConfigFormProps {
|
interface PluginConfigFormProps {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
|
companies: CompanyOption[];
|
||||||
schema: JsonSchemaNode;
|
schema: JsonSchemaNode;
|
||||||
initialValues?: Record<string, unknown>;
|
initialValues?: Record<string, unknown>;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
|
@ -937,13 +941,16 @@ interface PluginConfigFormProps {
|
||||||
* Separated from PluginSettings to isolate re-render scope — only the form
|
* Separated from PluginSettings to isolate re-render scope — only the form
|
||||||
* re-renders on field changes, not the entire page.
|
* re-renders on field changes, not the entire page.
|
||||||
*/
|
*/
|
||||||
function PluginConfigForm({ pluginId, companyId, schema, initialValues, isLoading, pluginStatus, supportsConfigTest }: PluginConfigFormProps) {
|
function PluginConfigForm({ pluginId, companyId, companies, schema, initialValues, isLoading, pluginStatus, supportsConfigTest }: PluginConfigFormProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const companyFields = getCompanyConfigFields(schema);
|
||||||
|
const formSchema = omitSchemaProperties(schema, companyFields.map((field) => field.key));
|
||||||
|
|
||||||
// Form values: start with saved values, fall back to schema defaults
|
// Form values: start with saved values, fall back to schema defaults
|
||||||
const [values, setValues] = useState<Record<string, unknown>>(() => ({
|
const [values, setValues] = useState<Record<string, unknown>>(() => ({
|
||||||
...getDefaultValues(schema),
|
...getDefaultValues(schema),
|
||||||
...(initialValues ?? {}),
|
...(initialValues ?? {}),
|
||||||
|
...getCompanyFieldDefaults(companyFields, initialValues, companyId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Sync when saved config loads asynchronously — only on first load so we
|
// Sync when saved config loads asynchronously — only on first load so we
|
||||||
|
|
@ -956,9 +963,10 @@ function PluginConfigForm({ pluginId, companyId, schema, initialValues, isLoadin
|
||||||
setValues({
|
setValues({
|
||||||
...getDefaultValues(schema),
|
...getDefaultValues(schema),
|
||||||
...initialValues,
|
...initialValues,
|
||||||
|
...getCompanyFieldDefaults(companyFields, initialValues, companyId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [initialValues, schema]);
|
}, [companyFields, companyId, initialValues, schema]);
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [saveMessage, setSaveMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
const [saveMessage, setSaveMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
@ -989,8 +997,8 @@ function PluginConfigForm({ pluginId, companyId, schema, initialValues, isLoadin
|
||||||
// Test configuration mutation
|
// Test configuration mutation
|
||||||
const testMutation = useMutation({
|
const testMutation = useMutation({
|
||||||
mutationFn: (configJson: Record<string, unknown>) =>
|
mutationFn: (configJson: Record<string, unknown>) =>
|
||||||
pluginsApi.testConfig(pluginId, configJson),
|
pluginsApi.testConfig(pluginId, configJson, companyId),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result: { valid: boolean; message?: string }) => {
|
||||||
if (result.valid) {
|
if (result.valid) {
|
||||||
setTestResult({ type: "success", text: "Configuration test passed." });
|
setTestResult({ type: "success", text: "Configuration test passed." });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1043,13 +1051,33 @@ function PluginConfigForm({ pluginId, companyId, schema, initialValues, isLoadin
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<JsonSchemaForm
|
{companyFields.length > 0 ? (
|
||||||
schema={schema}
|
<div className="space-y-4">
|
||||||
values={values}
|
{companyFields.map(({ key, schema: fieldSchema }) => (
|
||||||
onChange={handleChange}
|
<CompanyConfigField
|
||||||
errors={errors}
|
key={key}
|
||||||
disabled={saveMutation.isPending}
|
fieldKey={key}
|
||||||
/>
|
schema={fieldSchema}
|
||||||
|
value={values[key]}
|
||||||
|
companies={companies}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
error={errors[`/${key}`]}
|
||||||
|
required={(schema.required ?? []).includes(key)}
|
||||||
|
onChange={(nextValue) => handleChange({ ...values, [key]: nextValue })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{formSchema.properties && Object.keys(formSchema.properties).length > 0 ? (
|
||||||
|
<JsonSchemaForm
|
||||||
|
schema={formSchema}
|
||||||
|
values={values}
|
||||||
|
onChange={handleChange}
|
||||||
|
errors={errors}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Status messages */}
|
{/* Status messages */}
|
||||||
{saveMessage && (
|
{saveMessage && (
|
||||||
|
|
@ -1114,6 +1142,135 @@ function PluginConfigForm({ pluginId, companyId, schema, initialValues, isLoadin
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CompanyOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CompanyConfigFieldDescriptor = {
|
||||||
|
key: string;
|
||||||
|
schema: JsonSchemaNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function companiesForSelector(companies: Array<{ id: string; name: string }>): CompanyOption[] {
|
||||||
|
return companies.map((company) => ({ id: company.id, name: company.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCompanyFieldToken(value: string | undefined): string {
|
||||||
|
return (value ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompanyConfigField(key: string, schema: JsonSchemaNode): boolean {
|
||||||
|
const explicitResource = schema["x-paperclip-resource"];
|
||||||
|
if (explicitResource === "company") return true;
|
||||||
|
if (resolveJsonSchemaType(schema) !== "string") return false;
|
||||||
|
const normalizedKey = normalizeCompanyFieldToken(key);
|
||||||
|
const normalizedTitle = normalizeCompanyFieldToken(schema.title);
|
||||||
|
return normalizedKey.endsWith("companyid") || normalizedTitle.endsWith("companyid");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompanyConfigFields(schema: JsonSchemaNode): CompanyConfigFieldDescriptor[] {
|
||||||
|
return Object.entries(schema.properties ?? {})
|
||||||
|
.filter(([key, propSchema]) => isCompanyConfigField(key, propSchema))
|
||||||
|
.map(([key, propSchema]) => ({ key, schema: propSchema }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompanyFieldDefaults(
|
||||||
|
companyFields: CompanyConfigFieldDescriptor[],
|
||||||
|
initialValues: Record<string, unknown> | undefined,
|
||||||
|
activeCompanyId: string | null | undefined,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!activeCompanyId) return {};
|
||||||
|
const defaults: Record<string, unknown> = {};
|
||||||
|
for (const field of companyFields) {
|
||||||
|
const existingValue = initialValues?.[field.key];
|
||||||
|
if (typeof existingValue === "string" && existingValue.trim().length > 0) continue;
|
||||||
|
defaults[field.key] = activeCompanyId;
|
||||||
|
}
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
function omitSchemaProperties(schema: JsonSchemaNode, keysToOmit: string[]): JsonSchemaNode {
|
||||||
|
if (keysToOmit.length === 0 || !schema.properties) return schema;
|
||||||
|
|
||||||
|
const keySet = new Set(keysToOmit);
|
||||||
|
const nextProperties = Object.fromEntries(
|
||||||
|
Object.entries(schema.properties).filter(([key]) => !keySet.has(key)),
|
||||||
|
);
|
||||||
|
const nextRequired = (schema.required ?? []).filter((key) => !keySet.has(key));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
properties: nextProperties,
|
||||||
|
...(schema.required ? { required: nextRequired } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveJsonSchemaType(schema: JsonSchemaNode): string {
|
||||||
|
if (Array.isArray(schema.type)) {
|
||||||
|
return schema.type.find((value) => value !== "null") ?? "string";
|
||||||
|
}
|
||||||
|
return schema.type ?? "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyConfigFieldProps {
|
||||||
|
fieldKey: string;
|
||||||
|
schema: JsonSchemaNode;
|
||||||
|
value: unknown;
|
||||||
|
companies: CompanyOption[];
|
||||||
|
disabled: boolean;
|
||||||
|
error?: string;
|
||||||
|
required: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyConfigField({
|
||||||
|
fieldKey,
|
||||||
|
schema,
|
||||||
|
value,
|
||||||
|
companies,
|
||||||
|
disabled,
|
||||||
|
error,
|
||||||
|
required,
|
||||||
|
onChange,
|
||||||
|
}: CompanyConfigFieldProps) {
|
||||||
|
const fieldValue = typeof value === "string" ? value : "";
|
||||||
|
const label = schema.title ?? fieldKey.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ");
|
||||||
|
const hasKnownValue = companies.some((company) => company.id === fieldValue);
|
||||||
|
const placeholder = companies.length === 0 ? "No companies available" : "Select a company";
|
||||||
|
const selectValue = fieldValue || undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
{label}
|
||||||
|
{required ? <span className="ml-1 text-destructive">*</span> : null}
|
||||||
|
</Label>
|
||||||
|
<Select value={selectValue} onValueChange={onChange} disabled={disabled || companies.length === 0}>
|
||||||
|
<SelectTrigger className="w-full" aria-label={label}>
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{!hasKnownValue && fieldValue ? (
|
||||||
|
<SelectItem value={fieldValue}>
|
||||||
|
{fieldValue}
|
||||||
|
</SelectItem>
|
||||||
|
) : null}
|
||||||
|
{companies.map((company) => (
|
||||||
|
<SelectItem key={company.id} value={company.id}>
|
||||||
|
{company.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{schema.description ? (
|
||||||
|
<p className="text-[12px] text-muted-foreground leading-relaxed">{schema.description}</p>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="text-[12px] font-medium text-destructive">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Dashboard helper components and formatting utilities
|
// Dashboard helper components and formatting utilities
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue