- {field?.label || humanizeKey(key)}: {clearing ? "will be cleared" : "saved"}
+ {field?.label || humanizeConfigKey(key)}: {clearing ? "will be cleared" : "saved"}
{required ? " (required)" : ""}
{!required ? (
diff --git a/web/src/components/admin/plugins/configSchemaAdminForm.ts b/web/src/components/admin/plugins/configSchemaAdminForm.ts
new file mode 100644
index 00000000..e569962b
--- /dev/null
+++ b/web/src/components/admin/plugins/configSchemaAdminForm.ts
@@ -0,0 +1,69 @@
+import type { PluginAdminForm, PluginAdminFormField, PluginConfigSchema } from "@/api/types";
+
+export function humanizeConfigKey(value: string) {
+ return value
+ .split("_")
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+export function adminFormForConfigSchema(schema: PluginConfigSchema): PluginAdminForm | null {
+ if (schema.admin_form?.fields?.length) return schema.admin_form;
+
+ try {
+ const parsed = JSON.parse(schema.json_schema) as {
+ type?: string;
+ required?: string[];
+ properties?: Record<
+ string,
+ {
+ type?: string;
+ title?: string;
+ description?: string;
+ writeOnly?: boolean;
+ format?: string;
+ }
+ >;
+ };
+ if (parsed.type !== "object" || !parsed.properties) return null;
+
+ const fields = Object.entries(parsed.properties).map(
+ ([key, property]): PluginAdminFormField | null => {
+ const propertyType = property.type;
+ if (!propertyType || !["string", "number", "integer", "boolean"].includes(propertyType)) {
+ return null;
+ }
+ const secret = property.writeOnly === true || property.format === "password";
+ const control =
+ propertyType === "boolean"
+ ? "SWITCH"
+ : propertyType === "number" || propertyType === "integer"
+ ? "NUMBER"
+ : secret
+ ? "PASSWORD"
+ : "TEXT";
+ return {
+ key,
+ label: property.title || humanizeConfigKey(key),
+ description: property.description,
+ control,
+ placeholder: "",
+ required: parsed.required?.includes(key) ?? false,
+ secret,
+ multiline: false,
+ options: [],
+ rows: 0,
+ };
+ },
+ );
+ if (fields.some((field) => field == null)) return null;
+
+ return {
+ ...schema.admin_form,
+ fields: fields.filter((field): field is PluginAdminFormField => field != null),
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/web/src/pages/settings/WatchProvidersSettings.tsx b/web/src/pages/settings/WatchProvidersSettings.tsx
index 65143c6e..828579fb 100644
--- a/web/src/pages/settings/WatchProvidersSettings.tsx
+++ b/web/src/pages/settings/WatchProvidersSettings.tsx
@@ -38,7 +38,7 @@ import type { WatchProviderConnectionConfig } from "@/hooks/queries/watchProvide
import {
buildConnectionConfig,
connectionSchemasAreValid,
- type RenderableConnectionSchema,
+ renderableConnectionSchemas,
} from "./watchProviderConnectionConfig";
function formatRelativeTime(value?: string) {
@@ -239,9 +239,7 @@ function APIKeyBlock({
const [connectionConfig, setConnectionConfig] = useState
({});
const [configValidity, setConfigValidity] = useState>({});
const trimmed = value.trim();
- const renderableSchemas = configSchemas.filter(
- (schema): schema is RenderableConnectionSchema => schema.admin_form != null,
- );
+ const renderableSchemas = renderableConnectionSchemas(configSchemas);
const configValid = connectionSchemasAreValid(
renderableSchemas,
connectionConfig,
diff --git a/web/src/pages/settings/watchProviderConnectionConfig.test.ts b/web/src/pages/settings/watchProviderConnectionConfig.test.ts
index 1161adf3..b9f0abd6 100644
--- a/web/src/pages/settings/watchProviderConnectionConfig.test.ts
+++ b/web/src/pages/settings/watchProviderConnectionConfig.test.ts
@@ -6,6 +6,7 @@ import {
activeConnectionSchemas,
buildConnectionConfig,
connectionSchemasAreValid,
+ renderableConnectionSchemas,
} from "./watchProviderConnectionConfig";
const optionalSchema: PluginConfigSchema = {
@@ -53,4 +54,20 @@ describe("watch provider connection config", () => {
expect(activeConnectionSchemas([requiredSchema], {})).toEqual([requiredSchema]);
expect(connectionSchemasAreValid([requiredSchema], {}, {})).toBe(false);
});
+
+ it("derives a usable form for a required JSON-schema-only block", () => {
+ const headless = { ...optionalSchema, required: true, admin_form: undefined };
+ const schemas = renderableConnectionSchemas([headless]);
+ expect(schemas).toHaveLength(1);
+ const renderable = schemas[0]!;
+ expect(renderable.admin_form.fields).toEqual([
+ expect.objectContaining({
+ key: "base_url",
+ label: "Base Url",
+ control: "TEXT",
+ required: true,
+ }),
+ ]);
+ expect(connectionSchemasAreValid([renderable], {}, {})).toBe(false);
+ });
});
diff --git a/web/src/pages/settings/watchProviderConnectionConfig.ts b/web/src/pages/settings/watchProviderConnectionConfig.ts
index 9ea3b4fd..b269ea5c 100644
--- a/web/src/pages/settings/watchProviderConnectionConfig.ts
+++ b/web/src/pages/settings/watchProviderConnectionConfig.ts
@@ -1,4 +1,5 @@
import type { PluginConfigSchema } from "@/api/types";
+import { adminFormForConfigSchema } from "@/components/admin/plugins/configSchemaAdminForm";
import { buildSchemaValues, parseFieldTypes } from "@/components/admin/plugins/schemaFormUtils";
import type { WatchProviderConnectionConfig } from "@/hooks/queries/watchProviders";
@@ -6,6 +7,15 @@ export type RenderableConnectionSchema = PluginConfigSchema & {
admin_form: NonNullable;
};
+export function renderableConnectionSchemas(
+ schemas: PluginConfigSchema[],
+): RenderableConnectionSchema[] {
+ return schemas.flatMap((schema) => {
+ const adminForm = adminFormForConfigSchema(schema);
+ return adminForm == null ? [] : [{ ...schema, admin_form: adminForm }];
+ });
+}
+
function hasEnteredValue(value: unknown): boolean {
if (value == null) return false;
if (typeof value === "string") return value.trim().length > 0;