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" ;
193import { useMutation , useQueryClient } from "@tanstack/react-query" ;
20- import { useCallback , useEffect , useMemo , useState } from "react" ;
214import { 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
477interface 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+ */
6524export 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