mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## What kind of change does this PR introduce? Design system and validation consistency update. ## What is the current behaviour? `KeyValueFieldArray` already renders per-cell form messages, but each consumer still decides its own validation rules. At the moment, some consumers allow partially filled rows to submit silently, while Log Drains now treats them as inline validation errors. ## What is the new behaviour? This PR standardises the recommended partial-row behaviour for the current `KeyValueFieldArray` consumers by introducing a shared validation helper and using it from each form schema. - adds `getKeyValueFieldArrayValidationIssues` alongside `KeyValueFieldArray` - keeps `KeyValueFieldArray` presentation-only and leaves validation in consumer schemas - shows inline errors when one side of a key/value row is filled and the other is empty - keeps fully empty rows as draft rows - keeps duplicate-key validation in Log Drains, where it already applies - updates the design-system docs and examples to describe the validation pattern explicitly <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added reusable key/value validation utilities and public export; forms now trim header/key/value inputs, show inline errors for partially filled rows, and remove fully empty draft rows on submit. * **Documentation** * Clarified the field-array is rendering-only and added guidance for placing validation in form schemas and handling draft rows. * **Tests** * Added unit and integration tests covering validation rules, duplicate keys, trimming, draft-row stripping, and payload behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
122 lines
3.5 KiB
TypeScript
122 lines
3.5 KiB
TypeScript
type KeyValueFieldName = string
|
|
|
|
export type KeyValueFieldArrayValidationIssue<TFieldName extends KeyValueFieldName> = {
|
|
path: [number, TFieldName]
|
|
message: string
|
|
}
|
|
|
|
type GetKeyValueFieldArrayValidationIssuesParams<
|
|
TRow extends Record<string, unknown>,
|
|
TKeyFieldName extends Extract<keyof TRow, string>,
|
|
TValueFieldName extends Extract<keyof TRow, string>,
|
|
> = {
|
|
rows: TRow[]
|
|
keyFieldName: TKeyFieldName
|
|
valueFieldName: TValueFieldName
|
|
keyRequiredMessage: string
|
|
valueRequiredMessage: string
|
|
duplicateKeyMessage?: string
|
|
allowEmptyRows?: boolean
|
|
normaliseKey?: (key: string) => string
|
|
}
|
|
|
|
const getTrimmedString = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
|
|
|
|
export type StripEmptyKeyValueFieldArrayRowsParams<
|
|
TRow extends Record<string, unknown>,
|
|
TKeyFieldName extends Extract<keyof TRow, string>,
|
|
TValueFieldName extends Extract<keyof TRow, string>,
|
|
> = {
|
|
rows: TRow[]
|
|
keyFieldName: TKeyFieldName
|
|
valueFieldName: TValueFieldName
|
|
}
|
|
|
|
/**
|
|
* Removes fully empty draft rows before persisting field-array values.
|
|
*/
|
|
export const stripEmptyKeyValueFieldArrayRows = <
|
|
TRow extends Record<string, unknown>,
|
|
TKeyFieldName extends Extract<keyof TRow, string>,
|
|
TValueFieldName extends Extract<keyof TRow, string>,
|
|
>({
|
|
rows,
|
|
keyFieldName,
|
|
valueFieldName,
|
|
}: StripEmptyKeyValueFieldArrayRowsParams<TRow, TKeyFieldName, TValueFieldName>) =>
|
|
rows.filter((row) => {
|
|
const key = getTrimmedString(row[keyFieldName])
|
|
const value = getTrimmedString(row[valueFieldName])
|
|
|
|
return key.length > 0 || value.length > 0
|
|
})
|
|
|
|
/**
|
|
* Returns per-cell validation issues for draft-friendly key/value rows.
|
|
*
|
|
* Consumers should feed these issues into their resolver schema, typically via
|
|
* `zod.superRefine(...)`, so validation stays declarative and local to the form.
|
|
*/
|
|
export const getKeyValueFieldArrayValidationIssues = <
|
|
TRow extends Record<string, unknown>,
|
|
TKeyFieldName extends Extract<keyof TRow, string>,
|
|
TValueFieldName extends Extract<keyof TRow, string>,
|
|
>({
|
|
rows,
|
|
keyFieldName,
|
|
valueFieldName,
|
|
keyRequiredMessage,
|
|
valueRequiredMessage,
|
|
duplicateKeyMessage,
|
|
allowEmptyRows = true,
|
|
normaliseKey = (key) => key,
|
|
}: GetKeyValueFieldArrayValidationIssuesParams<TRow, TKeyFieldName, TValueFieldName>) => {
|
|
const issues: KeyValueFieldArrayValidationIssue<TKeyFieldName | TValueFieldName>[] = []
|
|
const rowIndexesByKey = duplicateKeyMessage ? new Map<string, number[]>() : null
|
|
|
|
rows.forEach((row, index) => {
|
|
const key = getTrimmedString(row[keyFieldName])
|
|
const value = getTrimmedString(row[valueFieldName])
|
|
|
|
if (!key && !value) {
|
|
if (!allowEmptyRows) {
|
|
issues.push({ path: [index, keyFieldName], message: keyRequiredMessage })
|
|
issues.push({ path: [index, valueFieldName], message: valueRequiredMessage })
|
|
}
|
|
return
|
|
}
|
|
|
|
if (!key) {
|
|
issues.push({ path: [index, keyFieldName], message: keyRequiredMessage })
|
|
return
|
|
}
|
|
|
|
if (!value) {
|
|
issues.push({ path: [index, valueFieldName], message: valueRequiredMessage })
|
|
return
|
|
}
|
|
|
|
if (!rowIndexesByKey) return
|
|
|
|
const normalisedKey = normaliseKey(key)
|
|
if (!normalisedKey) return
|
|
|
|
rowIndexesByKey.set(normalisedKey, [...(rowIndexesByKey.get(normalisedKey) ?? []), index])
|
|
})
|
|
|
|
if (!rowIndexesByKey || !duplicateKeyMessage) return issues
|
|
|
|
rowIndexesByKey.forEach((indexes) => {
|
|
if (indexes.length < 2) return
|
|
|
|
indexes.forEach((index) => {
|
|
issues.push({
|
|
path: [index, keyFieldName],
|
|
message: duplicateKeyMessage,
|
|
})
|
|
})
|
|
})
|
|
|
|
return issues
|
|
}
|