fix: form bugs in UI e.g. awkward behavior when modifying hooks

This commit is contained in:
garethgeorge
2024-02-07 20:59:09 -08:00
parent c0be33a484
commit 76cb5a0c0f
6 changed files with 233 additions and 222 deletions
+79 -69
View File
@@ -4,6 +4,20 @@ import { Button, Card, Collapse, CollapseProps, Form, FormListFieldData, Input,
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Rule } from 'antd/es/form';
export interface HookFormData {
hooks: {
conditions: string[];
}[];
}
export interface HookFields {
conditions: string[];
actionCommand?: any;
actionGotify?: any;
actionDiscord?: any;
actionWebhook?: any;
}
export const hooksListTooltipText = <>
Hooks are actions that can execute on backup lifecycle events.
@@ -34,19 +48,15 @@ export const hooksListTooltipText = <>
</ul>
</>
/**
* HooksFormList is a UI component for editing a list of hooks that can apply either at the repo level or at the plan level.
*/
export const HooksFormList = (props: { hooks: Hook[] }) => {
const [hooks, _] = useState([...props.hooks] || []);
return <Form.List name="hooks" initialValue={props.hooks || []}>
export const HooksFormList = () => {
return <Form.List name="hooks">
{(fields, { add, remove }, { errors }) => (
<>
{fields.map((field, index) => {
console.log(index, field);
const hook = hooks[index];
if (!hook) return null;
return <Card key={index} title={<>
Hook {index}
<MinusCircleOutlined
@@ -56,13 +66,12 @@ export const HooksFormList = (props: { hooks: Hook[] }) => {
/>
</>
} size="small" >
<Form.Item name={[field.name, "conditions"]} initialValue={hook.conditions}>
<Form.Item name={[field.name, "conditions"]} >
<Select
mode="multiple"
allowClear
style={{ width: '100%' }}
placeholder="Runs when..."
defaultValue={hook.conditions}
options={[
{ label: "On Finish Snapshot", value: Hook_Condition.SNAPSHOT_END },
{ label: "On Start Snapshot", value: Hook_Condition.SNAPSHOT_START },
@@ -71,7 +80,11 @@ export const HooksFormList = (props: { hooks: Hook[] }) => {
]}
/>
</Form.Item>
<HookBuilder hook={hook} field={field} />
<Form.Item shouldUpdate={(prevValues, curValues) => {
return prevValues.hooks[index] !== curValues.hooks[index];
}}>
<HookBuilder field={field} />
</Form.Item>
</Card>
})}
<Form.Item>
@@ -79,11 +92,7 @@ export const HooksFormList = (props: { hooks: Hook[] }) => {
content={<>
{hookTypes.map((hookType, index) => {
return <Button key={index} onClick={() => {
const hook = new Hook({
action: hookType.action
});
hooks.push(hook);
add(hook);
add(structuredClone(hookType.template));
}}>
{hookType.name}
</Button>
@@ -105,78 +114,79 @@ export const HooksFormList = (props: { hooks: Hook[] }) => {
const hookTypes: {
name: string,
action: typeof Hook.prototype.action,
template: HookFields,
}[] = [
{
name: "Command", action: {
case: "actionCommand",
value: new Hook_Command({
name: "Command", template: {
actionCommand: {
command: "echo {{ .ShellEscape .Summary }}",
}),
}
},
conditions: [],
},
},
{
name: "Discord", action: {
case: "actionDiscord",
value: new Hook_Discord({
name: "Discord", template: {
actionDiscord: {
webhookUrl: "",
template: "{{ .Summary }}",
}),
},
conditions: [],
}
},
{
name: "Gotify", action: {
case: "actionGotify",
value: new Hook_Gotify({
name: "Gotify", template: {
actionGotify: {
baseUrl: "",
token: "",
template: "{{ .Summary }}",
titleTemplate: "Backrest {{ .EventName .Event }} in plan {{ .Plan.Id }}",
}),
},
conditions: [],
}
}
];
const HookBuilder = ({ field, hook }: { field: FormListFieldData, hook: Hook }) => {
let component: React.ReactNode;
switch (hook.action.case) {
case "actionDiscord":
return <>
<Form.Item name={[field.name, "action", "value", "webhookUrl"]} rules={[requiredField("webhook URL is required")]} >
<Input addonBefore={<div style={{ width: "8em" }}>Discord Webhook</div>} />
</Form.Item >
Text Template:
<Form.Item name={[field.name, "action", "value", "template"]} >
<Input.TextArea style={{ width: "100%", fontFamily: "monospace" }} />
</Form.Item >
</>
case "actionCommand":
return <>
<Tooltip title="Script to execute. Commands will not work in the docker build of Backrest.">
Script:
</Tooltip>
<Form.Item name={[field.name, "action", "value", "command"]} rules={[requiredField("command is required")]}>
<Input.TextArea style={{ width: "100%", fontFamily: "monospace" }} />
</Form.Item>
</>
case "actionGotify":
return <>
<Form.Item name={[field.name, "action", "value", "baseUrl"]} rules={[requiredField("gotify base URL is required"), { type: "url" }]}>
<Input addonBefore={<div style={{ width: "8em" }}>Gotify Base URL</div>} />
</Form.Item >
<Form.Item name={[field.name, "action", "value", "token"]} rules={[requiredField("gotify token is required")]}>
<Input addonBefore={<div style={{ width: "8em" }}>Gotify Token</div>} />
</Form.Item>
<Form.Item name={[field.name, "action", "value", "titleTemplate"]} rules={[requiredField("gotify title template is required")]}>
<Input addonBefore={<div style={{ width: "8em" }}>Title Template</div>} />
</Form.Item>
Text Template:
<Form.Item name={[field.name, "action", "value", "template"]}>
<Input.TextArea style={{ width: "100%", fontFamily: "monospace" }} />
</Form.Item>
</>
default:
return <p>Unknown hook {hook.action.case}</p>
const HookBuilder = ({ field }: { field: FormListFieldData }) => {
const form = Form.useFormInstance();
const hookData = form.getFieldValue(["hooks", field.name]) as HookFields;
if (hookData.actionDiscord) {
return <>
<Form.Item name={[field.name, "action", "value", "webhookUrl"]} rules={[requiredField("webhook URL is required")]} >
<Input addonBefore={<div style={{ width: "8em" }}>Discord Webhook</div>} />
</Form.Item >
Text Template:
<Form.Item name={[field.name, "action", "value", "template"]} >
<Input.TextArea style={{ width: "100%", fontFamily: "monospace" }} />
</Form.Item >
</>
} else if (hookData.actionCommand) {
return <>
<Tooltip title="Script to execute. Commands will not work in the docker build of Backrest.">
Script:
</Tooltip>
<Form.Item name={[field.name, "action", "value", "command"]} rules={[requiredField("command is required")]}>
<Input.TextArea style={{ width: "100%", fontFamily: "monospace" }} />
</Form.Item>
</>
} else if (hookData.actionGotify) {
return <>
<Form.Item name={[field.name, "action", "value", "baseUrl"]} rules={[requiredField("gotify base URL is required"), { type: "url" }]}>
<Input addonBefore={<div style={{ width: "8em" }}>Gotify Base URL</div>} />
</Form.Item >
<Form.Item name={[field.name, "action", "value", "token"]} rules={[requiredField("gotify token is required")]}>
<Input addonBefore={<div style={{ width: "8em" }}>Gotify Token</div>} />
</Form.Item>
<Form.Item name={[field.name, "action", "value", "titleTemplate"]} rules={[requiredField("gotify title template is required")]}>
<Input addonBefore={<div style={{ width: "8em" }}>Title Template</div>} />
</Form.Item>
Text Template:
<Form.Item name={[field.name, "action", "value", "template"]}>
<Input.TextArea style={{ width: "100%", fontFamily: "monospace" }} />
</Form.Item>
</>
} else {
return <p>Unknown hook</p>
}
}
+2 -1
View File
@@ -3,7 +3,8 @@ import type { ValidateErrorEntity } from "rc-field-form/lib/interface";
export const validateForm = async <T>(form: FormInstance<T>) => {
try {
return await form.validateFields();
await form.validateFields();
return form.getFieldsValue();
} catch (e: any) {
if (e.errorFields) {
const firstError = (e as ValidateErrorEntity).errorFields?.[0]
+123 -128
View File
@@ -14,7 +14,7 @@ import {
Collapse,
FormInstance,
} from "antd";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useShowModal } from "../components/ModalManager";
import { Plan, RetentionPolicy } from "../../gen/ts/v1/config_pb";
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
@@ -30,13 +30,16 @@ import { backrestService } from "../api";
export const AddPlanModal = ({
template,
}: {
template: Partial<Plan> | null;
template: Plan | null;
}) => {
const [confirmLoading, setConfirmLoading] = useState(false);
const showModal = useShowModal();
const alertsApi = useAlertApi()!;
const [config, setConfig] = useConfig();
const [form] = Form.useForm<Plan>();
const [form] = Form.useForm();
useEffect(() => {
form.setFieldsValue(template ? JSON.parse(template.toJsonString()) : {});
}, [template])
if (!config) {
return null;
@@ -77,7 +80,12 @@ export const AddPlanModal = ({
setConfirmLoading(true);
try {
let plan = new Plan(await validateForm<Plan>(form));
let planFormData = await validateForm(form);
const plan = new Plan().fromJsonString(JSON.stringify(planFormData), { ignoreUnknownFields: false });
if (plan.retention && plan.retention.equals(new RetentionPolicy())) {
delete plan.retention;
}
// Merge the new plan (or update) into the config
if (template) {
@@ -330,14 +338,13 @@ export const AddPlanModal = ({
</Tooltip>
{/* Plan.retention */}
<RetentionPolicyView policy={template?.retention} form={form} />
<RetentionPolicyView />
{/* Plan.hooks */}
<Form.Item
label={<Tooltip title={hooksListTooltipText}>Hooks</Tooltip>}
>
<HooksFormList hooks={template?.hooks || []} />
<HooksFormList />
</Form.Item>
@@ -360,145 +367,133 @@ export const AddPlanModal = ({
)}
</Form.Item>
</Form>
</Modal>
</Modal >
</>
);
};
const RetentionPolicyView = ({ form, policy }: { policy?: RetentionPolicy, form: FormInstance }) => {
enum PolicyType {
TimeBased,
CountBased,
None,
}
const RetentionPolicyView = () => {
const form = Form.useFormInstance();
const retention = form.getFieldValue("retention") as RetentionPolicy | undefined;
const [mode, setMode] = useState(!retention ? 2 : retention.keepLastN ? 0 : 1);
policy = policy || new RetentionPolicy();
let defaultPolicyType = PolicyType.None;
if (policy.keepLastN) {
defaultPolicyType = PolicyType.CountBased;
} else if (policy) {
defaultPolicyType = PolicyType.TimeBased;
}
const [policyType, setPolicyType] = useState<PolicyType>(defaultPolicyType);
let elem = null;
switch (policyType) {
case PolicyType.TimeBased:
elem = (
<Form.Item
required={true}
>
<Row>
<Col span={11}>
<Form.Item
name={["retention", "keepYearly"]}
initialValue={policy.keepYearly || 0}
validateTrigger={["onChange", "onBlur"]}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Yearly</div>}
type="number"
/>
</Form.Item>
<Form.Item
name={["retention", "keepMonthly"]}
initialValue={policy.keepMonthly || 3}
validateTrigger={["onChange", "onBlur"]}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Monthly</div>}
type="number"
/>
</Form.Item>
<Form.Item
name={["retention", "keepWeekly"]}
initialValue={policy.keepWeekly || 4}
validateTrigger={["onChange", "onBlur"]}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Weekly</div>}
type="number"
/>
</Form.Item>
</Col>
<Col span={11} offset={1}>
<Form.Item
name={["retention", "keepDaily"]}
initialValue={policy.keepDaily || 7}
validateTrigger={["onChange", "onBlur"]}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Daily</div>}
type="number"
/>
</Form.Item>
<Form.Item
name={["retention", "keepHourly"]}
initialValue={policy.keepHourly || 24}
validateTrigger={["onChange", "onBlur"]}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Hourly</div>}
type="number"
/>
</Form.Item>
</Col>
</Row>
</Form.Item >
);
break;
case PolicyType.CountBased:
elem = (
<Form.Item
name={["retention", "keepLastN"]}
initialValue={policy.keepLastN || 30}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
message: "Please input keep last N",
},
]}
>
<InputNumber addonBefore={<div style={{ width: "5em" }}>Count</div>} type="number" />
</Form.Item>
);
break;
case PolicyType.None:
elem = <p>All backups are retained e.g. for append-only repos. Ensure that you manually forget / prune backups elsewhere. Backrest will register forgets performed externally on the next backup.</p>
let elem: React.ReactNode = null;
if (mode === 2) {
elem = <p>All backups are retained e.g. for append-only repos. Ensure that you manually forget / prune backups elsewhere. Backrest will register forgets performed externally on the next backup.</p>;
} else if (mode === 0) {
elem = (
<Form.Item
name={["retention", "keepLastN"]}
initialValue={30}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
message: "Please input keep last N",
},
]}
>
<InputNumber addonBefore={<div style={{ width: "5em" }}>Count</div>} type="number" />
</Form.Item>
);
} else {
elem = (
<Form.Item
required={true}
>
<Row>
<Col span={11}>
<Form.Item
name={["retention", "keepYearly"]}
validateTrigger={["onChange", "onBlur"]}
initialValue={0}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Yearly</div>}
type="number"
/>
</Form.Item>
<Form.Item
name={["retention", "keepMonthly"]}
initialValue={3}
validateTrigger={["onChange", "onBlur"]}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Monthly</div>}
type="number"
/>
</Form.Item>
<Form.Item
name={["retention", "keepWeekly"]}
initialValue={4}
validateTrigger={["onChange", "onBlur"]}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Weekly</div>}
type="number"
/>
</Form.Item>
</Col>
<Col span={11} offset={1}>
<Form.Item
name={["retention", "keepDaily"]}
initialValue={7}
validateTrigger={["onChange", "onBlur"]}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Daily</div>}
type="number"
/>
</Form.Item>
<Form.Item
name={["retention", "keepHourly"]}
initialValue={24}
validateTrigger={["onChange", "onBlur"]}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Hourly</div>}
type="number"
/>
</Form.Item>
</Col>
</Row>
</Form.Item >
);
}
return (
<>
<Form.Item label="Retention Policy">
<Row>
<Radio.Group
value={policyType}
onChange={(e) => {
setPolicyType(e.target.value);
if (e.target.value === PolicyType.None) {
form.resetFields(["retention"]);
}
}}
>
<Radio.Button value={PolicyType.CountBased}>
<Radio.Group value={mode} onChange={e => {
const selected = e.target.value;
if (selected === 0) {
setMode(0);
form.setFieldValue("retention", { keepLastN: 30 });
} else if (selected === 1) {
setMode(1);
form.setFieldValue("retention", { keepYearly: 0, keepMonthly: 3, keepWeekly: 4, keepDaily: 7, keepHourly: 24 });
} else {
setMode(2);
form.setFieldValue("retention", null);
}
}}>
<Radio.Button value={0}>
<Tooltip title="The last N snapshots will be kept by restic. Retention policy is applied to drop older snapshots after each backup run.">
By Count
</Tooltip>
</Radio.Button>
<Radio.Button value={PolicyType.TimeBased}>
<Radio.Button value={1}>
<Tooltip title="Snapshots older than the specified time period will be dropped by restic. Retention policy is applied to drop older snapshots after each backup run." >
By Time Period
</Tooltip>
</Radio.Button>
<Radio.Button value={PolicyType.None}>
<Radio.Button value={2}>
<Tooltip title="All backups will be retained. Note that this may result in slow backups if the set of snapshots grows very large.">
None
</Tooltip>
@@ -509,7 +504,7 @@ const RetentionPolicyView = ({ form, policy }: { policy?: RetentionPolicy, form:
<Row>
{elem}
</Row>
</Form.Item>
</Form.Item >
</>
);
};
+27 -23
View File
@@ -13,9 +13,9 @@ import {
FormInstance,
Collapse,
} from "antd";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useShowModal } from "../components/ModalManager";
import { Repo } from "../../gen/ts/v1/config_pb";
import { Hook, Repo } from "../../gen/ts/v1/config_pb";
import { URIAutocomplete } from "../components/URIAutocomplete";
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { useAlertApi } from "../components/Alerts";
@@ -24,6 +24,7 @@ import { backrestService } from "../api";
import {
HooksFormList,
hooksListTooltipText,
HookFormData,
} from "../components/HooksFormList";
import { ConfirmButton } from "../components/SpinButton";
import { useConfig } from "../components/ConfigProvider";
@@ -31,13 +32,16 @@ import { useConfig } from "../components/ConfigProvider";
export const AddRepoModal = ({
template,
}: {
template: Partial<Repo> | null;
template: Repo | null;
}) => {
const [confirmLoading, setConfirmLoading] = useState(false);
const showModal = useShowModal();
const alertsApi = useAlertApi()!;
const [config, setConfig] = useConfig();
const [form] = Form.useForm<Repo>();
const [form] = Form.useForm();
useEffect(() => {
form.setFieldsValue(template ? JSON.parse(template.toJsonString()) : {});
}, [template])
if (!config) {
return null;
@@ -88,7 +92,8 @@ export const AddRepoModal = ({
setConfirmLoading(true);
try {
let repo = await validateForm<Repo>(form);
let repoFormData = await validateForm(form);
const repo = new Repo().fromJsonString(JSON.stringify(repoFormData), { ignoreUnknownFields: false });
if (template !== null) {
// We are in the edit repo flow, update the repo in the config
@@ -97,7 +102,7 @@ export const AddRepoModal = ({
alertsApi.error("Can't update repo, not found");
return;
}
config.repos![idx] = new Repo(repo);
config.repos![idx] = repo;
setConfig(await backrestService.setConfig(config));
showModal(null);
alertsApi.success("Updated repo " + repo.uri);
@@ -166,7 +171,6 @@ export const AddRepoModal = ({
hasFeedback
name="id"
label="Repo Name"
initialValue={template ? template.id : ""}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
@@ -219,7 +223,6 @@ export const AddRepoModal = ({
hasFeedback
name="uri"
label="Repository URI"
initialValue={template ? template.uri : ""}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
@@ -239,7 +242,6 @@ export const AddRepoModal = ({
<Form.Item<Repo>
hasFeedback
name="password"
initialValue={template ? template.password : ""}
validateTrigger={["onChange", "onBlur"]}
>
<Input disabled={!!template} />
@@ -276,16 +278,15 @@ export const AddRepoModal = ({
},
},
]}
initialValue={template ? template.env : []}
>
{(fields, { add, remove }, { errors }) => (
<>
{fields.map((field, index) => (
console.log("FIELD: ", field),
<Form.Item key={field.key}>
<Form.Item
{...field}
validateTrigger={["onChange", "onBlur"]}
initialValue={""}
rules={[
{
required: true,
@@ -305,7 +306,7 @@ export const AddRepoModal = ({
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(field.name)}
onClick={() => remove(index)}
style={{ paddingLeft: "5px" }}
/>
</Form.Item>
@@ -313,7 +314,7 @@ export const AddRepoModal = ({
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
onClick={() => add("THIS IS A NEW VALUE")}
style={{ width: "90%" }}
icon={<PlusOutlined />}
>
@@ -330,7 +331,6 @@ export const AddRepoModal = ({
<Form.Item label="Flags">
<Form.List
name="flags"
initialValue={template ? template.flags : []}
>
{(fields, { add, remove }, { errors }) => (
<>
@@ -339,7 +339,6 @@ export const AddRepoModal = ({
<Form.Item
{...field}
validateTrigger={["onChange", "onBlur"]}
initialValue={""}
rules={[
{
required: true,
@@ -355,7 +354,7 @@ export const AddRepoModal = ({
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(field.name)}
onClick={() => remove(index)}
style={{ paddingLeft: "5px" }}
/>
</Form.Item>
@@ -398,7 +397,7 @@ export const AddRepoModal = ({
>
<Form.Item
name={["prunePolicy", "maxFrequencyDays"]}
initialValue={template?.prunePolicy?.maxFrequencyDays || 7}
initialValue={7}
required={false}
>
<InputNumber
@@ -409,7 +408,7 @@ export const AddRepoModal = ({
</Form.Item>
<Form.Item
name={["prunePolicy", "maxUnusedPercent"]}
initialValue={template?.prunePolicy?.maxUnusedPercent || 25}
initialValue={25}
required={false}
>
<InputNumber
@@ -425,7 +424,7 @@ export const AddRepoModal = ({
<Form.Item
label={<Tooltip title={hooksListTooltipText}>Hooks</Tooltip>}
>
<HooksFormList hooks={template?.hooks || []} />
<HooksFormList />
</Form.Item>
<Form.Item shouldUpdate label="Preview">
@@ -438,9 +437,7 @@ export const AddRepoModal = ({
label: "Repo Config as JSON",
children: (
<Typography>
<pre>{new Repo(form.getFieldsValue()).toJsonString({
prettySpaces: 2,
})}</pre>
<pre>{JSON.stringify(form.getFieldsValue(), undefined, 2)}</pre>
</Typography>
),
},
@@ -461,13 +458,20 @@ const expectedEnvVars: { [scheme: string]: string[] } = {
gs: ["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_PROJECT_ID"],
};
const envVarSetValidator = (form: FormInstance<Repo>, envVars: string[]) => {
const envVarSetValidator = (form: FormInstance<FormData>, envVars: string[]) => {
if (!envVars) {
return Promise.resolve();
}
let uri = form.getFieldValue("uri");
if (!uri) {
return Promise.resolve();
}
const envVarNames = envVars.map((e) => {
if (!e) {
return "";
}
let idx = e.indexOf("=");
if (idx === -1) {
return "";
+1 -1
View File
@@ -121,7 +121,7 @@ export const App: React.FC = () => {
</small>
<Button
type="text"
style={{ marginLeft: "10px" }}
style={{ marginLeft: "10px", color: "white" }}
onClick={() => {
setAuthToken("");
window.location.reload();
+1
View File
@@ -11,6 +11,7 @@ export const GettingStartedGuide = () => {
<>
<Typography.Text>
<h1>Getting Started</h1>
<p><a href="https://github.com/garethgeorge/backrest">Backrest documentation on GitHub</a></p>
<Divider orientation="left">Overview</Divider>
<ul>
<li>