Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ export default async function FormPreviewPage({
dateClear: t.dateClear,
dateNow: t.dateNow,
previewNote: t.previewNote,
yes: t.yes,
no: t.no,
}}
/>
</div>
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/dashboard/[guildId]/forms/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { ShareButton } from "@/components/dashboard/share-button";
import { LocalDateTime } from "@/components/public/local-datetime";
import { requireUser } from "@/lib/auth";
import { appBaseUrl } from "@/lib/url";
import { canManageForms, getGuildForms } from "@/lib/guild";
import { canManageForms, getGuildForms, getReviewScope } from "@/lib/guild";
import { getGuildPlan } from "@/lib/plan";
import { enterpriseEnabled, stripeEnabled } from "@/lib/stripe";
import { getDict } from "@/i18n";
Expand All @@ -35,8 +35,9 @@ export default async function GuildFormsPage({
}) {
const { guildId } = await params;
const user = await requireUser(`/dashboard/${guildId}/forms`);
const scope = await getReviewScope(guildId, user.id);
const [forms, canManage, plan] = await Promise.all([
getGuildForms(guildId),
getGuildForms(guildId, scope),
canManageForms(guildId, user.id),
getGuildPlan(guildId),
]);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/f/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ export default async function PublicFormPage({
dateClear: t.dateClear,
dateNow: t.dateNow,
previewNote: t.previewNote,
yes: t.yes,
no: t.no,
}}
/>
</Shell>
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/components/form/field-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,6 @@ export type FieldValue =
| Record<string, string>
| undefined;

const YES_NO_OPTIONS = [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
];

interface FieldInputProps {
field: FormField;
value: FieldValue;
Expand All @@ -41,6 +36,7 @@ interface FieldInputProps {
slug: string;
fileLabels: FileFieldLabels;
dateLabels: DateFieldLabels;
yesNoLabels: { yes: string; no: string };
}

/** Renders the interactive control for a single (non-layout) form field. */
Expand All @@ -53,6 +49,7 @@ export function FieldInput({
slug,
fileLabels,
dateLabels,
yesNoLabels,
}: FieldInputProps) {
const id = field.id;
const options = (field.options ?? []).map((o) => ({ value: o.value, label: o.label }));
Expand Down Expand Up @@ -215,7 +212,10 @@ export function FieldInput({
return (
<RadioGroup
name={id}
options={YES_NO_OPTIONS}
options={[
{ value: "yes", label: yesNoLabels.yes },
{ value: "no", label: yesNoLabels.no },
]}
value={value === true ? "yes" : value === false ? "no" : undefined}
disabled={disabled}
onChange={(v) => onChange(v === "yes")}
Expand Down
16 changes: 5 additions & 11 deletions apps/web/src/components/form/form-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import {
computePagePath,
evaluateFormula,
isAnswerMissing,
isComputedField,
isFieldRequired,
isFieldVisible,
Expand All @@ -26,16 +27,6 @@ function isLayout(field: FormField): boolean {
return isLayoutField(field.type);
}

/** True when a required field has no meaningful answer. */
function isEmpty(value: FieldValue): boolean {
if (value === undefined || value === null || value === "") return true;
if (Array.isArray(value)) return value.length === 0;
if (value === false) return true; // unchecked consent/age_check
// Matrix answer ({ rowId: column }) with nothing picked yet.
if (typeof value === "object" && Object.keys(value).length === 0) return true;
return false;
}

export interface FormLabels {
submit: string;
submitting: string;
Expand All @@ -53,6 +44,8 @@ export interface FormLabels {
dateClear: string;
dateNow: string;
previewNote: string;
yes: string;
no: string;
}

export function FormRenderer({
Expand Down Expand Up @@ -119,7 +112,7 @@ export function FormRenderer({
for (const field of fields) {
// Layout has no value; calculated fields are derived, never entered.
if (isLayout(field) || isComputedField(field.type) || !isFieldVisible(field, answers)) continue;
if (isFieldRequired(field, answers) && isEmpty(answers[field.id])) {
if (isFieldRequired(field, answers) && isAnswerMissing(field, answers[field.id])) {
out[field.id] = labels.required;
}
}
Expand Down Expand Up @@ -254,6 +247,7 @@ export function FormRenderer({
clear: labels.signatureClear,
}}
dateLabels={{ today: labels.dateToday, clear: labels.dateClear, now: labels.dateNow }}
yesNoLabels={{ yes: labels.yes, no: labels.no }}
/>
</Field>
),
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ const en = {
signatureClear: "Clear",
next: "Next", back: "Back", step: "Step",
dateToday: "Today", dateClear: "Clear", dateNow: "Now",
yes: "Yes", no: "No",
poweredBy: "Powered by MSK Forms",
},
status: {
Expand Down Expand Up @@ -752,6 +753,7 @@ const de: Dictionary = {
signatureClear: "Löschen",
next: "Weiter", back: "Zurück", step: "Schritt",
dateToday: "Heute", dateClear: "Löschen", dateNow: "Jetzt",
yes: "Ja", no: "Nein",
poweredBy: "Bereitgestellt von MSK Forms",
},
status: {
Expand Down Expand Up @@ -1298,6 +1300,7 @@ const hu: Dictionary = {
signatureClear: "Törlés",
next: "Tovább", back: "Vissza", step: "Lépés",
dateToday: "Ma", dateClear: "Törlés", dateNow: "Most",
yes: "Igen", no: "Nem",
poweredBy: "Üzemelteti: MSK Forms",
},
status: {
Expand Down Expand Up @@ -1844,6 +1847,7 @@ const fr: Dictionary = {
signatureClear: "Effacer",
next: "Suivant", back: "Retour", step: "Étape",
dateToday: "Aujourd'hui", dateClear: "Effacer", dateNow: "Maintenant",
yes: "Oui", no: "Non",
poweredBy: "Propulsé par MSK Forms",
},
status: {
Expand Down Expand Up @@ -2391,6 +2395,7 @@ const es: Dictionary = {
signatureClear: "Limpiar",
next: "Siguiente", back: "Atrás", step: "Paso",
dateToday: "Hoy", dateClear: "Limpiar", dateNow: "Ahora",
yes: "Sí", no: "No",
poweredBy: "Con tecnología de MSK Forms",
},
status: {
Expand Down Expand Up @@ -2938,6 +2943,7 @@ const pt: Dictionary = {
signatureClear: "Limpar",
next: "Próximo", back: "Voltar", step: "Etapa",
dateToday: "Hoje", dateClear: "Limpar", dateNow: "Agora",
yes: "Sim", no: "Não",
poweredBy: "Desenvolvido com MSK Forms",
},
status: {
Expand Down Expand Up @@ -3485,6 +3491,7 @@ const pl: Dictionary = {
signatureClear: "Wyczyść",
next: "Dalej", back: "Wstecz", step: "Krok",
dateToday: "Dzisiaj", dateClear: "Wyczyść", dateNow: "Teraz",
yes: "Tak", no: "Nie",
poweredBy: "Powered by MSK Forms",
},
status: {
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/lib/guild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,19 @@ export async function requireGuildMembership(guildId: string, userId: string) {
return { ...membership.guild, role: membership.role };
}

/** Forms for a guild, with submission counts, for the "Manage Forms" tab. */
export async function getGuildForms(guildId: string) {
/**
* Forms for a guild, with submission counts, for the "Manage Forms" tab, scoped
* to what the viewer may review. Managers/guild-wide reviewers (`scope.all`) see
* every form; a per-form reviewer sees only their granted forms (empty grants →
* no forms). Keeps the list consistent with the per-form export/review gating.
*/
export async function getGuildForms(guildId: string, scope: ReviewScope) {
if (!scope.all && scope.formIds.length === 0) return [];
return prisma.form.findMany({
where: { guildId },
where: {
guildId,
...(scope.all ? {} : { id: { in: scope.formIds } }),
},
orderBy: { updatedAt: "desc" },
select: {
id: true,
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
"version": "0.1.0",
"private": true,
"description": "MSK Forms — a modern form & application platform with status feedback and a Discord bot",
"author": "Moritz Kohm <moritz.kohm@gmail.com> (https://msk-scripts.de)",
"author": "Moritz Kohm <info@musiker15.de> (https://www.msk-scripts.de)",
"license": "SEE LICENSE IN LICENSE",
"type": "module",
"engines": {
"node": ">=22",
"pnpm": ">=9"
},
"packageManager": "pnpm@11.2.2+sha512.36e6621fad506178936455e70247b8808ef4ec25797a9f437a93281a020484e2607f6a469a22e982987c3dbb8866e3071514ab10a4a1749e06edcd1ec118436f",
"packageManager": "pnpm@11.10.0+sha512.0b7f8b98060031904c017e3a41eb187a16d40eeb829b95c4f8cb03681761fc4ab53dd219115b9b447f4dce1a05a214764461e7d3703392a9f32f9511ce8c86c8",
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
Expand Down
19 changes: 19 additions & 0 deletions packages/shared/src/conditions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ export function isBlankAnswer(value: unknown): boolean {
return false;
}

/**
* Field-aware "is this required answer missing?" check (shared client+server).
* Most types defer to `isBlankAnswer`, but two need special handling:
* - `yes_no`: an explicit "No" is `false`, which is a valid answer — unlike
* consent/age_check where an unchecked box (`false`) means no agreement.
* - `matrix`: every row must have a picked column.
*/
export function isAnswerMissing(field: FormField, value: unknown): boolean {
if (field.type === "yes_no") return value !== true && value !== false;
if (field.type === "matrix") {
const rows = field.rows ?? [];
return (
rows.length === 0 ||
rows.some((row) => isBlankAnswer((value as Record<string, unknown>)?.[row.id]))
);
}
return isBlankAnswer(value);
}

/** Evaluate a single rule's `when` clause against the current answers. */
export function evaluateCondition(when: ConditionRule["when"], answers: AnswerMap): boolean {
const actual = answers[when.field];
Expand Down
13 changes: 13 additions & 0 deletions packages/shared/src/form-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ describe("buildAnswerSchema", () => {
expect(answerSchema.safeParse({ age: 30 }).success).toBe(false); // name missing
});

it("accepts an explicit No (false) for a required yes_no field", () => {
const s = buildAnswerSchema(specWith({ id: "yn", type: "yes_no", validation: { required: true } }));
expect(s.safeParse({ yn: false }).success).toBe(true); // "No" is a valid answer
expect(s.safeParse({ yn: true }).success).toBe(true);
expect(s.safeParse({}).success).toBe(false); // unanswered
});

it("requires a checked box for a required consent field", () => {
const s = buildAnswerSchema(specWith({ id: "c", type: "consent", validation: { required: true } }));
expect(s.safeParse({ c: false }).success).toBe(false); // unchecked = no agreement
expect(s.safeParse({ c: true }).success).toBe(true);
});

it("rejects an empty string for a required text field", () => {
const s = buildAnswerSchema(specWith({ id: "x", type: "short_text", validation: { required: true } }));
expect(s.safeParse({ x: "" }).success).toBe(false);
Expand Down
11 changes: 2 additions & 9 deletions packages/shared/src/form-spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";

import { isBlankAnswer, isFieldRequired, isFieldVisible } from "./conditions";
import { isAnswerMissing, isFieldRequired, isFieldVisible } from "./conditions";

/**
* Form spec — the JSON definition of a form (concept §6–§8).
Expand Down Expand Up @@ -300,14 +300,7 @@ export function buildAnswerSchema(spec: FormSpec): z.ZodTypeAny {
for (const field of fields) {
if (!isFieldVisible(field, answers) || !isFieldRequired(field, answers)) continue;

const value = answers[field.id];
const missing =
field.type === "matrix"
? (field.rows ?? []).length === 0 ||
(field.rows ?? []).some((row) => isBlankAnswer((value as Record<string, unknown>)?.[row.id]))
: isBlankAnswer(value);

if (missing) {
if (isAnswerMissing(field, answers[field.id])) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [field.id], message: "Required." });
}
}
Expand Down