mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 20:32:05 +00:00
fix(team-members): keep legacy roles and colors editable in the detail drawer
Members created before the SH-326 model can carry data the current backend contract rejects (PUT team-members accepts only the Dispatcher/Scheduler/Admin roles and the ten palette colors), which made them impossible to edit. - Legacy roles (e.g. Manager) now show as "<role> (legacy)" in the edit form with helper copy asking to choose Dispatcher, Scheduler, or Admin; save stays blocked until one is chosen and the legacy value is never sent. View mode marks the stored role as legacy too. - Stored colors outside the current palette are canonicalized when they only differ by casing; otherwise the nearest palette color (RGB distance, first as fallback) is preselected with a notice naming the replaced value, so saving never fails on an untouched legacy color. - Role/color options move to team-member-form-options.ts with unit tests for canonicalization, nearest-color matching, and replacement notices.
This commit is contained in:
parent
aa407bc012
commit
c0f7bb26eb
6 changed files with 295 additions and 39 deletions
|
|
@ -36,26 +36,16 @@ import {
|
||||||
PERMISSION_LABELS,
|
PERMISSION_LABELS,
|
||||||
permissionOverrides,
|
permissionOverrides,
|
||||||
} from "./team-member-permissions";
|
} from "./team-member-permissions";
|
||||||
|
import { TEAM_MEMBER_COLORS, TEAM_MEMBER_ROLES } from "./team-member-form-options";
|
||||||
|
|
||||||
const ROLES: TeamMemberRole[] = ["Dispatcher", "Scheduler", "Admin"];
|
const LEGACY_ROLE_OPTION = "__legacy-role__";
|
||||||
const COLORS = [
|
|
||||||
"#F59E0B",
|
|
||||||
"#0D9488",
|
|
||||||
"#6366F1",
|
|
||||||
"#F43F5E",
|
|
||||||
"#7C3AED",
|
|
||||||
"#0EA5E9",
|
|
||||||
"#16A34A",
|
|
||||||
"#EA580C",
|
|
||||||
"#0891B2",
|
|
||||||
"#C026D3",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function emptyForm() {
|
function emptyForm() {
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
role: "Dispatcher" as TeamMemberRole,
|
role: "Dispatcher" as TeamMemberRole | "",
|
||||||
color: "",
|
color: "",
|
||||||
|
colorNotice: "",
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
active: true,
|
active: true,
|
||||||
|
|
@ -109,6 +99,53 @@ function PermissionsSection({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RoleSelectField({
|
||||||
|
role,
|
||||||
|
roleError,
|
||||||
|
updateRole,
|
||||||
|
disabled,
|
||||||
|
legacyRole,
|
||||||
|
}: {
|
||||||
|
role: TeamMemberRole | "";
|
||||||
|
roleError: string;
|
||||||
|
updateRole: (role: TeamMemberRole) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
legacyRole: string;
|
||||||
|
}) {
|
||||||
|
const legacyRolePending = Boolean(legacyRole) && !role;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl fullWidth required error={Boolean(roleError)}>
|
||||||
|
<InputLabel id="team-member-role-label">Role</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="team-member-role-label"
|
||||||
|
label="Role"
|
||||||
|
value={legacyRolePending ? LEGACY_ROLE_OPTION : role}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => updateRole(event.target.value as TeamMemberRole)}
|
||||||
|
>
|
||||||
|
{legacyRolePending && (
|
||||||
|
<MenuItem value={LEGACY_ROLE_OPTION} disabled>
|
||||||
|
{legacyRole} (legacy)
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{TEAM_MEMBER_ROLES.map((option) => (
|
||||||
|
<MenuItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{legacyRolePending && (
|
||||||
|
<FormHelperText error={Boolean(roleError)}>
|
||||||
|
{legacyRole} is a legacy role and can no longer be saved. Choose Dispatcher, Scheduler, or
|
||||||
|
Admin.
|
||||||
|
</FormHelperText>
|
||||||
|
)}
|
||||||
|
{!legacyRolePending && Boolean(roleError) && <FormHelperText>{roleError}</FormHelperText>}
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TeamMemberFormFields({
|
export function TeamMemberFormFields({
|
||||||
form,
|
form,
|
||||||
setForm,
|
setForm,
|
||||||
|
|
@ -117,6 +154,7 @@ export function TeamMemberFormFields({
|
||||||
toggleArea,
|
toggleArea,
|
||||||
togglePermission,
|
togglePermission,
|
||||||
roleDisabled = false,
|
roleDisabled = false,
|
||||||
|
legacyRole = "",
|
||||||
}: {
|
}: {
|
||||||
form: TeamMemberForm;
|
form: TeamMemberForm;
|
||||||
setForm: Dispatch<SetStateAction<TeamMemberForm>>;
|
setForm: Dispatch<SetStateAction<TeamMemberForm>>;
|
||||||
|
|
@ -125,6 +163,7 @@ export function TeamMemberFormFields({
|
||||||
toggleArea: (area: string) => void;
|
toggleArea: (area: string) => void;
|
||||||
togglePermission: (key: string) => void;
|
togglePermission: (key: string) => void;
|
||||||
roleDisabled?: boolean;
|
roleDisabled?: boolean;
|
||||||
|
legacyRole?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2.5}>
|
<Stack spacing={2.5}>
|
||||||
|
|
@ -138,36 +177,26 @@ export function TeamMemberFormFields({
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<FormControl fullWidth required error={Boolean(errors.role)}>
|
<RoleSelectField
|
||||||
<InputLabel id="team-member-role-label">Role</InputLabel>
|
role={form.role}
|
||||||
<Select
|
roleError={errors.role}
|
||||||
labelId="team-member-role-label"
|
updateRole={updateRole}
|
||||||
label="Role"
|
disabled={roleDisabled}
|
||||||
value={form.role}
|
legacyRole={legacyRole}
|
||||||
disabled={roleDisabled}
|
/>
|
||||||
onChange={(event) => updateRole(event.target.value as TeamMemberRole)}
|
|
||||||
>
|
|
||||||
{ROLES.map((role) => (
|
|
||||||
<MenuItem key={role} value={role}>
|
|
||||||
{role}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{Boolean(errors.role) && <FormHelperText>{errors.role}</FormHelperText>}
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography component="p" variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
|
<Typography component="p" variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||||
Color <span aria-hidden="true">*</span>
|
Color <span aria-hidden="true">*</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: "wrap" }}>
|
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: "wrap" }}>
|
||||||
{COLORS.map((color) => (
|
{TEAM_MEMBER_COLORS.map((color) => (
|
||||||
<Button
|
<Button
|
||||||
key={color}
|
key={color}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Select color ${color}`}
|
aria-label={`Select color ${color}`}
|
||||||
aria-pressed={form.color === color}
|
aria-pressed={form.color === color}
|
||||||
onClick={() => setForm((current) => ({ ...current, color }))}
|
onClick={() => setForm((current) => ({ ...current, color, colorNotice: "" }))}
|
||||||
sx={{
|
sx={{
|
||||||
minWidth: 32,
|
minWidth: 32,
|
||||||
width: 32,
|
width: 32,
|
||||||
|
|
@ -183,6 +212,9 @@ export function TeamMemberFormFields({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{Boolean(form.colorNotice) && !errors.color && (
|
||||||
|
<FormHelperText>{form.colorNotice}</FormHelperText>
|
||||||
|
)}
|
||||||
{Boolean(errors.color) && <FormHelperText error>{errors.color}</FormHelperText>}
|
{Boolean(errors.color) && <FormHelperText error>{errors.color}</FormHelperText>}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
@ -297,7 +329,7 @@ export function AddTeamMemberDialog({ open, onClose }: { open: boolean; onClose:
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
if (!isValid) return;
|
if (!isValid || form.role === "") return;
|
||||||
|
|
||||||
const input: CreateTeamMemberInput = {
|
const input: CreateTeamMemberInput = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
} from "./team-member-permissions";
|
} from "./team-member-permissions";
|
||||||
import { getFormErrors } from "./team-member-permissions";
|
import { getFormErrors } from "./team-member-permissions";
|
||||||
import { TeamMemberFormFields, type TeamMemberForm } from "./add-team-member-dialog";
|
import { TeamMemberFormFields, type TeamMemberForm } from "./add-team-member-dialog";
|
||||||
|
import { canonicalRole, resolveFormColor } from "./team-member-form-options";
|
||||||
import {
|
import {
|
||||||
useTeamMemberDetail,
|
useTeamMemberDetail,
|
||||||
useUpdateTeamMember,
|
useUpdateTeamMember,
|
||||||
|
|
@ -47,10 +48,12 @@ import type {
|
||||||
} from "@/domain/team-members/types/team-member";
|
} from "@/domain/team-members/types/team-member";
|
||||||
|
|
||||||
function formFromMember(member: TeamMemberDetail): TeamMemberForm {
|
function formFromMember(member: TeamMemberDetail): TeamMemberForm {
|
||||||
|
const { color, notice: colorNotice } = resolveFormColor(member.color);
|
||||||
return {
|
return {
|
||||||
name: member.name,
|
name: member.name,
|
||||||
role: (member.role as TeamMemberRole) || "Dispatcher",
|
role: canonicalRole(member.role) ?? "",
|
||||||
color: member.color,
|
color,
|
||||||
|
colorNotice,
|
||||||
email: member.email,
|
email: member.email,
|
||||||
phone: member.phone,
|
phone: member.phone,
|
||||||
active: member.status === "Active",
|
active: member.status === "Active",
|
||||||
|
|
@ -68,6 +71,7 @@ function emptyForm(): TeamMemberForm {
|
||||||
name: "",
|
name: "",
|
||||||
role: "Dispatcher",
|
role: "Dispatcher",
|
||||||
color: "",
|
color: "",
|
||||||
|
colorNotice: "",
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
active: true,
|
active: true,
|
||||||
|
|
@ -147,7 +151,10 @@ function MemberView({ member }: { member: TeamMemberDetail }) {
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
<Field label="Name" value={member.name} />
|
<Field label="Name" value={member.name} />
|
||||||
<Field label="Role" value={member.role} />
|
<Field
|
||||||
|
label="Role"
|
||||||
|
value={canonicalRole(member.role) ? member.role : `${member.role} (legacy)`}
|
||||||
|
/>
|
||||||
<Field label="Color" value={member.color} />
|
<Field label="Color" value={member.color} />
|
||||||
<Field
|
<Field
|
||||||
label="Service Area"
|
label="Service Area"
|
||||||
|
|
@ -173,6 +180,7 @@ function MemberEditForm({
|
||||||
toggleArea,
|
toggleArea,
|
||||||
togglePermission,
|
togglePermission,
|
||||||
roleDisabled,
|
roleDisabled,
|
||||||
|
legacyRole,
|
||||||
}: {
|
}: {
|
||||||
form: TeamMemberForm;
|
form: TeamMemberForm;
|
||||||
setForm: Dispatch<SetStateAction<TeamMemberForm>>;
|
setForm: Dispatch<SetStateAction<TeamMemberForm>>;
|
||||||
|
|
@ -181,6 +189,7 @@ function MemberEditForm({
|
||||||
toggleArea: (area: string) => void;
|
toggleArea: (area: string) => void;
|
||||||
togglePermission: (key: string) => void;
|
togglePermission: (key: string) => void;
|
||||||
roleDisabled: boolean;
|
roleDisabled: boolean;
|
||||||
|
legacyRole: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TeamMemberFormFields
|
<TeamMemberFormFields
|
||||||
|
|
@ -191,6 +200,7 @@ function MemberEditForm({
|
||||||
toggleArea={toggleArea}
|
toggleArea={toggleArea}
|
||||||
togglePermission={togglePermission}
|
togglePermission={togglePermission}
|
||||||
roleDisabled={roleDisabled}
|
roleDisabled={roleDisabled}
|
||||||
|
legacyRole={legacyRole}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -297,6 +307,7 @@ function useTeamMemberEditor(
|
||||||
emailIsValid &&
|
emailIsValid &&
|
||||||
(form.role !== "Dispatcher" || form.serviceAreas.length > 0);
|
(form.role !== "Dispatcher" || form.serviceAreas.length > 0);
|
||||||
const protectedOwner = currentMember?.isAccountOwner === true;
|
const protectedOwner = currentMember?.isAccountOwner === true;
|
||||||
|
const legacyRole = currentMember && !canonicalRole(currentMember.role) ? currentMember.role : "";
|
||||||
|
|
||||||
const updateRole = (role: TeamMemberRole) => {
|
const updateRole = (role: TeamMemberRole) => {
|
||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
|
|
@ -327,7 +338,7 @@ function useTeamMemberEditor(
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
if (!isValid || !currentMember) return;
|
if (!isValid || !currentMember || form.role === "") return;
|
||||||
|
|
||||||
updateTeamMember.mutate(
|
updateTeamMember.mutate(
|
||||||
{
|
{
|
||||||
|
|
@ -353,6 +364,7 @@ function useTeamMemberEditor(
|
||||||
setForm,
|
setForm,
|
||||||
errors,
|
errors,
|
||||||
protectedOwner,
|
protectedOwner,
|
||||||
|
legacyRole,
|
||||||
isPending: updateTeamMember.isPending,
|
isPending: updateTeamMember.isPending,
|
||||||
updateRole,
|
updateRole,
|
||||||
toggleArea,
|
toggleArea,
|
||||||
|
|
@ -426,6 +438,7 @@ function DrawerContent({
|
||||||
toggleArea={editor.toggleArea}
|
toggleArea={editor.toggleArea}
|
||||||
togglePermission={editor.togglePermission}
|
togglePermission={editor.togglePermission}
|
||||||
roleDisabled={editor.protectedOwner}
|
roleDisabled={editor.protectedOwner}
|
||||||
|
legacyRole={editor.legacyRole}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import type { TeamMemberRole } from "@/domain/team-members/types/team-member";
|
||||||
|
|
||||||
|
export const TEAM_MEMBER_ROLES: readonly TeamMemberRole[] = ["Dispatcher", "Scheduler", "Admin"];
|
||||||
|
|
||||||
|
export const TEAM_MEMBER_COLORS = [
|
||||||
|
"#F59E0B",
|
||||||
|
"#0D9488",
|
||||||
|
"#6366F1",
|
||||||
|
"#F43F5E",
|
||||||
|
"#7C3AED",
|
||||||
|
"#0EA5E9",
|
||||||
|
"#16A34A",
|
||||||
|
"#EA580C",
|
||||||
|
"#0891B2",
|
||||||
|
"#C026D3",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Returns the supported role matching the stored value (case-insensitive), or null for legacy roles. */
|
||||||
|
export function canonicalRole(value: string): TeamMemberRole | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return TEAM_MEMBER_ROLES.find((role) => role.toLowerCase() === trimmed.toLowerCase()) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHexColor(value: string): [number, number, number] | null {
|
||||||
|
const match = /^#?([0-9a-f]{6})$/i.exec(value.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
const numeric = Number.parseInt(match[1], 16);
|
||||||
|
return [(numeric >> 16) & 255, (numeric >> 8) & 255, numeric & 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the palette color closest to the stored color by RGB distance, or the first palette color when unparseable. */
|
||||||
|
export function nearestPaletteColor(value: string): string {
|
||||||
|
const target = parseHexColor(value);
|
||||||
|
if (!target) return TEAM_MEMBER_COLORS[0];
|
||||||
|
|
||||||
|
let nearest: string = TEAM_MEMBER_COLORS[0];
|
||||||
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
for (const color of TEAM_MEMBER_COLORS) {
|
||||||
|
const [red, green, blue] = parseHexColor(color) ?? [0, 0, 0];
|
||||||
|
const distance = (red - target[0]) ** 2 + (green - target[1]) ** 2 + (blue - target[2]) ** 2;
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearest = color;
|
||||||
|
nearestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedFormColor {
|
||||||
|
color: string;
|
||||||
|
notice: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a stored color onto the current palette. Palette colors (any casing) are
|
||||||
|
* canonicalized; legacy colors are replaced by the nearest palette color with a
|
||||||
|
* notice so the replacement is never silent.
|
||||||
|
*/
|
||||||
|
export function resolveFormColor(stored: string): ResolvedFormColor {
|
||||||
|
const trimmed = stored.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return {
|
||||||
|
color: TEAM_MEMBER_COLORS[0],
|
||||||
|
notice: `No color was saved for this member; preselected ${TEAM_MEMBER_COLORS[0]} from the palette.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonical = TEAM_MEMBER_COLORS.find(
|
||||||
|
(color) => color.toLowerCase() === trimmed.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (canonical) return { color: canonical, notice: "" };
|
||||||
|
|
||||||
|
const nearest = nearestPaletteColor(trimmed);
|
||||||
|
return {
|
||||||
|
color: nearest,
|
||||||
|
notice: `Saved color ${trimmed} is not in the current palette and was replaced with the nearest palette color ${nearest}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -88,7 +88,7 @@ export function permissionOverrides(role: TeamMemberRole, selected: Set<string>)
|
||||||
|
|
||||||
export interface TeamMemberFormValues {
|
export interface TeamMemberFormValues {
|
||||||
name: string;
|
name: string;
|
||||||
role: TeamMemberRole;
|
role: TeamMemberRole | "";
|
||||||
color: string;
|
color: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
|
||||||
|
|
@ -158,4 +158,83 @@ describe("TeamMemberDetailDrawer", () => {
|
||||||
expect(screen.getAllByText("Taylor Dispatcher")).not.toHaveLength(0);
|
expect(screen.getAllByText("Taylor Dispatcher")).not.toHaveLength(0);
|
||||||
expect(screen.queryByDisplayValue("Discarded draft")).not.toBeInTheDocument();
|
expect(screen.queryByDisplayValue("Discarded draft")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks a legacy role in view mode", () => {
|
||||||
|
vi.mocked(useTeamMemberDetail).mockReturnValue({
|
||||||
|
data: { ...detail, role: "Manager" },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as unknown as ReturnType<typeof useTeamMemberDetail>);
|
||||||
|
renderWithProviders(<TeamMemberDetailDrawer member={member} onClose={vi.fn()} />, {
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Manager (legacy)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a legacy role in the edit form and blocks save until a supported role is chosen", async () => {
|
||||||
|
vi.mocked(useTeamMemberDetail).mockReturnValue({
|
||||||
|
data: { ...detail, role: "Manager" },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as unknown as ReturnType<typeof useTeamMemberDetail>);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<TeamMemberDetailDrawer member={member} onClose={vi.fn()} />, {
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
const roleSelect = screen.getByRole("combobox", { name: "Role" });
|
||||||
|
expect(roleSelect).toHaveTextContent("Manager (legacy)");
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Manager is a legacy role and can no longer be saved/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||||
|
expect(mutate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.click(roleSelect);
|
||||||
|
await user.click(screen.getByRole("option", { name: "Scheduler" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mutate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "u-1",
|
||||||
|
input: expect.objectContaining({ role: "Scheduler" }),
|
||||||
|
}),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preselects the nearest palette color for a legacy color and saves without a manual pick", async () => {
|
||||||
|
vi.mocked(useTeamMemberDetail).mockReturnValue({
|
||||||
|
data: { ...detail, color: "#FF0000" },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as unknown as ReturnType<typeof useTeamMemberDetail>);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<TeamMemberDetailDrawer member={member} onClose={vi.fn()} />, {
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Saved color #FF0000 is not in the current palette/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Select color #EA580C" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mutate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
input: expect.objectContaining({ color: "#EA580C" }),
|
||||||
|
}),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
canonicalRole,
|
||||||
|
nearestPaletteColor,
|
||||||
|
resolveFormColor,
|
||||||
|
TEAM_MEMBER_COLORS,
|
||||||
|
} from "@/app/(protected)/team-members/_components/team-member-form-options";
|
||||||
|
|
||||||
|
describe("canonicalRole", () => {
|
||||||
|
it("returns the canonical role for supported values", () => {
|
||||||
|
expect(canonicalRole("Dispatcher")).toBe("Dispatcher");
|
||||||
|
expect(canonicalRole("scheduler")).toBe("Scheduler");
|
||||||
|
expect(canonicalRole(" Admin ")).toBe("Admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for legacy and empty roles", () => {
|
||||||
|
expect(canonicalRole("Manager")).toBeNull();
|
||||||
|
expect(canonicalRole("Supervisor")).toBeNull();
|
||||||
|
expect(canonicalRole("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nearestPaletteColor", () => {
|
||||||
|
it("returns the closest palette color by RGB distance", () => {
|
||||||
|
expect(nearestPaletteColor("#FF0000")).toBe("#EA580C");
|
||||||
|
expect(nearestPaletteColor("#00FF00")).toBe("#16A34A");
|
||||||
|
expect(nearestPaletteColor("#111111")).toBe("#16A34A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first palette color for unparseable values", () => {
|
||||||
|
expect(nearestPaletteColor("not-a-color")).toBe(TEAM_MEMBER_COLORS[0]);
|
||||||
|
expect(nearestPaletteColor("")).toBe(TEAM_MEMBER_COLORS[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveFormColor", () => {
|
||||||
|
it("keeps palette colors and canonicalizes their casing", () => {
|
||||||
|
expect(resolveFormColor("#F59E0B")).toEqual({ color: "#F59E0B", notice: "" });
|
||||||
|
expect(resolveFormColor("#f59e0b")).toEqual({ color: "#F59E0B", notice: "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a legacy color with the nearest palette color and explains the replacement", () => {
|
||||||
|
const resolved = resolveFormColor("#FF0000");
|
||||||
|
expect(resolved.color).toBe("#EA580C");
|
||||||
|
expect(resolved.notice).toContain("#FF0000");
|
||||||
|
expect(resolved.notice).toContain("#EA580C");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preselects the first palette color when nothing was stored", () => {
|
||||||
|
const resolved = resolveFormColor(" ");
|
||||||
|
expect(resolved.color).toBe(TEAM_MEMBER_COLORS[0]);
|
||||||
|
expect(resolved.notice).not.toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue