mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 12:43:19 +00:00
* fix(deps): update dependency @mui/material to v9 * fix(deps): couple @mui/icons-material v9 and migrate system props --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Adam Moussa <adam@seahavenind.com>
206 lines
4.9 KiB
TypeScript
206 lines
4.9 KiB
TypeScript
import {
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
CircularProgress,
|
|
MenuItem,
|
|
Snackbar,
|
|
Stack,
|
|
TextField,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import { useState, type ReactNode } from "react";
|
|
|
|
export type SettingsFormField =
|
|
| {
|
|
name: string;
|
|
label: string;
|
|
type?: "text" | "email" | "number";
|
|
required?: boolean;
|
|
}
|
|
| {
|
|
name: string;
|
|
label: string;
|
|
type: "select";
|
|
required?: boolean;
|
|
options: Array<{ value: string; label: string }>;
|
|
};
|
|
|
|
type SettingsFormShellProps = {
|
|
title: string;
|
|
testId: string;
|
|
fields: SettingsFormField[];
|
|
initialValues: Record<string, string>;
|
|
formKey?: string;
|
|
isLoading?: boolean;
|
|
isError?: boolean;
|
|
errorMessage?: string;
|
|
notFound?: boolean;
|
|
onCancel: () => void;
|
|
onSave: (values: Record<string, string>) => Promise<void>;
|
|
extra?: ReactNode;
|
|
};
|
|
|
|
type SettingsFormFieldsProps = {
|
|
title: string;
|
|
testId: string;
|
|
fields: SettingsFormField[];
|
|
initialValues: Record<string, string>;
|
|
onCancel: () => void;
|
|
onSave: (values: Record<string, string>) => Promise<void>;
|
|
extra?: ReactNode;
|
|
};
|
|
|
|
function SettingsFormFields({
|
|
title,
|
|
testId,
|
|
fields,
|
|
initialValues,
|
|
onCancel,
|
|
onSave,
|
|
extra,
|
|
}: SettingsFormFieldsProps) {
|
|
const [values, setValues] = useState(initialValues);
|
|
const [saving, setSaving] = useState(false);
|
|
const [snackbar, setSnackbar] = useState<string | null>(null);
|
|
const [validationError, setValidationError] = useState<string | null>(null);
|
|
|
|
const handleSave = async () => {
|
|
for (const field of fields) {
|
|
if (field.required !== false && !values[field.name]?.trim()) {
|
|
setValidationError(`${field.label} is required.`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setValidationError(null);
|
|
setSaving(true);
|
|
try {
|
|
await onSave(values);
|
|
setSnackbar("Saved to mock store");
|
|
} catch (error) {
|
|
setValidationError(error instanceof Error ? error.message : "Save failed");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Stack spacing={3} data-testid={testId}>
|
|
<Typography component="h1" variant="h1">
|
|
{title}
|
|
</Typography>
|
|
|
|
{validationError ? <Alert severity="error">{validationError}</Alert> : null}
|
|
|
|
<Stack
|
|
spacing={2}
|
|
sx={{
|
|
maxWidth: 480,
|
|
}}
|
|
>
|
|
{fields.map((field) => {
|
|
if (field.type === "select") {
|
|
return (
|
|
<TextField
|
|
key={field.name}
|
|
select
|
|
label={field.label}
|
|
value={values[field.name] ?? ""}
|
|
onChange={(event) =>
|
|
setValues((current) => ({ ...current, [field.name]: event.target.value }))
|
|
}
|
|
required={field.required !== false}
|
|
data-testid={`${testId}-field-${field.name}`}
|
|
>
|
|
{field.options.map((option) => (
|
|
<MenuItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TextField
|
|
key={field.name}
|
|
label={field.label}
|
|
type={field.type ?? "text"}
|
|
value={values[field.name] ?? ""}
|
|
onChange={(event) =>
|
|
setValues((current) => ({ ...current, [field.name]: event.target.value }))
|
|
}
|
|
required={field.required !== false}
|
|
data-testid={`${testId}-field-${field.name}`}
|
|
/>
|
|
);
|
|
})}
|
|
{extra}
|
|
</Stack>
|
|
|
|
<Stack direction="row" spacing={1}>
|
|
<Button variant="contained" onClick={() => void handleSave()} disabled={saving}>
|
|
Save
|
|
</Button>
|
|
<Button variant="outlined" onClick={onCancel} disabled={saving}>
|
|
Cancel
|
|
</Button>
|
|
</Stack>
|
|
|
|
<Snackbar
|
|
open={Boolean(snackbar)}
|
|
autoHideDuration={2500}
|
|
onClose={() => setSnackbar(null)}
|
|
message={snackbar}
|
|
data-testid={`${testId}-snackbar`}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
export function SettingsFormShell({
|
|
title,
|
|
testId,
|
|
fields,
|
|
initialValues,
|
|
formKey = "new",
|
|
isLoading = false,
|
|
isError = false,
|
|
errorMessage,
|
|
notFound = false,
|
|
onCancel,
|
|
onSave,
|
|
extra,
|
|
}: SettingsFormShellProps) {
|
|
if (isLoading) {
|
|
return (
|
|
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
|
|
<CircularProgress aria-label={`Loading ${title}`} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (isError) {
|
|
return (
|
|
<Alert severity="error">{errorMessage ?? `Failed to load ${title.toLowerCase()}`}</Alert>
|
|
);
|
|
}
|
|
|
|
if (notFound) {
|
|
return <Alert severity="warning">Record not found.</Alert>;
|
|
}
|
|
|
|
return (
|
|
<SettingsFormFields
|
|
key={formKey}
|
|
title={title}
|
|
testId={testId}
|
|
fields={fields}
|
|
initialValues={initialValues}
|
|
onCancel={onCancel}
|
|
onSave={onSave}
|
|
extra={extra}
|
|
/>
|
|
);
|
|
}
|