fix(watchsync): support declarative setup fallbacks

This commit is contained in:
Quick104
2026-08-06 13:12:39 -04:00
parent d4edf0068e
commit 6f445c1cda
8 changed files with 164 additions and 94 deletions
+12
View File
@@ -89,6 +89,9 @@ func NewPluginProvider(options PluginProviderOptions) (*PluginProvider, error) {
if err := validateWatchSyncConnectionConfigSchemas(options.ConnectionConfigSchema); err != nil {
return nil, fmt.Errorf("watch sync plugin %q %w", options.ProviderKey, err)
}
if authMethod != AuthMethodAPIKey && hasWatchSyncConnectionConfigSchema(options.ConnectionConfigSchema) {
return nil, fmt.Errorf("watch sync plugin %q connection config requires API-key authentication", options.ProviderKey)
}
if options.ResolveClient == nil {
return nil, fmt.Errorf("watch sync plugin client resolver is required")
}
@@ -107,6 +110,15 @@ func NewPluginProvider(options PluginProviderOptions) (*PluginProvider, error) {
}, nil
}
func hasWatchSyncConnectionConfigSchema(schemas []*pluginv1.ConfigSchema) bool {
for _, schema := range schemas {
if schema != nil {
return true
}
}
return false
}
func (p *PluginProvider) Key() string { return p.providerKey }
func (p *PluginProvider) DisplayName() string {
@@ -348,6 +348,22 @@ func TestPluginProviderRejectsUnresolvableDynamicConnectionOptions(t *testing.T)
}
}
func TestPluginProviderRejectsConnectionConfigForDeviceAuthorization(t *testing.T) {
_, err := NewPluginProvider(PluginProviderOptions{
InstallationID: 4, ProviderKey: testPluginProviderKey, CapabilityID: testPluginCapabilityID,
Descriptor: &pluginv1.WatchSyncProviderDescriptor{AuthMethods: []pluginv1.WatchSyncAuthMethod{
pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_DEVICE_CODE,
}},
ConnectionConfigSchema: []*pluginv1.ConfigSchema{{Key: "server"}},
ResolveClient: func(context.Context, int, string) (WatchSyncPluginClient, error) {
return &fakeWatchSyncPluginClient{}, nil
},
})
if err == nil || !strings.Contains(err.Error(), "connection config requires API-key authentication") {
t.Fatalf("error = %v", err)
}
}
func TestPluginProviderRefreshReturnsCredentialsAlongsideFault(t *testing.T) {
client := &fakeWatchSyncPluginClient{refreshResponse: &pluginv1.WatchSyncCredentialResponse{
Credentials: &pluginv1.WatchSyncCredentials{AccessToken: testRotatedAccessToken, TokenType: testBearerTokenType},
@@ -36,6 +36,30 @@ const schema: PluginConfigSchema = {
};
describe("PluginConfigForm secrets", () => {
it("derives a form when a plugin only supplies JSON Schema", () => {
render(
<PluginConfigForm
schema={{
key: "server",
title: "Server",
json_schema: JSON.stringify({
type: "object",
properties: {
base_url: { type: "string", title: "Base URL" },
api_key: { type: "string", format: "password" },
},
required: ["base_url", "api_key"],
}),
required: true,
}}
onSave={vi.fn()}
/>,
);
expect(screen.getByLabelText("Base URL")).toBeInTheDocument();
expect(screen.getByLabelText("Api Key")).toHaveAttribute("type", "password");
});
it("shows redacted saved state and only clears through an explicit action", async () => {
const onSave = vi.fn();
render(
@@ -10,11 +10,14 @@ import { ConnectionCheckAction } from "@/components/admin/ConnectionCheckAction"
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { adminFormForConfigSchema, humanizeConfigKey } from "./configSchemaAdminForm";
import { SchemaForm } from "./SchemaForm";
import { buildSchemaValues } from "./schemaFormUtils";
type PluginConfigValue = Record<string, unknown>;
const EMPTY_FIELDS: PluginAdminFormField[] = [];
type Props = {
schema: PluginConfigSchema;
value?: PluginConfigValue;
@@ -29,82 +32,7 @@ type Props = {
isTesting?: boolean;
};
type SupportedField = PluginAdminFormField & {
inferredType?: "string" | "number" | "integer" | "boolean";
};
type ParsedObjectSchema = {
supported: boolean;
fields: SupportedField[];
};
function humanizeKey(value: string) {
return value
.split("_")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function parseJSONSchema(schema: PluginConfigSchema): ParsedObjectSchema {
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 { supported: false, fields: [] };
}
const fields = Object.entries(parsed.properties).map(([key, property]) => {
const propertyType = property.type;
if (!propertyType || !["string", "number", "integer", "boolean"].includes(propertyType)) {
return null;
}
const isSensitive = property.writeOnly === true || property.format === "password";
const control =
propertyType === "boolean"
? "SWITCH"
: propertyType === "number" || propertyType === "integer"
? "NUMBER"
: isSensitive
? "PASSWORD"
: "TEXT";
return {
key,
label: property.title || humanizeKey(key),
description: property.description,
control,
placeholder: "",
required: parsed.required?.includes(key) ?? false,
secret: isSensitive,
multiline: false,
options: [],
rows: 0,
inferredType: propertyType as "string" | "number" | "integer" | "boolean",
} satisfies SupportedField;
});
if (fields.some((field) => field == null)) {
return { supported: false, fields: [] };
}
return { supported: true, fields: fields.filter(Boolean) as SupportedField[] };
} catch {
return { supported: false, fields: [] };
}
}
function defaultValueForField(field: SupportedField): string | boolean {
function defaultValueForField(field: PluginAdminFormField): string | boolean {
if (field.default_value !== undefined) {
if (typeof field.default_value === "boolean") {
return field.default_value;
@@ -122,7 +50,10 @@ function defaultValueForField(field: SupportedField): string | boolean {
return "";
}
function valueForField(field: SupportedField, configValue?: PluginConfigValue): string | boolean {
function valueForField(
field: PluginAdminFormField,
configValue?: PluginConfigValue,
): string | boolean {
const raw = configValue?.[field.key];
if (typeof raw === "boolean") {
return raw;
@@ -145,19 +76,12 @@ export function PluginConfigForm({
isSaving = false,
isTesting = false,
}: Props) {
const parsedFallback = useMemo(() => parseJSONSchema(schema), [schema]);
const fields = useMemo<SupportedField[]>(() => {
if (schema.admin_form?.fields?.length) {
return schema.admin_form.fields;
}
return parsedFallback.fields;
}, [parsedFallback.fields, schema.admin_form?.fields]);
const supported =
fields.length > 0 && (schema.admin_form?.fields?.length ? true : parsedFallback.supported);
const inferredDescriptor = useMemo(() => adminFormForConfigSchema(schema), [schema]);
const fields = inferredDescriptor?.fields ?? EMPTY_FIELDS;
const supported = inferredDescriptor != null;
const descriptor = useMemo<PluginAdminForm>(() => {
const base = schema.admin_form ?? { fields };
const base = inferredDescriptor ?? { fields };
const configured = new Set(configuredSecrets);
return {
...base,
@@ -167,7 +91,7 @@ export function PluginConfigForm({
: field,
),
};
}, [configuredSecrets, fields, schema.admin_form]);
}, [configuredSecrets, fields, inferredDescriptor]);
const [values, setValues] = useState<PluginConfigValue>(() =>
Object.fromEntries(fields.map((field) => [field.key, valueForField(field, value)])),
@@ -248,7 +172,7 @@ export function PluginConfigForm({
return (
<div key={key} className="flex items-center justify-between gap-3 text-xs">
<span className={clearing ? "text-destructive" : "text-muted-foreground"}>
{field?.label || humanizeKey(key)}: {clearing ? "will be cleared" : "saved"}
{field?.label || humanizeConfigKey(key)}: {clearing ? "will be cleared" : "saved"}
{required ? " (required)" : ""}
</span>
{!required ? (
@@ -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;
}
}
@@ -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<WatchProviderConnectionConfig>({});
const [configValidity, setConfigValidity] = useState<Record<string, boolean>>({});
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,
@@ -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);
});
});
@@ -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<PluginConfigSchema["admin_form"]>;
};
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;