diff --git a/webui/src/components/form/TypedForm.tsx b/webui/src/components/form/TypedForm.tsx new file mode 100644 index 00000000..2e505e9c --- /dev/null +++ b/webui/src/components/form/TypedForm.tsx @@ -0,0 +1,206 @@ +import React, { useContext, useEffect, useMemo, useState } from "react"; +import { Form } from "antd"; +import { + clone, + create, + DescMessage, + Message, + MessageShape, +} from "@bufbuild/protobuf"; +import { useWatch } from "antd/es/form/Form"; +import useFormInstance from "antd/es/form/hooks/useFormInstance"; + +export type Paths = T extends object + ? { + [K in Exclude]: `${K}${Paths extends never + ? "" + : `.${Paths}`}`; + }[Exclude] + : never; + +export type DeepIndex = T extends object + ? K extends `${infer F}.${infer R}` + ? DeepIndex, R> + : Idx + : never; + +type Idx = K extends keyof T ? T[K] : never; + +const getValue = (obj: any, path: string): any => { + let curr: any = obj; + const parts = path.split("."); + for (let i = 0; i < parts.length; i++) { + if (Array.isArray(curr)) { + curr = curr[parseInt(parts[i])]; + } else { + curr = curr[parts[i]]; + } + } + return curr as any; +}; + +const setValue = (obj: any, path: string, value: any): void => { + let curr: any = obj; + const parts = path.split("."); + for (let i = 0; i < parts.length - 1; i++) { + if (Array.isArray(curr)) { + curr = curr[parseInt(parts[i])]; + } else { + curr = curr[parts[i]]; + } + } + curr[parts[parts.length - 1]] = value; +}; + +interface typedFormCtx { + formData: Message; + schema: DescMessage; + setFormData: (fn: (prev: any) => any) => void; +} + +const TypedFormCtx = React.createContext(null); +const FieldPrefixCtx = React.createContext(""); + +export const TypedForm = ({ + schema, + formData, + setFormData, + children, + ...props +}: { + schema: DescMessage; + formData: T; + setFormData: (fn: (prev: T) => T) => void; + children?: React.ReactNode; +} & { + [key: string]: any; +}) => { + const [form] = Form.useForm(); + + return ( + +
+ {children} +
+
+ ); +}; + +export const TypedFormItem = ({ + field, + children, + ...props +}: { + field: Paths; + children?: React.ReactNode; +} & { + [key: string]: any; +}): React.ReactElement => { + const prefix = useContext(FieldPrefixCtx); + const { formData, setFormData, schema } = useContext(TypedFormCtx)!; + const form = useFormInstance(); + const resolvedField = prefix + field; + + const [lastSeen, setLastSeen] = useState(null); + const formValue = useWatch(resolvedField); + const formDataStateValue = getValue(formData, resolvedField); + + useEffect(() => { + if (lastSeen !== formValue) { + setFormData((prev: any) => { + const next = clone(schema, prev) as any as T; + setValue(next, resolvedField, formValue); + return next; + }); + setLastSeen(formValue); + } else if (lastSeen !== formDataStateValue) { + form.setFieldsValue({ [resolvedField]: formDataStateValue } as any); + setLastSeen(formDataStateValue); + } + }, [lastSeen, formValue, formDataStateValue]); + + return ( + + {children} + + ); +}; + +// This is a helper component that sets a prefix for all of its children. +const WithFieldPrefix = ({ + prefix, + children, +}: { + prefix: string; + children?: React.ReactNode; +}): React.ReactNode => { + const prev = useContext(FieldPrefixCtx); + return ( + + {children} + + ); +}; + +export const TypedFormList = ({ + field, + children, + ...props +}: { + field: Paths; + children?: React.ReactNode; +} & { + [key: string]: any; +}): React.ReactElement => { + return ( + + + + ); +}; + +interface oneofCase> { + case: DeepIndex; + create: () => DeepIndex; + view: React.ReactNode; +} + +/* +export const TypedFormOneof = >({ + field, + items, +}: { + field: F; + items: oneofCase[]; +}): React.ReactNode => { + const { formData, setFormData, schema } = useContext(TypedFormCtx)!; + const c = useWatch(`${field}.case`); + useEffect(() => { + for (const item of items) { + if (item.case === c) { + setFormData((prev: any) => { + const next = clone(schema, prev as T) as T; + setValue(next, field, { + case: c, + value: item.create(), + } as any); + return next; + }); + } + } + throw new Error("case " + c + " not found"); + }, [c]); + + for (const item of items) { + if (item.case === c) { + return item.view; + } + } + return null; +}; + +*/ diff --git a/webui/src/views/AddRepoModal.tsx b/webui/src/views/AddRepoModal.tsx index 1cfb7198..c1be4b7e 100644 --- a/webui/src/views/AddRepoModal.tsx +++ b/webui/src/views/AddRepoModal.tsx @@ -15,6 +15,7 @@ import { Checkbox, Select, Space, + Alert, } from "antd"; import React, { useEffect, useState } from "react"; import { useShowModal } from "../components/ModalManager"; @@ -37,13 +38,19 @@ import { } from "../components/HooksFormList"; import { ConfirmButton } from "../components/SpinButton"; import { useConfig } from "../components/ConfigProvider"; -import Cron from "react-js-cron"; import { ScheduleDefaultsInfrequent, ScheduleFormItem, } from "../components/ScheduleFormItem"; import { isWindows } from "../state/buildcfg"; -import { create, fromJson, toJson } from "@bufbuild/protobuf"; +import { + clone, + create, + fromJson, + toJson, + toJsonString, +} from "@bufbuild/protobuf"; +import { TypedForm, TypedFormItem } from "../components/form/TypedForm"; const repoDefaults = create(RepoSchema, { prunePolicy: { @@ -73,15 +80,7 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { const alertsApi = useAlertApi()!; const [config, setConfig] = useConfig(); const [form] = Form.useForm(); - useEffect(() => { - form.setFieldsValue( - template - ? toJson(RepoSchema, template, { - alwaysEmitImplicit: true, - }) - : toJson(RepoSchema, repoDefaults, { alwaysEmitImplicit: true }) - ); - }, [template]); + const [repo, setRepo] = useState(template || repoDefaults); if (!config) { return null; @@ -206,27 +205,35 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { ]} maskClosable={false} > -

