Add rank field support to public registration forms.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
11fc7cc3ce
commit
2e1cd06097
|
|
@ -3,9 +3,15 @@ import { FormDatePicker } from './FormDatePicker';
|
||||||
import { FormTimePicker } from './FormTimePicker';
|
import { FormTimePicker } from './FormTimePicker';
|
||||||
import { FormSelectField } from './FormSelectField';
|
import { FormSelectField } from './FormSelectField';
|
||||||
import { FormCountryStateField } from './FormCountryStateField';
|
import { FormCountryStateField } from './FormCountryStateField';
|
||||||
|
import { FormRankField } from './FormRankField';
|
||||||
import { FormFieldRemarks } from './FormFieldRemarks';
|
import { FormFieldRemarks } from './FormFieldRemarks';
|
||||||
import { fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from '../../utils/formFieldNames';
|
import { fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from '../../utils/formFieldNames';
|
||||||
import { statesForCountry } from '../../data/countryStates';
|
import { statesForCountry } from '../../data/countryStates';
|
||||||
|
import {
|
||||||
|
parseRankValue,
|
||||||
|
rankValueToOrderedLabels,
|
||||||
|
validateRankValue,
|
||||||
|
} from '../../utils/rankFieldUtils';
|
||||||
|
|
||||||
/** @deprecated use fieldDataKey */
|
/** @deprecated use fieldDataKey */
|
||||||
export function dataKey(label?: string) {
|
export function dataKey(label?: string) {
|
||||||
|
|
@ -22,6 +28,14 @@ export function fieldValueToData(field: FormField, values: Record<string, unknow
|
||||||
if (!k) return {};
|
if (!k) return {};
|
||||||
const value = values[k];
|
const value = values[k];
|
||||||
if (field.type === 'checkboxes' && Array.isArray(value)) return { [k]: value };
|
if (field.type === 'checkboxes' && Array.isArray(value)) return { [k]: value };
|
||||||
|
if (field.type === 'rank') {
|
||||||
|
const ordered = rankValueToOrderedLabels(
|
||||||
|
parseRankValue(value),
|
||||||
|
Array.isArray(field.options) ? field.options.map(String) : [],
|
||||||
|
field.allowOther,
|
||||||
|
);
|
||||||
|
return { [k]: ordered };
|
||||||
|
}
|
||||||
if (field.type === 'checkbox') return { [k]: value === true || value === 'true' ? 'Yes' : '' };
|
if (field.type === 'checkbox') return { [k]: value === true || value === 'true' ? 'Yes' : '' };
|
||||||
return { [k]: value };
|
return { [k]: value };
|
||||||
}
|
}
|
||||||
|
|
@ -266,6 +280,26 @@ export function DynamicFormFields({ fields, values, fileNames, onChange, onFileN
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.type === 'rank' && (field.options?.length ?? 0) > 0) {
|
||||||
|
return (
|
||||||
|
<div key={`field-${index}-${k}`}>
|
||||||
|
<label className="form-label">
|
||||||
|
{display}
|
||||||
|
{req ? <span style={{ color: '#dc2626' }}> *</span> : null}
|
||||||
|
</label>
|
||||||
|
<FormRankField
|
||||||
|
options={field.options!.map(String)}
|
||||||
|
value={values[k]}
|
||||||
|
onChange={(v) => onChange(k, v)}
|
||||||
|
allowOther={field.allowOther}
|
||||||
|
otherLabel={field.otherLabel}
|
||||||
|
required={!!req}
|
||||||
|
/>
|
||||||
|
<FormFieldRemarks text={field.remarks} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (field.type === 'checkbox') {
|
if (field.type === 'checkbox') {
|
||||||
return (
|
return (
|
||||||
<div key={`field-${index}-${k}`}>
|
<div key={`field-${index}-${k}`}>
|
||||||
|
|
@ -350,6 +384,23 @@ export function validateFormFields(fields: FormField[], values: Record<string, u
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
if (field.type === 'section') continue;
|
if (field.type === 'section') continue;
|
||||||
const display = fieldDisplayLabel(field);
|
const display = fieldDisplayLabel(field);
|
||||||
|
|
||||||
|
if (field.type === 'rank') {
|
||||||
|
const k = fieldDataKey(field);
|
||||||
|
if (!k) continue;
|
||||||
|
const err = validateRankValue(
|
||||||
|
parseRankValue(values[k]),
|
||||||
|
Array.isArray(field.options) ? field.options.map(String) : [],
|
||||||
|
{
|
||||||
|
required: !!field.required,
|
||||||
|
allowOther: field.allowOther,
|
||||||
|
displayLabel: display,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (err) return err;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!field.required) continue;
|
if (!field.required) continue;
|
||||||
|
|
||||||
if (isCountryStateField(field)) {
|
if (isCountryStateField(field)) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import {
|
||||||
|
OTHER_RANK_KEY,
|
||||||
|
parseRankValue,
|
||||||
|
rankSlotCount,
|
||||||
|
setRankForKey,
|
||||||
|
type RankFieldValue,
|
||||||
|
} from '../../utils/rankFieldUtils';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
options: string[];
|
||||||
|
value: unknown;
|
||||||
|
onChange: (value: RankFieldValue) => 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<number> => {
|
||||||
|
const s = new Set<number>();
|
||||||
|
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 (
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: "auto", minWidth: 88 }}
|
||||||
|
value={current === "" || current == null ? "" : String(current)}
|
||||||
|
required={!!required}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value;
|
||||||
|
onChange(setRankForKey(parsed, key, raw === "" ? "" : Number(raw)));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{ranks.map((n) => (
|
||||||
|
<option key={n} value={n} disabled={taken.has(n)}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<div
|
||||||
|
key={opt}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.9rem", flex: "1 1 140px" }}>{opt}</span>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: "0.85rem" }}>
|
||||||
|
Rank
|
||||||
|
{renderSelect(opt)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{allowOther ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
style={{ flex: "1 1 160px", minWidth: 120 }}
|
||||||
|
placeholder={otherLbl}
|
||||||
|
value={parsed.otherText || ""}
|
||||||
|
required={!!required}
|
||||||
|
onChange={(e) => onChange({ ...parsed, otherText: e.target.value })}
|
||||||
|
/>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: "0.85rem" }}>
|
||||||
|
Rank
|
||||||
|
{renderSelect(OTHER_RANK_KEY)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ export interface FormField {
|
||||||
type?: string;
|
type?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
/** Dropdown only: include an Other option; submitted value is the custom text. */
|
/** Dropdown / Rank: include an Other option. */
|
||||||
allowOther?: boolean;
|
allowOther?: boolean;
|
||||||
otherLabel?: string;
|
otherLabel?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
|
|
||||||
|
|
@ -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<string, number | "">;
|
||||||
|
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<string, number | ""> = {};
|
||||||
|
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<number>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue