validation.ts121 lines · main
1type KeyValueFieldName = string
2
3export type KeyValueFieldArrayValidationIssue<TFieldName extends KeyValueFieldName> = {
4 path: [number, TFieldName]
5 message: string
6}
7
8type GetKeyValueFieldArrayValidationIssuesParams<
9 TRow extends Record<string, unknown>,
10 TKeyFieldName extends Extract<keyof TRow, string>,
11 TValueFieldName extends Extract<keyof TRow, string>,
12> = {
13 rows: TRow[]
14 keyFieldName: TKeyFieldName
15 valueFieldName: TValueFieldName
16 keyRequiredMessage: string
17 valueRequiredMessage: string
18 duplicateKeyMessage?: string
19 allowEmptyRows?: boolean
20 normaliseKey?: (key: string) => string
21}
22
23const getTrimmedString = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
24
25export type StripEmptyKeyValueFieldArrayRowsParams<
26 TRow extends Record<string, unknown>,
27 TKeyFieldName extends Extract<keyof TRow, string>,
28 TValueFieldName extends Extract<keyof TRow, string>,
29> = {
30 rows: TRow[]
31 keyFieldName: TKeyFieldName
32 valueFieldName: TValueFieldName
33}
34
35/**
36 * Removes fully empty draft rows before persisting field-array values.
37 */
38export const stripEmptyKeyValueFieldArrayRows = <
39 TRow extends Record<string, unknown>,
40 TKeyFieldName extends Extract<keyof TRow, string>,
41 TValueFieldName extends Extract<keyof TRow, string>,
42>({
43 rows,
44 keyFieldName,
45 valueFieldName,
46}: StripEmptyKeyValueFieldArrayRowsParams<TRow, TKeyFieldName, TValueFieldName>) =>
47 rows.filter((row) => {
48 const key = getTrimmedString(row[keyFieldName])
49 const value = getTrimmedString(row[valueFieldName])
50
51 return key.length > 0 || value.length > 0
52 })
53
54/**
55 * Returns per-cell validation issues for draft-friendly key/value rows.
56 *
57 * Consumers should feed these issues into their resolver schema, typically via
58 * `zod.superRefine(...)`, so validation stays declarative and local to the form.
59 */
60export const getKeyValueFieldArrayValidationIssues = <
61 TRow extends Record<string, unknown>,
62 TKeyFieldName extends Extract<keyof TRow, string>,
63 TValueFieldName extends Extract<keyof TRow, string>,
64>({
65 rows,
66 keyFieldName,
67 valueFieldName,
68 keyRequiredMessage,
69 valueRequiredMessage,
70 duplicateKeyMessage,
71 allowEmptyRows = true,
72 normaliseKey = (key) => key,
73}: GetKeyValueFieldArrayValidationIssuesParams<TRow, TKeyFieldName, TValueFieldName>) => {
74 const issues: KeyValueFieldArrayValidationIssue<TKeyFieldName | TValueFieldName>[] = []
75 const rowIndexesByKey = duplicateKeyMessage ? new Map<string, number[]>() : null
76
77 rows.forEach((row, index) => {
78 const key = getTrimmedString(row[keyFieldName])
79 const value = getTrimmedString(row[valueFieldName])
80
81 if (!key && !value) {
82 if (!allowEmptyRows) {
83 issues.push({ path: [index, keyFieldName], message: keyRequiredMessage })
84 issues.push({ path: [index, valueFieldName], message: valueRequiredMessage })
85 }
86 return
87 }
88
89 if (!key) {
90 issues.push({ path: [index, keyFieldName], message: keyRequiredMessage })
91 return
92 }
93
94 if (!value) {
95 issues.push({ path: [index, valueFieldName], message: valueRequiredMessage })
96 return
97 }
98
99 if (!rowIndexesByKey) return
100
101 const normalisedKey = normaliseKey(key)
102 if (!normalisedKey) return
103
104 rowIndexesByKey.set(normalisedKey, [...(rowIndexesByKey.get(normalisedKey) ?? []), index])
105 })
106
107 if (!rowIndexesByKey || !duplicateKeyMessage) return issues
108
109 rowIndexesByKey.forEach((indexes) => {
110 if (indexes.length < 2) return
111
112 indexes.forEach((index) => {
113 issues.push({
114 path: [index, keyFieldName],
115 message: duplicateKeyMessage,
116 })
117 })
118 })
119
120 return issues
121}