From 2e1cd06097bdd5a791a02a4e7dad380c590c87de Mon Sep 17 00:00:00 2001 From: Ravi Gulhane <> Date: Wed, 30 Sep 2026 09:39:46 +0530 Subject: [PATCH] Add rank field support to public registration forms. Co-authored-by: Cursor --- src/components/forms/DynamicFormFields.tsx | 51 +++++++++ src/components/forms/FormRankField.tsx | 110 ++++++++++++++++++ src/types/index.ts | 2 +- src/utils/rankFieldUtils.ts | 123 +++++++++++++++++++++ 4 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 src/components/forms/FormRankField.tsx create mode 100644 src/utils/rankFieldUtils.ts diff --git a/src/components/forms/DynamicFormFields.tsx b/src/components/forms/DynamicFormFields.tsx index ad8b83a..df5c742 100644 --- a/src/components/forms/DynamicFormFields.tsx +++ b/src/components/forms/DynamicFormFields.tsx @@ -3,9 +3,15 @@ import { FormDatePicker } from './FormDatePicker'; import { FormTimePicker } from './FormTimePicker'; import { FormSelectField } from './FormSelectField'; import { FormCountryStateField } from './FormCountryStateField'; +import { FormRankField } from './FormRankField'; import { FormFieldRemarks } from './FormFieldRemarks'; import { fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from '../../utils/formFieldNames'; import { statesForCountry } from '../../data/countryStates'; +import { + parseRankValue, + rankValueToOrderedLabels, + validateRankValue, +} from '../../utils/rankFieldUtils'; /** @deprecated use fieldDataKey */ export function dataKey(label?: string) { @@ -22,6 +28,14 @@ export function fieldValueToData(field: FormField, values: Record 0) { + return ( +
+ + onChange(k, v)} + allowOther={field.allowOther} + otherLabel={field.otherLabel} + required={!!req} + /> + +
+ ); + } + if (field.type === 'checkbox') { return (
@@ -350,6 +384,23 @@ export function validateFormFields(fields: FormField[], values: Record void; + allowOther?: boolean; + otherLabel?: string; + required?: boolean; +}; + +export function FormRankField({ + options, + value, + onChange, + allowOther, + otherLabel, + required, +}: Props) { + const parsed = parseRankValue(value); + const slots = rankSlotCount(options, allowOther); + const ranks = Array.from({ length: slots }, (_, i) => i + 1); + const otherLbl = (otherLabel || "Other").trim() || "Other"; + + const takenByOthers = (exceptKey: string): Set => { + const s = new Set(); + for (const [k, v] of Object.entries(parsed.ranks)) { + if (k === exceptKey) continue; + if (typeof v === "number") s.add(v); + } + return s; + }; + + const renderSelect = (key: string) => { + const current = parsed.ranks[key]; + const taken = takenByOthers(key); + return ( + + ); + }; + + return ( +
+ {options.map((opt) => ( +
+ {opt} + +
+ ))} + {allowOther ? ( +
+ onChange({ ...parsed, otherText: e.target.value })} + /> + +
+ ) : null} +
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 0e732d2..d0d8213 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,7 +5,7 @@ export interface FormField { type?: string; required?: boolean; options?: string[]; - /** Dropdown only: include an Other option; submitted value is the custom text. */ + /** Dropdown / Rank: include an Other option. */ allowOther?: boolean; otherLabel?: string; placeholder?: string; diff --git a/src/utils/rankFieldUtils.ts b/src/utils/rankFieldUtils.ts new file mode 100644 index 0000000..319b4c5 --- /dev/null +++ b/src/utils/rankFieldUtils.ts @@ -0,0 +1,123 @@ +/** In-form state for a Rank field before submit. */ +export type RankFieldValue = { + /** option label -> rank number, or empty string if unset. Other uses OTHER_RANK_KEY. */ + ranks: Record; + otherText?: string; +}; + +export const OTHER_RANK_KEY = "__other__"; + +export function emptyRankValue(): RankFieldValue { + return { ranks: {}, otherText: "" }; +} + +export function parseRankValue(raw: unknown): RankFieldValue { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return emptyRankValue(); + const o = raw as RankFieldValue; + const ranks: Record = {}; + if (o.ranks && typeof o.ranks === "object") { + for (const [k, v] of Object.entries(o.ranks)) { + if (v === "" || v == null) ranks[k] = ""; + else { + const n = Number(v); + ranks[k] = Number.isFinite(n) && n > 0 ? n : ""; + } + } + } + return { ranks, otherText: o.otherText != null ? String(o.otherText) : "" }; +} + +export function rankSlotCount(options: string[], allowOther?: boolean): number { + const n = Array.isArray(options) ? options.length : 0; + return allowOther ? n + 1 : n; +} + +/** Ordered labels for submit (rank ascending). Empty if nothing ranked. */ +export function rankValueToOrderedLabels( + value: RankFieldValue, + options: string[], + allowOther?: boolean, +): string[] { + const items: { label: string; rank: number }[] = []; + for (const opt of options) { + const r = value.ranks[opt]; + if (typeof r === "number" && r > 0) items.push({ label: opt, rank: r }); + } + if (allowOther) { + const text = String(value.otherText || "").trim(); + const r = value.ranks[OTHER_RANK_KEY]; + if (text && typeof r === "number" && r > 0) items.push({ label: text, rank: r }); + } + items.sort((a, b) => a.rank - b.rank); + return items.map((i) => i.label); +} + +/** + * Returns error message or null. + * - required: full unique ranking 1…N (Other text+rank if allowOther). + * - optional: empty OK; if any rank/other started, must complete fully. + */ +export function validateRankValue( + value: RankFieldValue, + options: string[], + opts: { required?: boolean; allowOther?: boolean; displayLabel?: string }, +): string | null { + const label = opts.displayLabel || "This field"; + const allowOther = !!opts.allowOther; + const slots = rankSlotCount(options, allowOther); + if (slots < 1) return null; + + const otherText = String(value.otherText || "").trim(); + const hasAny = + options.some((o) => value.ranks[o] !== "" && value.ranks[o] != null) || + (allowOther && (otherText !== "" || (value.ranks[OTHER_RANK_KEY] !== "" && value.ranks[OTHER_RANK_KEY] != null))); + + if (!opts.required && !hasAny) return null; + + const used = new Set(); + for (const opt of options) { + const r = value.ranks[opt]; + if (r === "" || r == null) { + return `“${label}” requires a rank for every option.`; + } + if (typeof r !== "number" || r < 1 || r > slots) { + return `“${label}” has an invalid rank.`; + } + if (used.has(r)) return `“${label}” ranks must be unique.`; + used.add(r); + } + + if (allowOther) { + if (!otherText) return `“${label}” requires Other text when Other is enabled.`; + const r = value.ranks[OTHER_RANK_KEY]; + if (r === "" || r == null) return `“${label}” requires a rank for Other.`; + if (typeof r !== "number" || r < 1 || r > slots) { + return `“${label}” has an invalid Other rank.`; + } + if (used.has(r)) return `“${label}” ranks must be unique.`; + used.add(r); + } + + if (used.size !== slots) { + return `“${label}” requires ranks 1 through ${slots}.`; + } + return null; +} + +export function setRankForKey( + value: RankFieldValue, + key: string, + nextRank: number | "", +): RankFieldValue { + const ranks = { ...value.ranks }; + if (nextRank === "" || nextRank == null) { + ranks[key] = ""; + return { ...value, ranks }; + } + // Clear the same rank from any other key + for (const [k, v] of Object.entries(ranks)) { + if (k !== key && v === nextRank) ranks[k] = ""; + } + ranks[key] = nextRank; + return { ...value, ranks }; +}