New Changes
This commit is contained in:
parent
ca5bce7134
commit
3bf7c84a9e
|
|
@ -3,6 +3,15 @@ import { Star } from "lucide-react";
|
|||
import type { VisitorPublicFeedbackForm, VisitorPublicFeedbackFormField } from "../lib/api";
|
||||
import { postPublicFormSubmit } from "../lib/api";
|
||||
import { getAttendeeParticipant } from "../lib/attendeeAuth";
|
||||
import {
|
||||
OTHER_RANK_KEY,
|
||||
parseRankValue,
|
||||
rankSlotCount,
|
||||
rankValueToOrderedLabels,
|
||||
setRankForKey,
|
||||
validateRankValue,
|
||||
type RankFieldValue,
|
||||
} from "../lib/rankFieldUtils";
|
||||
|
||||
function isStarRatingField(f: VisitorPublicFeedbackFormField): boolean {
|
||||
const t = (f.type || "").toLowerCase().trim();
|
||||
|
|
@ -44,9 +53,23 @@ export function DynamicFeedbackForm({
|
|||
const validate = (): boolean => {
|
||||
for (const f of fields) {
|
||||
const key = String(f.label).trim();
|
||||
if (!f.required) continue;
|
||||
const raw = values[key];
|
||||
if ((f.type || "").toLowerCase() === "checkboxes") {
|
||||
const t = (f.type || "").toLowerCase();
|
||||
if (t === "rank") {
|
||||
const msg = validateRankValue(
|
||||
parseRankValue(raw),
|
||||
(f.options || []).map((o) => String(o).trim()).filter(Boolean),
|
||||
{
|
||||
required: !!f.required,
|
||||
allowOther: f.allowOther,
|
||||
displayLabel: key,
|
||||
},
|
||||
);
|
||||
if (msg) return false;
|
||||
continue;
|
||||
}
|
||||
if (!f.required) continue;
|
||||
if (t === "checkboxes") {
|
||||
if (!Array.isArray(raw) || raw.length === 0) return false;
|
||||
} else if (isStarRatingField(f)) {
|
||||
if (!starValueValid(raw)) return false;
|
||||
|
|
@ -67,8 +90,17 @@ export function DynamicFeedbackForm({
|
|||
const data: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
const key = String(f.label).trim();
|
||||
const t = (f.type || "").toLowerCase();
|
||||
if (t === "rank") {
|
||||
data[key] = rankValueToOrderedLabels(
|
||||
parseRankValue(values[key]),
|
||||
(f.options || []).map((o) => String(o).trim()).filter(Boolean),
|
||||
f.allowOther,
|
||||
);
|
||||
} else {
|
||||
data[key] = values[key] ?? "";
|
||||
}
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const participant = getAttendeeParticipant();
|
||||
|
|
@ -265,6 +297,78 @@ function FieldInput({
|
|||
);
|
||||
}
|
||||
|
||||
if (t === "rank" && opts.length > 0) {
|
||||
const parsed = parseRankValue(value);
|
||||
const slots = rankSlotCount(opts, f.allowOther);
|
||||
const ranks = Array.from({ length: slots }, (_, i) => i + 1);
|
||||
const otherLbl = (f.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
|
||||
value={current === "" || current == null ? "" : String(current)}
|
||||
required={!!f.required}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
onChange(label, setRankForKey(parsed, key, raw === "" ? "" : Number(raw)));
|
||||
}}
|
||||
className={`${baseInput} w-auto min-w-[5.5rem]`}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{ranks.map((n) => (
|
||||
<option key={n} value={n} disabled={taken.has(n)}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm font-semibold text-[var(--theme-card-text-color)]">
|
||||
{label}
|
||||
{f.required ? <span className="text-red-600"> *</span> : null}
|
||||
</legend>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{opts.map((o) => (
|
||||
<div key={o} className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm text-[var(--theme-card-text-color)]">{o}</span>
|
||||
<label className="flex items-center gap-2 text-xs text-[var(--theme-card-text-color)]">
|
||||
Rank
|
||||
{renderSelect(o)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{f.allowOther ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={otherLbl}
|
||||
value={parsed.otherText || ""}
|
||||
required={!!f.required}
|
||||
onChange={(e) => onChange(label, { ...parsed, otherText: e.target.value } satisfies RankFieldValue)}
|
||||
className={`${baseInput} min-w-[8rem] flex-1`}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-[var(--theme-card-text-color)]">
|
||||
Rank
|
||||
{renderSelect(OTHER_RANK_KEY)}
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
if (t === "checkbox") {
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--theme-card-text-color)]">
|
||||
|
|
|
|||
|
|
@ -290,6 +290,8 @@ export type VisitorPublicFeedbackFormField = {
|
|||
options?: string[];
|
||||
placeholder?: string;
|
||||
remarks?: string;
|
||||
allowOther?: boolean;
|
||||
otherLabel?: string;
|
||||
};
|
||||
|
||||
export type VisitorPublicFeedbackForm = {
|
||||
|
|
|
|||
|
|
@ -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