- See{" "} - - backrest getting started guide - {" "} - for repository configuration instructions or check the{" "} - - restic documentation - {" "} - for more details about repositories. -

+ + See{" "} + + backrest getting started guide + {" "} + for repository configuration instructions or check the{" "} + + restic documentation + {" "} + for more details about repositories. + + } + type="info" + showIcon + />
-
+ schema={RepoSchema} autoComplete="off" - form={form} labelCol={{ span: 4 }} wrapperCol={{ span: 18 }} disabled={confirmLoading} + formData={repo} + setFormData={setRepo} > {/* Repo.id */} { "Unique ID that identifies this repo in the backrest UI (e.g. s3-mybucket). This cannot be changed after creation." } > - + hasFeedback - name="id" + field={"id"} label="Repo Name" validateTrigger={["onChange", "onBlur"]} rules={[ @@ -245,7 +252,7 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { message: "Please input repo name", }, { - validator: async (_, value) => { + validator: async (_: any, value: any) => { if (template) return; if (config?.repos?.find((r) => r.id === value)) { throw new Error(); @@ -264,7 +271,7 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { disabled={!!template} placeholder={"repo" + ((config?.repos?.length || 0) + 1)} /> - + {/* Repo.uri */} @@ -291,9 +298,9 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { } > - + hasFeedback - name="uri" + field="uri" label="Repository URI" validateTrigger={["onChange", "onBlur"]} rules={[ @@ -304,7 +311,7 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { ]} > - + {/* Repo.password */} @@ -333,13 +340,13 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { - + hasFeedback - name="password" + field="password" validateTrigger={["onChange", "onBlur"]} > - + { type="text" onClick={() => { if (template) return; - form.setFieldsValue({ - password: cryptoRandomPassword(), + + setRepo((prev) => { + const copy = clone(RepoSchema, prev); + copy.password = cryptoRandomPassword(); + return copy; }); }} > @@ -363,7 +373,7 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { {/* Repo.env */} - { )} - + */} {/* Repo.flags */} - + {/* {(fields, { add, remove }, { errors }) => ( <> @@ -471,10 +481,10 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { )} - + */} {/* Repo.prunePolicy */} - { name={["prunePolicy", "schedule"]} defaults={ScheduleDefaultsInfrequent} /> - + */} {/* Repo.checkPolicy */} - { name={["checkPolicy", "schedule"]} defaults={ScheduleDefaultsInfrequent} /> - + */} {/* Repo.commandPrefix */} - {!isWindows && ( + {/* {!isWindows && ( { - )} + )} */} - label={ { Auto Unlock } - name="autoUnlock" + field="autoUnlock" valuePropName="checked" > - + Hooks} @@ -687,7 +697,7 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { children: (
-                          {JSON.stringify(form.getFieldsValue(), undefined, 2)}
+                          {toJsonString(RepoSchema, repo, { prettySpaces: 2 })}
                         
), @@ -696,7 +706,7 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => { /> )}
- + );