Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
7 changes: 7 additions & 0 deletions scripts/mutation/equivalent-mutants.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
# <file>` — exit 0 means lint did not kill it.

src/ui/templates/components/data-table.tsx:85:43 0 → -1 # For the only changed case, an empty array, both branches render the same empty tbody; non-empty arrays make both comparisons false
src/shared/form-data.ts:16:33 ?? → || # trim returns a string, and its only falsy value is the empty string supplied by the fallback
src/shared/form-data.ts:35:23 !== → != # parsePositiveIntId returns number|null and cannot return undefined, so strict and loose inequality agree when comparing it with null
src/shared/forms/validation.ts:51:8 === → == # value is string|number|null and cannot be undefined, so strict and loose equality agree when comparing it with null
src/shared/forms/validation.ts:69:10 === → == # readSubmittedFieldValue returns string|null and cannot return undefined, so strict and loose equality agree when comparing it with null
src/shared/forms/validation.ts:101:46 = → += # this assignment runs only when trimmed is the empty string, so replacing it and appending to it produce the same defaultValue
src/shared/forms/validation.ts:105:16 !== → != # validateFieldText returns string|null and cannot return undefined, so strict and loose inequality agree when comparing it with null
src/features/admin/settings-listing-defaults.ts:53:15 === → == # parseNonNegativeInt returns number|null and cannot return undefined, so strict and loose equality agree when comparing it with null

src/features/admin/entity-pages.ts:275:24 ?? → || # opts.query is a URLSearchParams object when present, so it is always truthy
src/features/admin/site-content-page.ts:87:38 ?? → || # extraTabs is an array when present, and arrays are always truthy
Expand Down
14 changes: 9 additions & 5 deletions src/features/admin/aggregate-recalculation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* jscpd:ignore-start */

