335 lines
8.6 KiB
TypeScript
335 lines
8.6 KiB
TypeScript
import { useId, useState, type FormEvent } from "react";
|
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
|
import * as stylex from "@stylexjs/stylex";
|
|
import {
|
|
localRecordCreateMutation,
|
|
localRecordDeleteMutation,
|
|
localRecordUpdateMutation,
|
|
localRecordsQuery,
|
|
} from "@/lib/queries";
|
|
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
|
|
import InlineError from "@/lib/InlineError";
|
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
|
import Select from "@/ui/Select";
|
|
import { useCrudForm } from "@/ui/useCrudForm";
|
|
import { styles as shared } from "@/ui/styles";
|
|
import { colors } from "@/ui/tokens.stylex";
|
|
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
|
|
|
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
|
|
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
|
|
|
|
const styles = stylex.create({
|
|
formHeading: {
|
|
fontWeight: 500,
|
|
},
|
|
fieldLabel: {
|
|
display: "block",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
fontWeight: 500,
|
|
},
|
|
buttonRow: {
|
|
display: "flex",
|
|
gap: "0.5rem",
|
|
},
|
|
toolbar: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
marginTop: "1rem",
|
|
},
|
|
intro: {
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textMuted,
|
|
},
|
|
table: {
|
|
width: "100%",
|
|
textAlign: "left",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
},
|
|
headRow: {
|
|
borderBottomWidth: 1,
|
|
borderBottomStyle: "solid",
|
|
borderBottomColor: colors.border,
|
|
color: colors.textMuted,
|
|
},
|
|
headCell: {
|
|
paddingBlock: "0.5rem",
|
|
paddingRight: "1rem",
|
|
fontWeight: 500,
|
|
},
|
|
headCellLast: {
|
|
paddingBlock: "0.5rem",
|
|
},
|
|
bodyRow: {
|
|
borderBottomWidth: 1,
|
|
borderBottomStyle: "solid",
|
|
borderBottomColor: colors.border,
|
|
},
|
|
cell: {
|
|
paddingBlock: "0.5rem",
|
|
paddingRight: "1rem",
|
|
},
|
|
emptyCell: {
|
|
paddingBlock: "1rem",
|
|
color: colors.textMuted,
|
|
},
|
|
actionCell: {
|
|
paddingBlock: "0.5rem",
|
|
textAlign: "right",
|
|
whiteSpace: "nowrap",
|
|
},
|
|
dangerText: {
|
|
color: colors.danger,
|
|
},
|
|
dimWhenDisabled: {
|
|
opacity: { default: 1, ":disabled": 0.5 },
|
|
},
|
|
});
|
|
|
|
function RecordForm({
|
|
initial,
|
|
busy,
|
|
readOnly,
|
|
error,
|
|
onSubmit,
|
|
onCancel,
|
|
}: {
|
|
initial?: LocalRecord;
|
|
busy: boolean;
|
|
readOnly: boolean;
|
|
error: unknown;
|
|
onSubmit: (input: LocalRecordInput) => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const id = useId();
|
|
const [name, setName] = useState(initial?.name ?? "");
|
|
const [rtype, setRtype] = useState<LocalRecordType>(initial?.rtype ?? "A");
|
|
const [value, setValue] = useState(initial?.value ?? "");
|
|
const [ttl, setTtl] = useState(initial === undefined ? "" : String(initial.ttl));
|
|
|
|
function submit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
const input: LocalRecordInput = { name: name.trim(), rtype, value: value.trim() };
|
|
if (ttl.trim() !== "") input.ttl = Number(ttl);
|
|
onSubmit(input);
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
|
|
<h3 {...stylex.props(styles.formHeading)}>
|
|
{initial === undefined ? "New record" : `Edit ${initial.name}`}
|
|
</h3>
|
|
<div>
|
|
<label htmlFor={`${id}-name`} {...stylex.props(styles.fieldLabel)}>
|
|
Name
|
|
</label>
|
|
<input
|
|
id={`${id}-name`}
|
|
required
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
placeholder="nas.lan.home"
|
|
{...stylex.props(shared.input, shared.focusRing)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Select
|
|
label="Type"
|
|
value={rtype}
|
|
onChange={(next) => setRtype(next as LocalRecordType)}
|
|
options={RTYPE_OPTIONS}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor={`${id}-value`} {...stylex.props(styles.fieldLabel)}>
|
|
Value
|
|
</label>
|
|
<input
|
|
id={`${id}-value`}
|
|
required
|
|
value={value}
|
|
onChange={(event) => setValue(event.target.value)}
|
|
placeholder={
|
|
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
|
|
}
|
|
{...stylex.props(shared.input, shared.focusRing)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor={`${id}-ttl`} {...stylex.props(styles.fieldLabel)}>
|
|
TTL (seconds)
|
|
</label>
|
|
<input
|
|
id={`${id}-ttl`}
|
|
type="number"
|
|
min={0}
|
|
value={ttl}
|
|
onChange={(event) => setTtl(event.target.value)}
|
|
placeholder="300"
|
|
{...stylex.props(shared.input, shared.focusRing)}
|
|
/>
|
|
</div>
|
|
<div {...stylex.props(styles.buttonRow)}>
|
|
<button
|
|
type="submit"
|
|
disabled={busy || readOnly}
|
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
|
>
|
|
{busy ? "Saving…" : "Save"}
|
|
</button>
|
|
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<InlineError error={error} />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export default function RecordsTab() {
|
|
const records = useSuspenseQuery(localRecordsQuery()).data;
|
|
const {
|
|
create,
|
|
update,
|
|
remove,
|
|
form,
|
|
openForm,
|
|
closeForm,
|
|
onSubmit,
|
|
onDelete,
|
|
pendingDelete,
|
|
confirmPendingDelete,
|
|
cancelPendingDelete,
|
|
} = useCrudForm<LocalRecord, LocalRecordInput>({
|
|
create: localRecordCreateMutation,
|
|
update: localRecordUpdateMutation,
|
|
remove: localRecordDeleteMutation,
|
|
confirmDelete: (record) => `Delete record "${record.name}"?`,
|
|
});
|
|
const readOnly = useReadOnlyConfig();
|
|
|
|
return (
|
|
<div>
|
|
<div {...stylex.props(styles.toolbar)}>
|
|
<p {...stylex.props(styles.intro)}>Answers served directly for LAN names. Changes apply live.</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => openForm({ mode: "create" })}
|
|
disabled={readOnly}
|
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
|
>
|
|
Add record
|
|
</button>
|
|
</div>
|
|
<InlineError error={remove.error} />
|
|
{form?.mode === "create" && (
|
|
<RecordForm
|
|
busy={create.isPending}
|
|
readOnly={readOnly}
|
|
error={create.error}
|
|
onSubmit={onSubmit}
|
|
onCancel={closeForm}
|
|
/>
|
|
)}
|
|
<div {...stylex.props(shared.tableWrap)}>
|
|
<table {...stylex.props(styles.table)}>
|
|
<thead>
|
|
<tr {...stylex.props(styles.headRow)}>
|
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
|
Name
|
|
</th>
|
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
|
Type
|
|
</th>
|
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
|
Value
|
|
</th>
|
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
|
TTL
|
|
</th>
|
|
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{records.length === 0 && (
|
|
<tr>
|
|
<td colSpan={5} {...stylex.props(styles.emptyCell)}>
|
|
No local records yet.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{records.map((record) => (
|
|
<tr key={record.id} {...stylex.props(styles.bodyRow)}>
|
|
{form?.mode === "edit" && form.entity.id === record.id ? (
|
|
<td colSpan={5}>
|
|
<RecordForm
|
|
initial={record}
|
|
busy={update.isPending}
|
|
readOnly={readOnly}
|
|
error={update.error}
|
|
onSubmit={onSubmit}
|
|
onCancel={closeForm}
|
|
/>
|
|
</td>
|
|
) : (
|
|
<>
|
|
<td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
|
|
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
|
|
<td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
|
|
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
|
|
<td {...stylex.props(styles.actionCell)}>
|
|
<button
|
|
type="button"
|
|
onClick={() => openForm({ mode: "edit", entity: record })}
|
|
disabled={readOnly}
|
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
{...stylex.props(
|
|
shared.rowButton,
|
|
styles.dimWhenDisabled,
|
|
shared.focusRing,
|
|
)}
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => onDelete(record)}
|
|
disabled={remove.isPending || readOnly}
|
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
{...stylex.props(
|
|
shared.rowButton,
|
|
styles.dangerText,
|
|
styles.dimWhenDisabled,
|
|
shared.focusRing,
|
|
)}
|
|
>
|
|
Delete
|
|
</button>
|
|
</td>
|
|
</>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<ConfirmDialog
|
|
isOpen={pendingDelete !== null}
|
|
title="Delete record"
|
|
message={pendingDelete?.message ?? ""}
|
|
confirmLabel="Delete"
|
|
onConfirm={confirmPendingDelete}
|
|
onCancel={cancelPendingDelete}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|