fix: slightly improve schedule layout and minor ui bug fixes
Release Please / release-please (push) Has been cancelled
Release Preview / call-reusable-release (push) Has been cancelled
Test / test-nix (push) Has been cancelled
Test / test-win (push) Has been cancelled
Update Restic / update-restic-version (push) Has been cancelled

This commit is contained in:
Gareth
2025-12-22 00:54:30 -08:00
parent 4c95556573
commit 64abe7c8b1
5 changed files with 247 additions and 328 deletions
+1 -1
View File
@@ -623,7 +623,7 @@ const BackupOperationStatus = ({
console.error("GOT UNEXPECTED STATUS: ", status);
return (
<>
{m.op_row_unexpected_status} {status}
{m.op_row_unexpected_status() + JSON.stringify(status)}
</>
);
}
+17 -17
View File
@@ -1,9 +1,9 @@
import {
Checkbox,
Flex,
Form,
InputNumber,
Radio,
Row,
Tooltip,
Typography,
} from "antd";
@@ -169,8 +169,8 @@ export const ScheduleFormItem = ({
}
return (
<>
<Row>
<Flex vertical gap="small">
<div>
<Radio.Group
value={mode}
onChange={(e) => {
@@ -217,26 +217,25 @@ export const ScheduleFormItem = ({
</Tooltip>
</Radio.Button>
</Radio.Group>
<Typography.Text style={{ marginLeft: "1em", marginRight: "1em" }}>
Clock for schedule:{" "}
</Typography.Text>
</div>
<Flex align="center" gap="small">
<Typography.Text>Clock for schedule:</Typography.Text>
<Tooltip
title={
<>
Clock provides the time that the schedule is evaluated relative
to.
Clock provides the time that the schedule is evaluated relative to.
<ul>
<li>Local - current time in the local timezone.</li>
<li>UTC - current time in the UTC timezone.</li>
<li>
Last Run Time - relative to the last time the task ran. Good
for devices that aren't always powered on e.g. laptops.
Last Run Time - relative to the last time the task ran. Good for
devices that aren't always powered on e.g. laptops.
</li>
</ul>
</>
}
>
<Form.Item name={name.concat("clock")}>
<Form.Item name={name.concat("clock")} noStyle>
<Radio.Group>
<Radio.Button
value={clockEnumValueToString(Schedule_Clock.LOCAL)}
@@ -254,12 +253,13 @@ export const ScheduleFormItem = ({
</Radio.Group>
</Form.Item>
</Tooltip>
</Row>
<div style={{ height: "0.5em" }} />
<Row>
<Form.Item>{elem}</Form.Item>
</Row>
</>
</Flex>
{elem && (
<div style={{ marginTop: "8px" }}>
<Form.Item noStyle>{elem}</Form.Item>
</div>
)}
</Flex>
);
};
+138 -215
View File
@@ -13,6 +13,7 @@ import {
Collapse,
Checkbox,
AutoComplete,
Flex,
} from "antd";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useShowModal } from "../components/ModalManager";
@@ -53,112 +54,7 @@ import * as m from "../paraglide/messages";
const { TextArea } = Input;
const sep = isWindows ? "\\" : "/";
const PathsTextArea = ({ value, onChange, ...props }: any) => {
const [options, setOptions] = useState<{ value: string }[]>([]);
const [currentLine, setCurrentLine] = useState("");
const [cursorPosition, setCursorPosition] = useState(0);
// selectingRef acts as a lock to prevent the AutoComplete's default behavior (replacing the entire value)
// from overwriting the multi-line merge logic in onSelect.
const selectingRef = useRef(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSearch = useMemo(
() =>
debounce((searchValue: string) => {
if (!searchValue) {
setOptions([]);
return;
}
const lastSlash = searchValue.lastIndexOf(sep);
let searchPath = searchValue;
if (lastSlash !== -1) {
searchPath = searchValue.substring(0, lastSlash);
}
backrestService
.pathAutocomplete({ value: searchPath + sep })
.then((res: StringList) => {
if (!res.values) {
return;
}
const vals = res.values.map((v) => {
return {
value: searchPath + sep + v,
};
});
setOptions(vals.filter((o) => o.value.indexOf(searchValue) !== -1));
})
.catch((e) => {
console.log("Path autocomplete error: ", e);
});
}, 200),
[]
);
const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
// If we are currently handling a selection, ignore the change event that AutoComplete triggers
// to replace the text with the selected item.
if (selectingRef.current) {
selectingRef.current = false;
return;
}
const newValue = e.target.value;
const cursorPos = e.target.selectionStart || 0;
// Find the current line based on cursor position
const lines = newValue.substring(0, cursorPos).split("\n");
const currentLineValue = lines[lines.length - 1];
setCurrentLine(currentLineValue);
setCursorPosition(cursorPos);
// Trigger autocomplete for the current line
handleSearch(currentLineValue);
// Update the form value
if (onChange) {
onChange(newValue);
}
};
const onSelect = (selectedValue: string) => {
// Set the flag to ignore the next onChange event (which contains the raw selected value)
selectingRef.current = true;
const lines = (value || "").split("\n");
const beforeCursor = (value || "").substring(0, cursorPosition);
const afterCursor = (value || "").substring(cursorPosition);
const linesBeforeCursor = beforeCursor.split("\n");
// Replace the current line with the selected value
linesBeforeCursor[linesBeforeCursor.length - 1] = selectedValue;
const newValue = linesBeforeCursor.join("\n") + afterCursor;
if (onChange) {
onChange(newValue);
}
setOptions([]);
};
return (
<AutoComplete
options={options}
onSelect={onSelect}
onSearch={() => {}} // We handle search in textarea change
{...props}
>
<TextArea
value={value}
onChange={handleTextAreaChange}
placeholder={m.add_plan_modal_field_paths_placeholder()}
style={{ minHeight: 100 }}
autoSize={{ minRows: 3, maxRows: 10 }}
/>
</AutoComplete>
);
};
const planDefaults = create(PlanSchema, {
schedule: {
@@ -191,15 +87,7 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
? toJson(PlanSchema, template, { alwaysEmitImplicit: true })
: toJson(PlanSchema, planDefaults, { alwaysEmitImplicit: true });
// Convert paths array to newline-separated string for the textarea
const formDataObj = formData as any;
if (formDataObj?.paths && Array.isArray(formDataObj.paths)) {
formDataObj.pathsText = formDataObj.paths.join("\n");
} else {
formDataObj.pathsText = "";
}
form.setFieldsValue(formDataObj);
form.setFieldsValue(formData);
}, [template]);
if (!config) {
@@ -244,17 +132,6 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
try {
let planFormData = await validateForm(form);
// Convert pathsText back to paths array
if (planFormData.pathsText) {
planFormData.paths = planFormData.pathsText
.split("\n")
.map((path: string) => path.trim())
.filter((path: string) => path.length > 0);
delete planFormData.pathsText;
} else {
planFormData.paths = [];
}
const plan = fromJson(PlanSchema, planFormData, {
ignoreUnknownFields: false,
});
@@ -342,8 +219,8 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
<Form
autoComplete="off"
form={form}
labelCol={{ span: 6 }}
wrapperCol={{ span: 16 }}
labelCol={{ flex: "160px" }}
wrapperCol={{ flex: "auto" }}
disabled={confirmLoading}
>
{/* Plan.id */}
@@ -406,30 +283,73 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
{/* Plan.paths */}
<Form.Item
name="pathsText"
label={m.add_plan_modal_field_paths()}
required={true}
tooltip={m.add_plan_modal_field_paths_tooltip()}
rules={[
{
validator: async (_, value) => {
if (!value || !value.trim()) {
throw new Error(m.add_plan_modal_validation_paths_required());
}
const paths = value
.split("\n")
.map((p: string) => p.trim())
.filter((p: string) => p.length > 0);
if (paths.length === 0) {
throw new Error(
m.add_plan_modal_validation_paths_valid_required()
);
}
},
},
]}
>
<PathsTextArea />
<Form.List
name="paths"
rules={[
{
validator: async (_, paths) => {
if (!paths || paths.length === 0) {
throw new Error(
m.add_plan_modal_validation_paths_required()
);
}
},
},
]}
initialValue={template ? template.paths : []}
>
{(fields, { add, remove }, { errors }) => (
<>
{fields.map((field, index) => {
const { key, ...restField } = field;
return (
<Form.Item required={false} key={field.key}>
<Flex gap="small" align="center">
<Form.Item
{...restField}
validateTrigger={["onChange", "onBlur"]}
initialValue={""}
rules={[
{
required: true,
message:
m.add_plan_modal_validation_paths_valid_required(),
},
]}
noStyle
>
<URIAutocomplete
style={{ flex: 1 }}
onBlur={() => form.validateFields()}
globAllowed={true}
/>
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(field.name)}
/>
</Flex>
</Form.Item>
);
})}
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
block
icon={<PlusOutlined />}
>
Add Path
</Button>
<Form.ErrorList errors={errors} />
</Form.Item>
</>
)}
</Form.List>
</Form.Item>
{/* Plan.excludes */}
@@ -460,28 +380,29 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
const { key, ...restField } = field;
return (
<Form.Item required={false} key={field.key}>
<Form.Item
{...restField}
validateTrigger={["onChange", "onBlur"]}
initialValue={""}
rules={[
{
required: true,
},
]}
noStyle
>
<URIAutocomplete
style={{ width: "90%" }}
onBlur={() => form.validateFields()}
globAllowed={true}
<Flex gap="small" align="center">
<Form.Item
{...restField}
validateTrigger={["onChange", "onBlur"]}
initialValue={""}
rules={[
{
required: true,
},
]}
noStyle
>
<URIAutocomplete
style={{ flex: 1 }}
onBlur={() => form.validateFields()}
globAllowed={true}
/>
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(field.name)}
/>
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(field.name)}
style={{ paddingLeft: "5px" }}
/>
</Flex>
</Form.Item>
);
})}
@@ -489,7 +410,7 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
<Button
type="dashed"
onClick={() => add()}
style={{ width: "90%" }}
block
icon={<PlusOutlined />}
>
{m.add_plan_modal_field_excludes_add()}
@@ -529,28 +450,29 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
const { key, ...restField } = field;
return (
<Form.Item required={false} key={field.key}>
<Form.Item
{...restField}
validateTrigger={["onChange", "onBlur"]}
initialValue={""}
rules={[
{
required: true,
},
]}
noStyle
>
<URIAutocomplete
style={{ width: "90%" }}
onBlur={() => form.validateFields()}
globAllowed={true}
<Flex gap="small" align="center">
<Form.Item
{...restField}
validateTrigger={["onChange", "onBlur"]}
initialValue={""}
rules={[
{
required: true,
},
]}
noStyle
>
<URIAutocomplete
style={{ flex: 1 }}
onBlur={() => form.validateFields()}
globAllowed={true}
/>
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(field.name)}
/>
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(field.name)}
style={{ paddingLeft: "5px" }}
/>
</Flex>
</Form.Item>
);
})}
@@ -558,7 +480,7 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
<Button
type="dashed"
onClick={() => add()}
style={{ width: "90%" }}
block
icon={<PlusOutlined />}
>
{m.add_plan_modal_field_iexcludes_add()}
@@ -593,30 +515,31 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
const { key, ...restField } = field;
return (
<Form.Item required={false} key={field.key}>
<Form.Item
{...restField}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
whitespace: true,
pattern: /^\-\-?.*$/,
message:
m.add_plan_modal_validation_flag_pattern(),
},
]}
noStyle
>
<Input
placeholder="--flag"
style={{ width: "90%" }}
<Flex gap="small" align="center">
<Form.Item
{...restField}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
whitespace: true,
pattern: /^\-\-?.*$/,
message:
m.add_plan_modal_validation_flag_pattern(),
},
]}
noStyle
>
<Input
placeholder="--flag"
style={{ flex: 1 }}
/>
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(index)}
/>
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(index)}
style={{ paddingLeft: "5px" }}
/>
</Flex>
</Form.Item>
);
})}
@@ -624,7 +547,7 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
<Button
type="dashed"
onClick={() => add()}
style={{ width: "90%" }}
block
icon={<PlusOutlined />}
>
{m.add_plan_modal_field_backup_flags_add()}
+90 -95
View File
@@ -15,6 +15,7 @@ import {
Checkbox,
Select,
Space,
Flex,
} from "antd";
import React, { useEffect, useState } from "react";
import { useShowModal } from "../components/ModalManager";
@@ -243,8 +244,8 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
<Form
autoComplete="off"
form={form}
labelCol={{ span: 4 }}
wrapperCol={{ span: 18 }}
labelCol={{ flex: "160px" }}
wrapperCol={{ flex: "auto" }}
disabled={confirmLoading}
>
{/* Repo.id */}
@@ -350,41 +351,34 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
}
>
<Form.Item label={m.add_repo_modal_field_password()}>
<Row>
<Col span={16}>
<Form.Item<Repo>
hasFeedback
name="password"
validateTrigger={["onChange", "onBlur"]}
>
<Input disabled={!!template} />
</Form.Item>
</Col>
<Col
span={7}
offset={1}
style={{ display: "flex", justifyContent: "left" }}
<Flex gap="small">
<Form.Item<Repo>
hasFeedback
name="password"
validateTrigger={["onChange", "onBlur"]}
noStyle
>
<Button
type="text"
onClick={() => {
if (template) return;
form.setFieldsValue({
password: cryptoRandomPassword(),
});
}}
>
{m.add_repo_modal_button_generate()}
</Button>
</Col>
</Row>
<Input disabled={!!template} style={{ flex: 1 }} />
</Form.Item>
<Button
type="text"
onClick={() => {
if (template) return;
form.setFieldsValue({
password: cryptoRandomPassword(),
});
}}
>
{m.add_repo_modal_button_generate()}
</Button>
</Flex>
</Form.Item>
</Tooltip>
{/* Repo.env */}
<Tooltip
title={
m.add_repo_modal_field_env_vars_tooltip()
m.add_repo_modal_field_env_vars_tooltip({ MY_FOO_VAR: "$MY_FOO_VAR" })
}
>
<Form.Item label={m.add_repo_modal_field_env_vars()}>
@@ -404,31 +398,31 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
const { key, ...restField } = field;
return (
<Form.Item key={field.key}>
<Form.Item
{...restField}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
whitespace: true,
pattern: /^[\w-]+=.*$/,
message:
m.add_repo_modal_error_env_format(),
},
]}
noStyle
>
<Input
placeholder="KEY=VALUE"
onBlur={() => form.validateFields()}
style={{ width: "90%" }}
<Flex gap="small" align="center">
<Form.Item
{...restField}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
whitespace: true,
pattern: /^[\w-]+=.*$/,
message: m.add_repo_modal_error_env_format(),
},
]}
noStyle
>
<Input
placeholder="KEY=VALUE"
onBlur={() => form.validateFields()}
style={{ flex: 1 }}
/>
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(index)}
/>
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(index)}
style={{ paddingLeft: "5px" }}
/>
</Flex>
</Form.Item>
);
})}
@@ -436,7 +430,7 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
<Button
type="dashed"
onClick={() => add("")}
style={{ width: "90%" }}
block
icon={<PlusOutlined />}
>
{m.add_repo_modal_button_set_env()}
@@ -458,30 +452,30 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
const { key, ...restField } = field;
return (
<Form.Item required={false} key={field.key}>
<Form.Item
{...restField}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
whitespace: true,
pattern: /^\-\-?.*$/,
message:
m.add_repo_modal_error_flag_format(),
},
]}
noStyle
>
<Input
placeholder="--flag"
style={{ width: "90%" }}
<Flex gap="small" align="center">
<Form.Item
{...restField}
validateTrigger={["onChange", "onBlur"]}
rules={[
{
required: true,
whitespace: true,
pattern: /^\-\-?.*$/,
message: m.add_repo_modal_error_flag_format(),
},
]}
noStyle
>
<Input
placeholder="--flag"
style={{ flex: 1 }}
/>
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(index)}
/>
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
onClick={() => remove(index)}
style={{ paddingLeft: "5px" }}
/>
</Flex>
</Form.Item>
);
})}
@@ -489,7 +483,7 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
<Button
type="dashed"
onClick={() => add()}
style={{ width: "90%" }}
block
icon={<PlusOutlined />}
>
{m.add_repo_modal_button_set_flag()}
@@ -501,6 +495,23 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
</Form.List>
</Form.Item>
{/* Repo.autoUnlock */}
<Form.Item
label={
<Tooltip
title={
m.add_repo_modal_field_auto_unlock_tooltip()
}
>
{m.add_repo_modal_field_auto_unlock()}
</Tooltip>
}
name="autoUnlock"
valuePropName="checked"
>
<Checkbox />
</Form.Item>
{/* Repo.prunePolicy */}
<Form.Item
label={
@@ -669,22 +680,6 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
</Form.Item>
)}
<Form.Item
label={
<Tooltip
title={
m.add_repo_modal_field_auto_unlock_tooltip()
}
>
{m.add_repo_modal_field_auto_unlock()}
</Tooltip>
}
name="autoUnlock"
valuePropName="checked"
>
<Checkbox />
</Form.Item>
<Form.Item
label={<Tooltip title={hooksListTooltipText}>{m.add_plan_modal_field_hooks()}</Tooltip>}
>
+1
View File
@@ -98,6 +98,7 @@ export const SettingsModal = () => {
user.passwordBcrypt = hash.value;
delete user.needsBcrypt;
}
delete user.isExisting;
}
}