import { AUTH_FORM, requireSessionOr, withAuth } from "#routes/auth.ts";
import { htmlResponse, redirect } from "#routes/response.ts";
import { getFlash } from "#shared/flash-context.ts";
Expand Down Expand Up @@ -35,10 +36,13 @@ export const parseEditableAggregateForm = <TValues, TInput>(

export const selectedRecalculationFields = <T extends string>(
form: FormParams,
allowed: readonly T[],
allowed: readonly [T, ...T[]],
): T[] => {
const selected = new Set(form.getAll(RECALCULATE_FIELD_NAME));
return allowed.filter((field) => selected.has(field));
const selection = form.getRepeatedPicklist(RECALCULATE_FIELD_NAME, allowed);
if (!selection.ok) {
throw new Error(`Invalid recalculation field: ${selection.error}`);
Comment on lines +42 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return a validation response for bad recalc fields

When an authenticated listing/modifier/answer recalculate POST contains a stale or tampered recalculate_fields value, this exception bubbles through runRecalculatePost/withAuth without being converted to a response, so the operator gets a 500 instead of the same 400 re-render used for the empty-selection validation path. Treat the unknown choice as a form validation error at this boundary so bad request data fails closed without crashing the route.

Useful? React with 👍 / 👎.

}
return selection.value;
};

/**
Expand All @@ -51,7 +55,7 @@ export const selectedRecalculationFields = <T extends string>(
*/
export const runRecalculatePost = async <T extends string>(config: {
form: FormParams;
fields: readonly T[];
fields: readonly [T, ...T[]];
/** Re-render the recalculate page with the "choose a field" error. */
renderChoose: ResponseHandler;
reset: (selected: T[]) => Promise<unknown>;
Expand Down Expand Up @@ -108,7 +112,7 @@ export const createRecalculateHandlers = <T, F extends string, ID>(config: {
error?: string,
success?: string,
) => Promise<Response>;
fields: readonly F[];
fields: readonly [F, ...F[]];
entityId: (entity: T) => number;
reset: (entityId: number, selected: F[]) => Promise<unknown>;
log: (entity: T) => Promise<unknown>;
Expand Down
4 changes: 2 additions & 2 deletions src/features/admin/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ const handleAdminGet = (request: Request): Promise<Response> =>
newestAttendees,
successMessage,
stats,
settings.listingColumnOrder,
settings.listingColumnLayout,
activeType,
holidays,
unbookableIds,
Expand All @@ -170,7 +170,7 @@ const handleAdminListingsGet: TypedRouteHandler<"GET /admin/listings"> =
return adminListingsPage(
listings,
session,
settings.listingColumnOrder,
settings.listingColumnLayout,
Comment thread
stefan-burke marked this conversation as resolved.
Outdated
await loadListingAttributeFilterContext(request, listings),
);
});
Expand Down
36 changes: 19 additions & 17 deletions src/features/admin/settings-general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ import {
settingsHandler,
settingsToggle,
} from "#routes/admin/settings-helpers.ts";
import { validateColumnTemplate } from "#shared/column-order.ts";
import { ATTENDEE_TABLE_COLUMNS } from "#shared/columns/attendee-columns.ts";
import { LISTING_TABLE_COLUMNS } from "#shared/columns/listing-columns.ts";
import { COLUMN_LAYOUTS } from "#shared/column-order.ts";
import { clearSessionCookie } from "#shared/cookies.ts";
import { logActivity } from "#shared/db/activityLog.ts";
import { settings } from "#shared/db/settings.ts";
Expand Down Expand Up @@ -194,24 +192,28 @@ export const handleBookingFeePost = settingsHandler({
* Build a column-order settings handler for the listing or attendee table.
* Handles POST /admin/settings/{listing,attendee}-column-order - owner only
*/
const columnOrderHandler = (kind: "listing" | "attendee") => {
const columns =
kind === "listing" ? LISTING_TABLE_COLUMNS : ATTENDEE_TABLE_COLUMNS;
const update =
kind === "listing"
? settings.update.listingColumnOrder
: settings.update.attendeeColumnOrder;
const label =
kind === "listing" ? "Listing column order" : "Attendee column order";
const COLUMN_ORDER_SETTINGS = {
attendee: {
label: "Attendee column order",
update: settings.update.attendeeColumnOrder,
},
listing: {
label: "Listing column order",
update: settings.update.listingColumnOrder,
},
};

type ConfigurableColumnLayoutKind = keyof typeof COLUMN_ORDER_SETTINGS;

const columnOrderHandler = (kind: ConfigurableColumnLayoutKind) => {
const config = COLUMN_ORDER_SETTINGS[kind];
return settingsHandler({
advanced: true,
extract: (form) => form.getString("column_order").trim(),
formId: `settings-${kind}-column-order`,
label,
save: (value) => update(value),
// Empty value clears to the default column order
validate: (value) =>
value ? validateColumnTemplate(value, Object.keys(columns)) : null,
label: config.label,
save: config.update,
validate: COLUMN_LAYOUTS[kind].validate,
});
};

Expand Down
21 changes: 11 additions & 10 deletions src/features/admin/settings-listing-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,22 +80,23 @@ const parseUrlField = (
/** Parse the bookable-days default: only set when its enable box is ticked, with
* at least one valid day (in canonical order). */
const parseDaysField = (form: FormParams): FieldParse => {
if (form.getString("default_bookable_days_enabled") !== "1") return {};
const submittedDays = form.getAll("default_bookable_days");
const invalidDay = submittedDays.find(
(submittedDay) =>
!VALID_DAY_NAMES.some((validDay) => validDay === submittedDay),
if (!form.getFlag("default_bookable_days_enabled")) return {};
const selection = form.getRepeatedPicklist(
"default_bookable_days",
VALID_DAY_NAMES,
);
if (invalidDay !== undefined)
if (!selection.ok) {
return {
error: t("fields.validation.invalid_day", {
day: invalidDay,
day: selection.error,
valid: VALID_DAY_NAMES.join(", "),
}),
};
const days = VALID_DAY_NAMES.filter((day) => submittedDays.includes(day));
if (days.length === 0) return { error: t("listing_defaults.days_required") };
return { value: days };
}
if (selection.value.length === 0) {
return { error: t("listing_defaults.days_required") };
}
return { value: selection.value };
};

/** Per-kind parser. The `Record` is keyed by {@link ListingDefaultKind}, so a
Expand Down
161 changes: 116 additions & 45 deletions src/shared/column-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
* {{price | currency}} → "£25.00"
*/

import * as v from "valibot";
import { createBaseLiquidEngine } from "#shared/liquid-engine.ts";
import { requireSuccess } from "#shared/result.ts";
import type { Result } from "#shared/result.ts";

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -47,6 +48,11 @@ export type ColumnGenerators<TRow, TOpts = unknown> = Record<
ColumnDef<TRow, TOpts>
>;

export type ColumnLayout<TColumn extends string> = {
readonly columnKeys: readonly TColumn[];
readonly filters: ReadonlyMap<TColumn, string>;
};

// ---------------------------------------------------------------------------
// Liquid engine — single instance for rendering filtered values
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -79,59 +85,49 @@ const parseTagBody = (
* Parse a column-order template and extract the ordered list of column keys
* plus any per-column Liquid filter expressions.
*
* Validation is done by checking extracted keys against the valid set.
* No Liquid engine is needed for validation — just regex + Set.has().
* Validation is done by parsing extracted keys through the column schema.
* No Liquid engine is needed for validation.
*/
const parseColumnTemplate = (
type ParsedLayout<T extends string> = Result<{
columns: T[];
filters: Map<T, string>;
}>;

const parseColumnTemplate = <T extends string>(
template: string,
validKeys: readonly string[],
):
| { ok: true; columns: string[]; filters: Map<string, string> }
| {
ok: false;
error: string;
} => {
const validKeySet = new Set(validKeys);
const columns: string[] = [];
const filters = new Map<string, string>();
options: readonly T[],
): ParsedLayout<T> => {
const columns: T[] = [];
const filters = new Map<T, string>();
const seen = new Set<string>();

for (const match of template.matchAll(LIQUID_TAG_RE)) {
const { key, filter } = parseTagBody(match[1]!);
if (!validKeySet.has(key)) {
const option = options.find((value) => value === key);
if (option === undefined) {
return {
error: `Unknown column "${key}". Available columns: ${validKeys.join(
", ",
)}`,
error: `Unknown column "${key}". Available columns: ${options.join(", ")}`,
ok: false,
};
}
if (!seen.has(key)) {
seen.add(key);
columns.push(key);
if (filter) filters.set(key, filter);
if (!seen.has(option)) {
seen.add(option);
columns.push(option);
if (filter) filters.set(option, filter);
}
}

if (columns.length === 0) {
return { error: "Template must include at least one column", ok: false };
}

return { columns, filters, ok: true };
return { ok: true, value: { columns, filters } };
};

/**
* Validate a column-order template without extracting columns.
* Returns null if valid, or an error message string if invalid.
*/
export const validateColumnTemplate = (
template: string,
validKeys: readonly string[],
): string | null => {
const result = parseColumnTemplate(template, validKeys);
return result.ok ? null : result.error;
};

/**
* Build a default template from an ordered list of column keys.
* Produces e.g. "{{name}}, {{description}}, {{actions}}"
Expand All @@ -143,19 +139,93 @@ export const buildDefaultTemplate = (keys: readonly string[]): string =>
* Parse a template and return column keys and filters.
* Shared by all listing/attendee table renderers.
*/
export const resolveColumnLayout = (
template: string,
validKeys: readonly string[],
defaultOrder: readonly string[],
): { columnKeys: string[]; filters: Map<string, string> } => {
if (!template) {
return { columnKeys: [...defaultOrder], filters: new Map() };
}
const result = parseColumnTemplate(template, validKeys);
requireSuccess(result);
return { columnKeys: result.columns, filters: result.filters };
const defineColumnLayout = <
const TDefault extends readonly [string, ...string[]],
const TExtra extends readonly string[],
>(
defaultOrder: TDefault,
extra: TExtra,
) => {
const [first, ...rest] = defaultOrder;
const schema = v.picklist([first, ...rest, ...extra]);
const options: readonly (TDefault[number] | TExtra[number])[] = [
...defaultOrder,
...extra,
];
const defaultLayout: ColumnLayout<TDefault[number] | TExtra[number]> = {
columnKeys: defaultOrder,
filters: new Map(),
};
return {
defaultLayout,
defaultOrder,
defaultTemplate: buildDefaultTemplate(defaultOrder),
options,
parse(template: string): ColumnLayout<TDefault[number] | TExtra[number]> {
if (!template) return defaultLayout;
const result = parseColumnTemplate(template, options);
if (!result.ok) throw new Error(result.error);
return {
columnKeys: result.value.columns,
filters: result.value.filters,
};
},
schema,
validate(template: string): string | null {
if (!template) return null;
const result = parseColumnTemplate(template, options);
return result.ok ? null : result.error;
},
};
};

export const COLUMN_LAYOUTS = {
attendee: defineColumnLayout(
[
"status",
"date",
"name",
"listings",
"email",
"phone",
"address",
"special_instructions",
"answers",
"qty",
"ticket",
"registered",
],
[],
),
listing: defineColumnLayout(
[
"name",
"description",
"status",
"attendees",
"tickets",
"revenue",
"cost",
"profit",
"created",
],
["date", "location", "price", "renewal"],
),
};

export type ColumnLayoutKind = keyof typeof COLUMN_LAYOUTS;
export type ListingColumn =
(typeof COLUMN_LAYOUTS)["listing"]["options"][number];
export type AttendeeColumn =
(typeof COLUMN_LAYOUTS)["attendee"]["options"][number];

export type ListingColumnLayout = ReturnType<
(typeof COLUMN_LAYOUTS)["listing"]["parse"]
>;
export type AttendeeColumnLayout = ReturnType<
(typeof COLUMN_LAYOUTS)["attendee"]["parse"]
>;

/** Matches only when `date` is the first filter applied to the raw value */
const FIRST_FILTER_IS_DATE_RE = /^[^|]*\|\s*date\b/;

Expand Down Expand Up @@ -196,15 +266,16 @@ export const renderFilteredValue = (
*/
export const renderCells = <TRow, TOpts>(
row: TRow,
columnKeys: string[],
columnKeys: readonly string[],
generators: ColumnGenerators<TRow, TOpts>,
opts: TOpts,
filters: Map<string, string>,
filters: ReadonlyMap<string, string>,
escapeHtml: (s: string) => string,
): string => {
const cells: string[] = [];
for (const key of columnKeys) {
const col = generators[key]!;
const col = generators[key];
if (col === undefined) throw new Error(`Unknown column: ${key}`);
const filterExpr = filters.get(key);
const useFilter = filterExpr && col.rawValue;
const content = useFilter
Expand Down
Loading
Loading