Skip to content

Commit 6cdf75e

Browse files
committed
refactor(admin): share one schema-driven config form across integrations + services
The integration and service "Config" tabs were two parallel ~form implementations of the same idea (render configSchema fields, filter secrets, source badges, env-disable, diff changed keys). That duplication is why the secret-filter fix had to be applied twice — and was missed on the service side. - New shared `admin/shared/SchemaConfigForm.tsx` (+ `extractConfigFields`): the single field model + secret filter + source badges + env-override disable + setup-guide + changed-key diff + inline save state. Supports `excludeKeys` and an optional second "apply" action. - `ConfigSchemaForm` (integration) → thin wrapper: keeps its PATCH+reload mutation, excludes `enabled` (dedicated toggle owns it). - `ServiceConfigForm` (service) → thin wrapper: keeps env-prefix hint + "Save & apply (recreate)". - Secret fields are filtered in exactly one place now, so the two tabs can't drift again. Test moved to shared/__tests__/SchemaConfigForm.test.ts (covers secret filter + excludeKeys). Also: ServiceDetail tab label "Config" -> "Configuration" to match the integration tab.
1 parent ca66290 commit 6cdf75e

6 files changed

Lines changed: 432 additions & 649 deletions

File tree

Lines changed: 21 additions & 276 deletions
Original file line numberDiff line numberDiff line change
@@ -1,141 +1,34 @@
11
"use client";
22

3-
import SaveIcon from "@mui/icons-material/Save";
4-
import Alert from "@mui/material/Alert";
5-
import Box from "@mui/material/Box";
6-
import Button from "@mui/material/Button";
7-
import Chip from "@mui/material/Chip";
8-
import CircularProgress from "@mui/material/CircularProgress";
9-
import FormControlLabel from "@mui/material/FormControlLabel";
10-
import MenuItem from "@mui/material/MenuItem";
11-
import Select from "@mui/material/Select";
12-
import Stack from "@mui/material/Stack";
13-
import Switch from "@mui/material/Switch";
14-
import TextField from "@mui/material/TextField";
15-
import Tooltip from "@mui/material/Tooltip";
16-
import Typography from "@mui/material/Typography";
17-
import type { CredentialSetup } from "@openmapx/integration-framework";
18-
import { readCredentialSetup } from "@openmapx/integration-framework";
193
import { useMutation, useQueryClient } from "@tanstack/react-query";
20-
import { useCallback, useEffect, useMemo, useState } from "react";
214
import { useEnv } from "@/lib/EnvProvider";
22-
import { useAdminToast } from "../shared/AdminToast";
23-
import { CredentialSetupGuide } from "./CredentialSetupGuide";
24-
25-
type ConfigSource = "default" | "database" | "vault" | "config.json" | "env";
26-
27-
const SOURCE_COLOR: Record<ConfigSource, "default" | "primary" | "secondary" | "success" | "info"> =
28-
{
29-
default: "default",
30-
database: "primary",
31-
vault: "info",
32-
"config.json": "secondary",
33-
env: "success",
34-
};
35-
36-
interface SchemaProperty {
37-
type?: string;
38-
title?: string;
39-
description?: string;
40-
default?: unknown;
41-
enum?: string[];
42-
format?: string;
43-
"x-openmapx-secret"?: boolean;
44-
"x-openmapx-setup"?: CredentialSetup;
45-
}
5+
import { type ConfigFieldSource, SchemaConfigForm } from "../shared/SchemaConfigForm";
466

477
interface ConfigSchemaFormProps {
488
integrationId: string;
49-
/** The manifest configSchema object */
9+
/** The manifest configSchema object. */
5010
schema: Record<string, unknown> | undefined;
51-
/** Resolved config values with source annotations */
52-
resolvedConfig: Record<string, { value: unknown; source: ConfigSource }>;
53-
/** Called after a successful save + reload */
11+
/** Resolved config values with source annotations. */
12+
resolvedConfig: Record<string, { value: unknown; source: ConfigFieldSource }>;
13+
/** Called after a successful save + reload. */
5414
onSaved?: () => void;
5515
}
5616

