diff --git a/apps/web/src/components/settings/MainSettingsForm.tsx b/apps/web/src/components/settings/MainSettingsForm.tsx new file mode 100644 index 00000000000..b141e370a03 --- /dev/null +++ b/apps/web/src/components/settings/MainSettingsForm.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useMemo, type ReactNode } from "react"; +import { Equal, Schema } from "effect"; +import { ClientSettingsSchema, ServerSettings } from "@t3tools/contracts/settings"; +import { + schemaFormOptionLabels, + type SchemaFormControl as SchemaFormControlKind, +} from "@t3tools/contracts/schemaForm"; + +import { SchemaFormFieldControl } from "./SchemaFormControl"; +import { SettingResetButton, SettingsRow } from "./settingsLayout"; +import { + deriveSchemaFormFields, + type SchemaFormFieldModel, + type SchemaFormSchema, +} from "./schemaForm"; + +const ThemePreference = Schema.Literals(["system", "light", "dark"]); + +type MainSettingsFormControl = Extract< + SchemaFormControlKind, + "select" | "switch" | "text" | "textGenerationModelSelection" +>; + +export type MainSettingsFieldModel = SchemaFormFieldModel; + +export const ThemeSettingsSchema = Schema.Struct({ + theme: ThemePreference.pipe( + Schema.annotateKey({ + title: "Theme", + description: "Choose how T3 Code looks across the app.", + schemaForm: { + order: 0, + resetLabel: "theme", + ariaLabel: "Theme preference", + optionLabels: schemaFormOptionLabels(ThemePreference, { + system: "System", + light: "Light", + dark: "Dark", + }), + }, + }), + ), +}); + +export const MAIN_SETTINGS_FORM_SCHEMAS: readonly SchemaFormSchema[] = [ + ThemeSettingsSchema, + ClientSettingsSchema, + ServerSettings, +]; + +export function deriveMainSettingsFields( + schemas: readonly SchemaFormSchema[] = MAIN_SETTINGS_FORM_SCHEMAS, +): ReadonlyArray { + return deriveSchemaFormFields({ + schemas, + allowedControls: ["select", "switch", "text", "textGenerationModelSelection"], + includeUnannotatedFields: false, + sortByFormOrder: true, + }); +} + +interface MainSettingsFormProps { + readonly values: Readonly>; + readonly defaultValues: Readonly>; + readonly customControls?: Readonly> | undefined; + readonly onChange: (key: string, value: unknown) => void; +} + +export function MainSettingsForm({ + values, + defaultValues, + customControls, + onChange, +}: MainSettingsFormProps) { + const fields = useMemo(() => deriveMainSettingsFields(), []); + + return ( + <> + {fields.map((field) => { + const value = values[field.key]; + const defaultValue = defaultValues[field.key]; + const isDirty = !Equal.equals(value, defaultValue); + return ( + onChange(field.key, defaultValue)} + /> + ) : null + } + control={ + onChange(field.key, next)} + /> + } + /> + ); + })} + + ); +} diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 244fa9f31ca..1feea6f75b5 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -1,122 +1,50 @@ "use client"; import { useMemo, type ReactNode } from "react"; -import { Schema } from "effect"; -import type { - ProviderSettingsFormAnnotation, - ProviderSettingsFormControl, - ProviderSettingsFormSchemaAnnotation, -} from "@t3tools/contracts"; +import type { SchemaFormControl as SchemaFormControlKind } from "@t3tools/contracts/schemaForm"; import { cn } from "../../lib/utils"; -import { DraftInput } from "../ui/draft-input"; -import { Input } from "../ui/input"; -import { Switch } from "../ui/switch"; -import { Textarea } from "../ui/textarea"; import type { ProviderClientDefinition } from "./providerDriverMeta"; - -export interface ProviderSettingsFieldModel { +import { + getSchemaFormFieldLayout, + readSchemaFormBoolean, + readSchemaFormFieldValue, + readSchemaFormString, + SchemaFormFieldControl, +} from "./SchemaFormControl"; +import { deriveSchemaFormFields, type SchemaFormFieldModel } from "./schemaForm"; + +type ProviderSettingsFormControl = Extract< + SchemaFormControlKind, + "text" | "password" | "textarea" | "switch" +>; + +export type ProviderSettingsFieldModel = SchemaFormFieldModel; + +interface ProviderSettingsValueField { readonly key: string; readonly control: ProviderSettingsFormControl; - readonly label: string; - readonly description?: string | undefined; - readonly placeholder?: string | undefined; + readonly label?: string | undefined; readonly clearWhenEmpty: "omit" | "persist"; readonly defaultBooleanValue?: boolean | undefined; } -function titleizeFieldKey(key: string): string { - return key - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/[-_]+/g, " ") - .replace(/^./, (char) => char.toUpperCase()); -} - -function readFieldAnnotations( - fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string], -) { - return Schema.resolveAnnotationsKey(fieldSchema) ?? Schema.resolveAnnotations(fieldSchema); -} - -function readFieldAnnotationString( - fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string], - key: "title" | "description", -): string | undefined { - const annotations = readFieldAnnotations(fieldSchema); - const value = annotations?.[key]; - return typeof value === "string" ? value : undefined; -} - -function readProviderSettingsFormAnnotation( - fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string], -): ProviderSettingsFormAnnotation { - const annotation = readFieldAnnotations(fieldSchema)?.providerSettingsForm; - return annotation ?? {}; -} - -function readProviderSettingsFormSchemaAnnotation( - definition: ProviderClientDefinition, -): ProviderSettingsFormSchemaAnnotation { - return Schema.resolveAnnotations(definition.settingsSchema)?.providerSettingsFormSchema ?? {}; -} - -function readFieldBooleanDefault( - fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string], -): boolean | undefined { - try { - const decoded = Schema.decodeUnknownSync(fieldSchema as Schema.Decoder)(undefined); - return typeof decoded === "boolean" ? decoded : undefined; - } catch { - return undefined; - } -} - export function deriveProviderSettingsFields( definition: ProviderClientDefinition, ): ReadonlyArray { - const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition); - const orderedKeys = new Map( - (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const), - ); - const orderFallbackOffset = orderedKeys.size; - - return Object.keys(definition.settingsSchema.fields) - .map((key, index) => ({ key, index })) - .toSorted((left, right) => { - return ( - (orderedKeys.get(left.key) ?? orderFallbackOffset + left.index) - - (orderedKeys.get(right.key) ?? orderFallbackOffset + right.index) - ); - }) - .flatMap(({ key }) => { - const fieldSchema = definition.settingsSchema.fields[key]!; - const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema); - if (formAnnotation.hidden) return []; - - const annotatedTitle = readFieldAnnotationString(fieldSchema, "title"); - const annotatedDescription = readFieldAnnotationString(fieldSchema, "description"); - return [ - { - key, - control: formAnnotation.control ?? "text", - label: annotatedTitle ?? titleizeFieldKey(key), - ...(annotatedDescription !== undefined ? { description: annotatedDescription } : {}), - ...(formAnnotation.placeholder !== undefined - ? { placeholder: formAnnotation.placeholder } - : {}), - clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", - ...(formAnnotation.control === "switch" - ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } - : {}), - } satisfies ProviderSettingsFieldModel, - ]; - }); + return deriveSchemaFormFields({ + schemas: [definition.settingsSchema], + allowedControls: ["text", "password", "textarea", "switch"], + includeUnannotatedFields: true, + defaultControl: ({ inferredControl }) => { + return inferredControl?.control === "switch" ? "switch" : "text"; + }, + }); } export function readProviderConfigString(config: unknown, key: string): string { if (config === null || typeof config !== "object") return ""; - const value = (config as Record)[key]; - return typeof value === "string" ? value : ""; + return readSchemaFormString((config as Record)[key]); } export function readProviderConfigBoolean( @@ -125,13 +53,24 @@ export function readProviderConfigBoolean( defaultValue = false, ): boolean { if (config === null || typeof config !== "object") return defaultValue; - const value = (config as Record)[key]; - return typeof value === "boolean" ? value : defaultValue; + return readSchemaFormBoolean((config as Record)[key], defaultValue); +} + +function readProviderConfigFieldValue(config: unknown, field: ProviderSettingsValueField) { + if (config === null || typeof config !== "object") { + return readSchemaFormFieldValue(field, undefined, field.defaultBooleanValue); + } + + return readSchemaFormFieldValue( + field, + (config as Record)[field.key], + field.defaultBooleanValue, + ); } export function nextProviderConfigWithFieldValue( config: unknown, - field: ProviderSettingsFieldModel, + field: ProviderSettingsValueField, value: string | boolean, ): Record | undefined { const base: Record = @@ -198,8 +137,19 @@ function ProviderSettingsFieldRow({ const description = field.description ? ( {field.description} ) : null; + const control = ( + onChange(nextProviderConfigWithFieldValue(value, field, next))} + /> + ); - if (field.control === "switch") { + if (getSchemaFormFieldLayout(field) === "inline") { return (
@@ -207,69 +157,17 @@ function ProviderSettingsFieldRow({ {label} {description}
- - onChange(nextProviderConfigWithFieldValue(value, field, Boolean(checked))) - } - aria-label={field.label} - /> + {control}
); } - if (field.control === "textarea") { - return ( - -