Skip to content

Commit 563ccdf

Browse files
authored
SBCQ-97: Part 2 - Update services (#74)
2 parents 2ec46c3 + 961b9ba commit 563ccdf

10 files changed

Lines changed: 230 additions & 30 deletions

File tree

src/app/protected/admin/services/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { revalidatePath } from "next/cache"
22
import { ServiceTable } from "@/components/admin/services/ServiceTable"
3+
import { doesServiceCodeExist } from "@/lib/prisma/service/doesServiceCodeExist"
34
import { getAllServices } from "@/lib/prisma/service/getAllServices"
45
import { updateService } from "@/lib/prisma/service/updateService"
56
import { getAllLocations } from "@/utils"
@@ -23,6 +24,7 @@ export default async function Page() {
2324
services={services}
2425
updateService={updateService}
2526
offices={offices}
27+
doesServiceCodeExist={doesServiceCodeExist}
2628
revalidateTable={revalidateTable}
2729
/>
2830
</div>

src/components/admin/services/EditServiceModal/EditServiceModal.test.tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import "@testing-library/jest-dom"
12
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
23
import { describe, expect, it, vi } from "vitest"
34

@@ -11,13 +12,44 @@ import type { ServiceWithRelations } from "@/lib/prisma/service/types"
1112
import { EditServiceModal } from "./EditServiceModal"
1213

1314
describe("EditServiceModal", () => {
14-
const service = { id: "s1", code: "SRV1", deletedAt: null } as unknown as ServiceWithRelations
15+
const now = new Date()
16+
const service = {
17+
id: "s1",
18+
code: "SRV1",
19+
name: "Service 1",
20+
description: "Description",
21+
publicName: "Public Service",
22+
ticketPrefix: "T",
23+
legacyServiceId: null,
24+
backOffice: false,
25+
deletedAt: null,
26+
createdAt: now,
27+
updatedAt: now,
28+
locations: [
29+
{
30+
id: "o1",
31+
name: "Office 1",
32+
deletedAt: null,
33+
createdAt: now,
34+
updatedAt: now,
35+
timezone: "UTC",
36+
streetAddress: "123 Test St",
37+
mailAddress: null,
38+
phoneNumber: null,
39+
latitude: 0,
40+
longitude: 0,
41+
legacyOfficeNumber: null,
42+
},
43+
],
44+
} as unknown as ServiceWithRelations
45+
1546
const offices = [{ id: "o1", name: "Office 1" }] as unknown as Location[]
1647

1748
it("renders modal title when open with a service", async () => {
1849
const onClose = vi.fn()
1950
const updateService = vi.fn().mockResolvedValue(service)
2051
const revalidateTable = vi.fn().mockResolvedValue(undefined)
52+
const doesServiceCodeExist = vi.fn().mockResolvedValue(false)
2153

2254
render(
2355
<EditServiceModal
@@ -26,6 +58,7 @@ describe("EditServiceModal", () => {
2658
service={service}
2759
offices={offices}
2860
updateService={updateService}
61+
doesServiceCodeExist={doesServiceCodeExist}
2962
revalidateTable={revalidateTable}
3063
/>
3164
)
@@ -38,6 +71,7 @@ describe("EditServiceModal", () => {
3871
const onClose = vi.fn()
3972
const updateService = vi.fn().mockResolvedValue(service)
4073
const revalidateTable = vi.fn().mockResolvedValue(undefined)
74+
const doesServiceCodeExist = vi.fn().mockResolvedValue(false)
4175

4276
render(
4377
<EditServiceModal
@@ -46,6 +80,7 @@ describe("EditServiceModal", () => {
4680
service={service}
4781
offices={offices}
4882
updateService={updateService}
83+
doesServiceCodeExist={doesServiceCodeExist}
4984
revalidateTable={revalidateTable}
5085
/>
5186
)
@@ -60,7 +95,10 @@ describe("EditServiceModal", () => {
6095
onClose.mockReset()
6196
updateService.mockReset()
6297
revalidateTable.mockReset()
98+
doesServiceCodeExist.mockReset()
6399

100+
// wait for async validation to complete so Save is enabled
101+
await waitFor(() => expect(screen.getByText("Save Changes")).toBeEnabled())
64102
fireEvent.click(screen.getByText("Save Changes"))
65103

66104
await waitFor(() => expect(updateService).toHaveBeenCalled())

src/components/admin/services/EditServiceModal/EditServiceModal.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client"
22

33
import { useEffect, useState } from "react"
4+
import { z } from "zod"
45
import {
56
CloseButton,
67
DialogActions,
@@ -22,6 +23,7 @@ type EditServiceModalProps = {
2223
service: Partial<ServiceWithRelations>,
2324
prevService: Partial<ServiceWithRelations>
2425
) => Promise<ServiceWithRelations | null>
26+
doesServiceCodeExist: (code: string) => Promise<boolean>
2527
revalidateTable: () => Promise<void>
2628
}
2729

@@ -31,11 +33,37 @@ export const EditServiceModal = ({
3133
service,
3234
offices,
3335
updateService,
36+
doesServiceCodeExist,
3437
revalidateTable,
3538
}: EditServiceModalProps) => {
3639
const [isSaving, setIsSaving] = useState(false)
3740
const [formData, setFormData] = useState<ServiceWithRelations | null>(null)
3841
const [previousService, setPreviousService] = useState<ServiceWithRelations | null>(null)
42+
const [isFormValidState, setIsFormValidState] = useState<boolean>(false)
43+
const [isFormValidating, setIsFormValidating] = useState<boolean>(false)
44+
45+
const EditServiceWithRelationsSchema = z.object({
46+
name: z.string().min(1, "Name is required"),
47+
code: z
48+
.string()
49+
.min(1, "Code is required")
50+
.refine(
51+
async (code) => {
52+
if (code === previousService?.code) return true
53+
return !(await doesServiceCodeExist(code))
54+
},
55+
{ message: "Code already exists" }
56+
),
57+
description: z.string(),
58+
publicName: z.string().min(1, "Public name is required"),
59+
ticketPrefix: z.string().min(1, "Ticket prefix is required"),
60+
legacyServiceId: z.number().nullable(),
61+
backOffice: z.boolean(),
62+
deletedAt: z.date().nullable(),
63+
createdAt: z.date(),
64+
updatedAt: z.date(),
65+
locations: z.array(z.any()),
66+
})
3967

4068
useEffect(() => {
4169
if (open && service) {
@@ -44,6 +72,34 @@ export const EditServiceModal = ({
4472
}
4573
}, [open, service])
4674

75+
// Validate formData asynchronously and update local state instead of calling async validators during render
76+
// biome-ignore lint/correctness/useExhaustiveDependencies: <>
77+
useEffect(() => {
78+
if (!formData) {
79+
setIsFormValidState(false)
80+
setIsFormValidating(false)
81+
return
82+
}
83+
84+
let active = true
85+
setIsFormValidating(true)
86+
87+
EditServiceWithRelationsSchema.parseAsync(formData)
88+
.then(() => {
89+
if (active) setIsFormValidState(true)
90+
})
91+
.catch(() => {
92+
if (active) setIsFormValidState(false)
93+
})
94+
.finally(() => {
95+
if (active) setIsFormValidating(false)
96+
})
97+
98+
return () => {
99+
active = false
100+
}
101+
}, [formData, previousService, doesServiceCodeExist])
102+
47103
if (!service || !formData || !previousService) return null
48104

49105
const isArchived = service.deletedAt !== null
@@ -81,6 +137,7 @@ export const EditServiceModal = ({
81137
service={formData}
82138
offices={offices}
83139
setFormData={setFormData}
140+
doesServiceCodeExist={doesServiceCodeExist}
84141
isReadonly={isReadonly}
85142
/>
86143
</form>
@@ -94,7 +151,7 @@ export const EditServiceModal = ({
94151
type="button"
95152
className="primary"
96153
onClick={handleSave}
97-
disabled={isReadonly || isSaving}
154+
disabled={isReadonly || isSaving || isFormValidating || !isFormValidState}
98155
>
99156
{isSaving ? "Saving..." : "Save Changes"}
100157
</button>

src/components/admin/services/ServiceForm.tsx

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,83 @@
11
import type { Dispatch, SetStateAction } from "react"
2-
import { useMemo } from "react"
3-
import { Switch, TextArea, TextField } from "@/components/common"
2+
import { useEffect, useMemo, useRef, useState } from "react"
3+
import { Notice, Switch, TextArea, TextField } from "@/components/common"
44
import { MultiSelect } from "@/components/common/select/MultiSelect"
55
import type { Location } from "@/generated/prisma/client"
66
import type { ServiceWithRelations } from "@/lib/prisma/service/types"
77

88
type ServiceFormProps = {
9+
initialCode?: string
910
service: ServiceWithRelations
1011
offices: Location[]
1112
setFormData: Dispatch<SetStateAction<ServiceWithRelations | null>>
13+
doesServiceCodeExist: (code: string) => Promise<boolean>
1214
isReadonly: boolean
1315
}
1416

1517
/**
1618
* ServiceForm component renders the form fields for editing a service.
1719
*
1820
* @param props - The properties object.
21+
* @property props.initialCode - The initial code of the service, used to determine if the code has changed.
1922
* @property props.service - The service being edited.
2023
* @property props.offices - List of office locations.
2124
* @property props.setFormData - Function to update the form data state.
25+
* @property props.doesServiceCodeExist - Function to check if a service code already exists.
2226
* @property props.isReadonly - Whether the section inputs are read-only.
2327
*/
24-
export const ServiceForm = ({ service, offices, setFormData, isReadonly }: ServiceFormProps) => {
28+
export const ServiceForm = ({
29+
initialCode,
30+
service,
31+
offices,
32+
setFormData,
33+
doesServiceCodeExist,
34+
isReadonly,
35+
}: ServiceFormProps) => {
2536
const officeOptions = useMemo(() => offices.map((o) => ({ key: o.id, label: o.name })), [offices])
2637

2738
const selectedOfficeIds = service.locations ? service.locations.map((l) => l.id) : []
39+
const [codeExists, setCodeExists] = useState<boolean | null>(null)
40+
const initialCodeRef = useRef<string | undefined>(initialCode ?? service.code)
41+
42+
// biome-ignore lint/correctness/useExhaustiveDependencies: <>
43+
useEffect(() => {
44+
// when the service changes (new service loaded) reset initial code and state
45+
initialCodeRef.current = initialCode ?? service.code
46+
setCodeExists(null)
47+
}, [service.updatedAt, initialCode])
48+
49+
useEffect(() => {
50+
// debounce checking service code existence
51+
const code = service.code
52+
if (!code || code.length === 0) {
53+
setCodeExists(null)
54+
return
55+
}
56+
57+
// don't warn if the code is unchanged from the initial value
58+
if (initialCodeRef.current === code) {
59+
setCodeExists(false)
60+
return
61+
}
62+
63+
const t = setTimeout(() => {
64+
doesServiceCodeExist(code)
65+
.then((exists) => setCodeExists(exists))
66+
.catch(() => setCodeExists(null))
67+
}, 500)
68+
69+
return () => clearTimeout(t)
70+
}, [service.code, doesServiceCodeExist])
71+
2872
return (
2973
<div className="flex flex-col gap-2">
74+
{codeExists && <Notice type="warn" message="A service with this code already exists." />}
3075
<div className="grid grid-cols-7 gap-2">
3176
<TextField
3277
id="service-code"
3378
label="Code"
3479
value={service.code}
35-
onChange={(v) => setFormData((s) => (s ? { ...s, code: v } : s))}
80+
onChange={(v) => setFormData((s) => (s ? { ...s, code: v.replace(/\s/g, "") } : s))}
3681
disabled={isReadonly}
3782
required
3883
className="col-span-3"

src/components/admin/services/ServiceTable/ServiceTable.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ export type ServiceTableProps = {
1616
service: Partial<ServiceWithRelations>,
1717
prevService: Partial<ServiceWithRelations>
1818
) => Promise<ServiceWithRelations | null>
19+
doesServiceCodeExist: (code: string) => Promise<boolean>
1920
revalidateTable: () => Promise<void>
2021
}
2122

2223
export const ServiceTable = ({
2324
services,
2425
offices,
2526
updateService,
27+
doesServiceCodeExist,
2628
revalidateTable,
2729
}: ServiceTableProps) => {
2830
const {
@@ -69,6 +71,7 @@ export const ServiceTable = ({
6971
service={selectedService}
7072
offices={offices}
7173
updateService={updateService}
74+
doesServiceCodeExist={doesServiceCodeExist}
7275
revalidateTable={revalidateTable}
7376
/>
7477
</>

src/components/common/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from "./footer"
44
export * from "./header"
55
export * from "./icons"
66
export * from "./loginout"
7+
export * from "./notice"
78
export * from "./select"
89
export * from "./switch"
910
export * from "./textarea"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
type NoticeProps = {
2+
type: "success" | "error" | "warn" | "info"
3+
message: string
4+
}
5+
6+
export const Notice = ({ type, message }: NoticeProps) => {
7+
const bgColor =
8+
type === "success"
9+
? "bg-green-50"
10+
: type === "error"
11+
? "bg-red-50"
12+
: type === "warn"
13+
? "bg-yellow-50"
14+
: "bg-blue-50"
15+
const borderLeftColor =
16+
type === "success"
17+
? "border-l-green-400"
18+
: type === "error"
19+
? "border-l-red-400"
20+
: type === "warn"
21+
? "border-l-yellow-400"
22+
: "border-l-blue-400"
23+
const textColor =
24+
type === "success"
25+
? "text-green-800"
26+
: type === "error"
27+
? "text-red-800"
28+
: type === "warn"
29+
? "text-yellow-800"
30+
: "text-blue-800"
31+
32+
return (
33+
<div
34+
role="alert"
35+
className={`rounded-md border-l-4 ${borderLeftColor} ${bgColor} p-2 text-sm ${textColor}`}
36+
>
37+
{message}
38+
</div>
39+
)
40+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./Notice"

0 commit comments

Comments
 (0)