57-
function humanize(key: string): string {
58-
return key
59-
.replace(/([A-Z])/g, " $1")
60-
.replace(/[_-]/g, " ")
61-
.replace(/^./, (s) => s.toUpperCase())
62-
.trim();
63-
}
64-
17+
/**
18+
* Integration config tab — a thin wrapper over the shared {@link SchemaConfigForm}.
19+
* Integration-specific bits: the save is a `PATCH …/config` that reloads the
20+
* integration, and the `enabled` flag is owned by a dedicated toggle elsewhere
21+
* so it's excluded here. Secrets are never shown — they live on the Credentials
22+
* tab.
23+
*/
6524
export function ConfigSchemaForm({
6625
integrationId,
6726
schema,
6827
resolvedConfig,
6928
onSaved,
7029
}: ConfigSchemaFormProps) {
71-
const env = useEnv();
72-
const apiUrl = env.apiUrl;
30+
const { apiUrl } = useEnv();
7331
const qc = useQueryClient();
74-
const showToast = useAdminToast();
75-
76-
// Extract editable fields (non-secret, non-enabled)
77-
const fields = useMemo(() => {
78-
if (!schema) return [];
79-
const props = (schema.properties ?? schema) as Record<string, SchemaProperty>;
80-
return Object.entries(props)
81-
.filter(([key, def]) => {
82-
if (key === "type" || key === "properties") return false;
83-
if (key === "enabled") return false; // handled by dedicated toggle
84-
if (def?.["x-openmapx-secret"]) return false;
85-
return true;
86-
})
87-
.map(([key, def]) => ({
88-
key,
89-
title: def?.title ?? humanize(key),
90-
description: def?.description,
91-
type: def?.type ?? "string",
92-
enum: def?.enum,
93-
format: def?.format,
94-
default: def?.default,
95-
setup: readCredentialSetup(def),
96-
}));
97-
}, [schema]);
98-
99-
// Compute initial form values from resolvedConfig (env-overridden fields excluded)
100-
const computeInitialValues = useCallback(() => {
101-
const initial: Record<string, unknown> = {};
102-
for (const field of fields) {
103-
const entry = resolvedConfig[field.key];
104-
if (entry?.source !== "env") {
105-
initial[field.key] = entry?.value ?? field.default ?? "";
106-
}
107-
}
108-
return initial;
109-
}, [fields, resolvedConfig]);
110-
111-
const [values, setValues] = useState<Record<string, unknown>>(computeInitialValues);
112-
113-
// Re-sync form state when resolvedConfig changes (e.g. after save + refetch)
114-
useEffect(() => {
115-
setValues(computeInitialValues());
116-
}, [computeInitialValues]);
117-
118-
// Track original DB values to compute diff
119-
const originalValues = useMemo(() => {
120-
const orig: Record<string, unknown> = {};
121-
for (const field of fields) {
122-
const entry = resolvedConfig[field.key];
123-
if (entry?.source !== "env") {
124-
orig[field.key] = entry?.value ?? field.default ?? "";
125-
}
126-
}
127-
return orig;
128-
}, [fields, resolvedConfig]);
129-
130-
const changedKeys = useMemo(
131-
() =>
132-
Object.keys(values).filter((k) => {
133-
const orig = originalValues[k];
134-
const cur = values[k];
135-
return String(cur) !== String(orig ?? "");
136-
}),
137-
[values, originalValues],
138-
);
13932

14033
const saveMutation = useMutation({
14134
mutationFn: async (updates: Record<string, unknown>) => {
@@ -148,7 +41,7 @@ export function ConfigSchemaForm({
14841
if (!res.ok) {
14942
const err = await res.json().catch(() => ({}));
15043
throw new Error(
151-
(err as { error?: string; errors?: string[] })?.errors?.join(", ") ??
44+
(err as { errors?: string[] })?.errors?.join(", ") ??
15245
(err as { error?: string })?.error ??
15346
"Save failed",
15447
);
@@ -157,165 +50,17 @@ export function ConfigSchemaForm({
15750
},
15851
onSuccess: () => {
15952
qc.invalidateQueries({ queryKey: ["admin", "integrations", integrationId] });
160-
showToast("Configuration saved");
16153
onSaved?.();
16254
},
163-
onError: (err) => showToast(err instanceof Error ? err.message : "Save failed", "error"),
16455
});
16556

166-
function handleSave() {
167-
const diff: Record<string, unknown> = {};
168-
for (const key of changedKeys) {
169-
diff[key] = values[key];
170-
}
171-
saveMutation.mutate(diff);
172-
}
173-
174-
if (fields.length === 0) {
175-
return (
176-
<Alert severity="info" variant="outlined">
177-
No editable configuration fields. Only secrets and the enabled toggle are available for this
178-
integration.
179-
</Alert>
180-
);
181-
}
182-
18357
return (
184-
<Stack
185-
sx={{
186-
gap: 2.5,
187-
}}
188-
>
189-
{fields.map((field) => {
190-
const entry = resolvedConfig[field.key];
191-
const source: ConfigSource = entry?.source ?? "default";
192-
const isEnvOverridden = source === "env";
193-
194-
return (
195-
<Stack
196-
key={field.key}
197-
sx={{
198-
gap: 0.5,
199-
}}
200-
>
201-
<Stack
202-
direction="row"
203-
sx={{
204-
alignItems: "center",
205-
gap: 1,
206-
flexWrap: "wrap",
207-
}}
208-
>
209-
<Typography
210-
variant="body2"
211-
sx={{
212-
fontWeight: 600,
213-
}}
214-
>
215-
{field.title}
216-
</Typography>
217-
<Tooltip title={`Currently sourced from: ${source}`}>
218-
<Chip
219-
label={source}
220-
size="small"
221-
color={SOURCE_COLOR[source] ?? "default"}
222-
variant="outlined"
223-
sx={{ fontFamily: "monospace", fontSize: "0.7rem" }}
224-
/>
225-
</Tooltip>
226-
{isEnvOverridden && (
227-
<Chip
228-
label="env override"
229-
size="small"
230-
color="success"
231-
sx={{ fontSize: "0.7rem" }}
232-
/>
233-
)}
234-
{entry?.value === field.default && source === "default" && (
235-
<Typography
236-
variant="caption"
237-
sx={{
238-
color: "text.disabled",
239-
}}
240-
>
241-
(default)
242-
</Typography>
243-
)}
244-
</Stack>
245-
{field.description && (
246-
<Typography
247-
variant="caption"
248-
sx={{
249-
color: "text.secondary",
250-
}}
251-
>
252-
{field.description}
253-
</Typography>
254-
)}
255-
{field.setup && <CredentialSetupGuide setup={field.setup} />}
256-
{field.type === "boolean" ? (
257-
<FormControlLabel
258-
control={
259-
<Switch
260-
size="small"
261-
checked={Boolean(values[field.key])}
262-
onChange={(e) =>
263-
setValues((prev) => ({ ...prev, [field.key]: e.target.checked }))
264-
}
265-
disabled={isEnvOverridden || saveMutation.isPending}
266-
/>
267-
}
268-
label={
269-
<Typography variant="body2" color={isEnvOverridden ? "text.disabled" : undefined}>
270-
{String(values[field.key] ?? false)}
271-
</Typography>
272-
}
273-
/>
274-
) : field.enum ? (
275-
<Select
276-
size="small"
277-
value={String(values[field.key] ?? "")}
278-
onChange={(e) => setValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
279-
disabled={isEnvOverridden || saveMutation.isPending}
280-
sx={{ maxWidth: 320 }}
281-
>
282-
{field.enum.map((opt) => (
283-
<MenuItem key={opt} value={opt}>
284-
{opt}
285-
</MenuItem>
286-
))}
287-
</Select>
288-
) : (
289-
<TextField
290-
size="small"
291-
value={String(values[field.key] ?? "")}
292-
onChange={(e) => setValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
293-
disabled={isEnvOverridden || saveMutation.isPending}
294-
type={
295-
field.type === "number" || field.type === "integer"
296-
? "number"
297-
: field.format === "url"
298-
? "url"
299-
: "text"
300-
}
301-
placeholder={field.default !== undefined ? String(field.default) : undefined}
302-
sx={{ maxWidth: 480 }}
303-
/>
304-
)}
305-
</Stack>
306-
);
307-
})}
308-
<Box>
309-
<Button
310-
variant="contained"
311-
size="small"
312-
startIcon={saveMutation.isPending ? <CircularProgress size={14} /> : <SaveIcon />}
313-
onClick={handleSave}
314-
disabled={saveMutation.isPending || changedKeys.length === 0}
315-
>
316-
Save{changedKeys.length > 0 ? ` (${changedKeys.length} changed)` : ""}
317-
</Button>
318-
</Box>
319-
</Stack>
58+
<SchemaConfigForm
59+
schema={schema}
60+
resolvedConfig={resolvedConfig}
61+
excludeKeys={["enabled"]}
62+
onSave={(diff) => saveMutation.mutateAsync(diff)}
63+
emptyMessage="No editable configuration fields. Only secrets and the enabled toggle are available for this integration."
64+
/>
32065
);
32166
}

0 commit comments

Comments
 (0)