From 1aa4642c930e749a4b90f98e1790dbd7ad10a9a1 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 10 Jul 2026 13:38:17 +0100 Subject: [PATCH 01/11] Add listing attributes --- src/features/admin/attributes.ts | 334 +++++++++++++++ src/features/admin/dashboard.ts | 64 ++- src/features/admin/index.ts | 2 + src/features/admin/listing-choice-post.ts | 37 ++ src/features/admin/listing-page-data.ts | 54 ++- src/features/admin/listing-page.ts | 12 + src/features/admin/questions.ts | 23 +- src/features/public/pages.ts | 14 +- src/features/public/ticket-submit.ts | 41 +- src/features/public/types.ts | 3 + src/locales/en/attributes.json | 33 ++ src/locales/en/entity-pages.json | 1 + src/locales/en/index.ts | 2 + src/locales/en/terms.json | 2 + src/shared/admin-pages.ts | 1 + src/shared/db/attributes.ts | 317 +++++++++++++++ src/shared/db/listings.ts | 8 +- src/shared/db/migrations.ts | 3 + .../2026-07-09_listing_attributes.ts | 19 + src/shared/db/migrations/schema.ts | 56 ++- src/shared/listing-attribute-filter.ts | 128 ++++++ src/ui/static/style.scss | 26 +- src/ui/templates/admin/attributes.tsx | 314 +++++++++++++++ src/ui/templates/admin/dashboard.tsx | 43 +- .../admin/listing-attribute-filters.ts | 80 ++++ .../templates/admin/listing-panel-frame.tsx | 40 ++ src/ui/templates/admin/questions.tsx | 148 +++---- .../components/aggregate-sections.tsx | 23 ++ src/ui/templates/components/reorder-table.tsx | 82 ++++ src/ui/templates/public/homepage.tsx | 15 +- src/ui/templates/public/listing-attributes.ts | 19 + src/ui/templates/public/reservations.tsx | 16 + test/lib/db/migration-schema-guard.test.ts | 3 +- test/lib/server-attributes.test.ts | 380 ++++++++++++++++++ test/lib/server-listings-filter.test.ts | 113 ++++++ test/shared/db/attributes.test.ts | 177 ++++++++ test/shared/db/listings/delete.test.ts | 19 + test/shared/listing-attribute-filter.test.ts | 144 +++++++ test/test-utils.ts | 1 + test/test-utils/db-helpers/attributes.ts | 49 +++ .../admin/listing-attribute-filters.test.ts | 62 +++ 41 files changed, 2740 insertions(+), 168 deletions(-) create mode 100644 src/features/admin/attributes.ts create mode 100644 src/features/admin/listing-choice-post.ts create mode 100644 src/locales/en/attributes.json create mode 100644 src/shared/db/attributes.ts create mode 100644 src/shared/db/migrations/2026-07-09_listing_attributes.ts create mode 100644 src/shared/listing-attribute-filter.ts create mode 100644 src/ui/templates/admin/attributes.tsx create mode 100644 src/ui/templates/admin/listing-attribute-filters.ts create mode 100644 src/ui/templates/admin/listing-panel-frame.tsx create mode 100644 src/ui/templates/components/reorder-table.tsx create mode 100644 src/ui/templates/public/listing-attributes.ts create mode 100644 test/lib/server-attributes.test.ts create mode 100644 test/shared/db/attributes.test.ts create mode 100644 test/shared/listing-attribute-filter.test.ts create mode 100644 test/test-utils/db-helpers/attributes.ts create mode 100644 test/ui/templates/admin/listing-attribute-filters.test.ts diff --git a/src/features/admin/attributes.ts b/src/features/admin/attributes.ts new file mode 100644 index 0000000000..2bbf351314 --- /dev/null +++ b/src/features/admin/attributes.ts @@ -0,0 +1,334 @@ +/** + * Admin routes for listing attributes. + */ + +/* jscpd:ignore-start */ +import { + createConfirmedHandlers, + createVerifiedFormRoute, +} from "#routes/admin/confirmation.ts"; +import { OWNER_FORM, ownerPage, requireOwnerOr } from "#routes/auth.ts"; +import { ownerGetById } from "#routes/entity.ts"; +import { + errorRedirect, + htmlResponse, + notFoundResponse, + redirect, +} from "#routes/response.ts"; +import { defineRoutes } from "#routes/router.ts"; +import { + type AuthedHandlerArgs, + createAuthedFormRoute, + createAuthedHandler, +} from "#shared/app-forms.ts"; +import { logActivity } from "#shared/db/activityLog.ts"; +import { + type AttributeOption, + type AttributeWithOptions, + assignNextAttributeSortOrder, + attributeOptionsTable, + attributesTable, + deleteAttribute, + deleteAttributeOption, + getAllAttributesWithOptions, + getAttributeWithOptions, + getNextAttributeOptionSortOrder, + pruneInvalidAttributeOptionIds, + setListingAttributeOptions, + swapAttributeOptionOrder, + swapAttributeOrder, +} from "#shared/db/attributes.ts"; +import { getFlash } from "#shared/flash-context.ts"; +import { defineForm } from "#shared/forms.tsx"; +import { + adminAttributeDeletePage, + adminAttributeOptionDeletePage, + adminAttributePage, + adminAttributesPage, + attributeNameFlat, +} from "#templates/admin/attributes.tsx"; +import { createListingChoicePost } from "./listing-choice-post.ts"; +/* jscpd:ignore-end */ + +export const attributeNameForm = defineForm({ + fields: [ + { + label: "Attribute name", + name: "name", + placeholder: "e.g. Difficulty", + required: true, + type: "text", + }, + ] as const, + id: "attributeName", +}); + +export const attributeOptionForm = defineForm({ + fields: [ + { + label: "Option text", + name: "text", + placeholder: "e.g. Beginner", + required: true, + type: "text", + }, + ] as const, + id: "attributeOption", +}); + +const handleAttributesGet = ownerPage(async (session) => { + const flash = getFlash(); + return adminAttributesPage( + await getAllAttributesWithOptions(), + session, + flash.error, + ); +}); + +const handleAttributesPost = createAuthedFormRoute({ + auth: OWNER_FORM, + form: attributeNameForm, + onInvalid: ({ error }) => errorRedirect("/admin/attributes", error), + onValid: async ({ values: { name } }) => { + const attribute = await attributesTable.insert({ name }); + await assignNextAttributeSortOrder(attribute.id); + await logActivity(`Attribute '${name}' created`); + return redirect( + `/admin/attributes/${attribute.id}`, + "Attribute created", + true, + ); + }, +}); + +const handleAttributeGet = ownerGetById( + getAttributeWithOptions, + (attribute, session) => + htmlResponse(adminAttributePage(attribute, session, getFlash().error)), +); + +type AttributeParams = { id: number }; +type AttributeOptionParams = { id: number; optionId: number }; +type AttributeOptionContext = { + attribute: AttributeWithOptions; + option: AttributeOption; +}; + +const redirectToAttribute = (args: { + error: string; + params: AttributeParams; +}): Response => + errorRedirect(`/admin/attributes/${args.params.id}`, args.error); + +const withAttribute = async ( + id: number, + handle: (attribute: AttributeWithOptions) => Promise, +): Promise => { + const attribute = await getAttributeWithOptions(id); + return attribute ? handle(attribute) : notFoundResponse(); +}; + +const handleAttributeEdit = createAuthedFormRoute< + { name: string }, + AttributeParams +>({ + auth: OWNER_FORM, + form: attributeNameForm, + onInvalid: redirectToAttribute, + onValid: ({ params, values: { name } }) => + withAttribute(params.id, async () => { + await attributesTable.update(params.id, { name }); + await logActivity(`Attribute '${name}' updated`); + return redirect( + `/admin/attributes/${params.id}`, + "Attribute updated", + true, + ); + }), +}); + +const handleAddOption = createAuthedFormRoute< + { text: string }, + AttributeParams +>({ + auth: OWNER_FORM, + form: attributeOptionForm, + onInvalid: redirectToAttribute, + onValid: ({ params, values: { text } }) => + withAttribute(params.id, async (attribute) => { + await attributeOptionsTable.insert({ + attributeId: params.id, + sortOrder: await getNextAttributeOptionSortOrder(params.id), + text, + }); + await logActivity(`Attribute option '${text}' added to ${attribute.id}`); + return redirect(`/admin/attributes/${params.id}`, "Option added", true); + }), +}); + +const attributeDelete = createConfirmedHandlers({ + identifier: (attribute) => attributeNameFlat(attribute.name), + identifierLabel: "Attribute name", + load: (id) => getAttributeWithOptions(id), + onConfirm: async (attribute) => { + await deleteAttribute(attribute.id); + await logActivity(`Attribute '${attribute.name}' deleted`); + }, + path: "/admin/attributes/:id/delete", + render: (attribute, session, error) => + adminAttributeDeletePage(attribute, session, error), + successMessage: "Attribute deleted", + successRedirect: "/admin/attributes", +}); + +const loadAttributeOption = async ({ + id, + optionId, +}: AttributeOptionParams): Promise => { + const attribute = await getAttributeWithOptions(id); + const option = attribute?.options.find((item) => item.id === optionId); + return attribute && option ? { attribute, option } : null; +}; + +const optionRoute = + ( + handler: ( + attribute: AttributeWithOptions, + option: AttributeOption, + session: Parameters[2], + ) => Response, + ) => + (request: Request, params: AttributeOptionParams): Promise => + requireOwnerOr(request, async (session) => { + const context = await loadAttributeOption(params); + if (!context) return notFoundResponse(); + return handler(context.attribute, context.option, session); + }); + +const handleDeleteOptionGet = optionRoute((attribute, option, session) => + htmlResponse( + adminAttributeOptionDeletePage( + attribute, + option, + session, + getFlash().error, + ), + ), +); + +const optionDeletePath = ({ id, optionId }: AttributeOptionParams): string => + `/admin/attributes/${id}/options/${optionId}/delete`; + +const handleDeleteOptionPost = createVerifiedFormRoute< + AttributeOptionParams, + AttributeOptionContext +>({ + actionLabel: "deletion", + auth: OWNER_FORM, + identifier: ({ option }) => option.text, + identifierLabel: "Option text", + loadContext: loadAttributeOption, + mismatchRedirect: (_context, params) => optionDeletePath(params), + onConfirm: async ({ context: { attribute, option } }) => { + await deleteAttributeOption(option.id); + await logActivity( + `Attribute option '${option.text}' deleted from ${attribute.id}`, + ); + return redirect( + `/admin/attributes/${attribute.id}`, + "Option deleted", + true, + ); + }, +}); + +const editOptionPath = ({ id }: AttributeOptionParams): string => + `/admin/attributes/${id}`; + +const handleEditOptionPost = createAuthedFormRoute< + { text: string }, + AttributeOptionParams, + AttributeOptionContext +>({ + auth: OWNER_FORM, + form: attributeOptionForm, + loadContext: loadAttributeOption, + onInvalid: ({ error, params }) => + errorRedirect(editOptionPath(params), error), + onValid: async ({ context: { attribute, option }, values: { text } }) => { + await attributeOptionsTable.update(option.id, { text }); + await logActivity(`Attribute option '${text}' updated in ${attribute.id}`); + return redirect( + `/admin/attributes/${attribute.id}`, + "Option updated", + true, + ); + }, +}); + +const optionActionHandler = ( + handle: ( + args: AuthedHandlerArgs, + ) => Response | Promise, +) => + createAuthedHandler({ + auth: OWNER_FORM, + handle, + loadContext: loadAttributeOption, + }); + +const moveOptionHandler = (direction: -1 | 1) => + optionActionHandler(async ({ context: { attribute, option } }) => { + const index = attribute.options.findIndex((item) => item.id === option.id); + const neighbor = attribute.options[index + direction]; + if (neighbor) await swapAttributeOptionOrder(option.id, neighbor.id); + return redirect(`/admin/attributes/${attribute.id}`, "Option moved", true); + }); + +const moveAttributeHandler = (direction: -1 | 1) => + createAuthedHandler({ + auth: OWNER_FORM, + handle: async ({ context: attribute }) => { + const attributes = await getAllAttributesWithOptions(); + const index = attributes.findIndex((item) => item.id === attribute.id); + const neighbor = attributes[index + direction]; + if (neighbor) await swapAttributeOrder(attribute.id, neighbor.id); + return redirect("/admin/attributes", "Attribute moved", true); + }, + loadContext: ({ id }) => getAttributeWithOptions(id), + }); + +const handleListingAttributesPost = createListingChoicePost({ + fieldName: "option_ids", + label: "Attributes", + noun: "option", + readIds: async (form) => + pruneInvalidAttributeOptionIds( + await getAllAttributesWithOptions(), + form.getNumberArray("option_ids"), + ), + saveIds: setListingAttributeOptions, + tab: "attributes", +}); + +export const attributesRoutes = { + ...attributeDelete.routes, + ...defineRoutes({ + "GET /admin/attributes": handleAttributesGet, + "GET /admin/attributes/:id": handleAttributeGet, + "GET /admin/attributes/:id/options/:optionId/delete": handleDeleteOptionGet, + "POST /admin/attributes": handleAttributesPost, + "POST /admin/attributes/:id/edit": handleAttributeEdit, + "POST /admin/attributes/:id/move-down": moveAttributeHandler(1), + "POST /admin/attributes/:id/move-up": moveAttributeHandler(-1), + "POST /admin/attributes/:id/options": handleAddOption, + "POST /admin/attributes/:id/options/:optionId/delete": + handleDeleteOptionPost, + "POST /admin/attributes/:id/options/:optionId/edit": handleEditOptionPost, + "POST /admin/attributes/:id/options/:optionId/move-down": + moveOptionHandler(1), + "POST /admin/attributes/:id/options/:optionId/move-up": + moveOptionHandler(-1), + "POST /admin/listing/:id/attributes": handleListingAttributesPost, + }), +}; diff --git a/src/features/admin/dashboard.ts b/src/features/admin/dashboard.ts index 687917042e..b7cada0a9c 100644 --- a/src/features/admin/dashboard.ts +++ b/src/features/admin/dashboard.ts @@ -28,12 +28,17 @@ import { getActiveListingStats, getNewestAttendeesRaw, } from "#shared/db/attendees.ts"; +import { getSelectedAttributesForListings } from "#shared/db/attributes.ts"; import { getHiddenPackageMemberIds } from "#shared/db/groups.ts"; import { getActiveHolidays } from "#shared/db/holidays.ts"; import { getNonStandaloneChildIds } from "#shared/db/listing-parents.ts"; import { getAllListings, getListingNamesByIds } from "#shared/db/listings.ts"; import { settings } from "#shared/db/settings.ts"; import { getFlash } from "#shared/flash-context.ts"; +import { + attributeFilterGroupsForListings, + selectedAttributeFiltersFromRequest, +} from "#shared/listing-attribute-filter.ts"; import { filterListingsByType, listingTypeFromRequest, @@ -41,6 +46,7 @@ import { import { requireRequestPrivateKey } from "#shared/session-private-key.ts"; import { sortListings } from "#shared/sort-listings.ts"; import { todayInTz } from "#shared/timezone.ts"; +import type { ListingWithCount } from "#shared/types.ts"; /* jscpd:ignore-end */ import { type ActivityLogRefs, @@ -50,6 +56,7 @@ import { adminDashboardPage, adminListingsPage, } from "#templates/admin/dashboard.tsx"; +import type { ListingAttributeFilterView } from "#templates/admin/listing-attribute-filters.ts"; import { adminLoginPage } from "#templates/admin/login.tsx"; /** Login page response helper */ @@ -76,6 +83,28 @@ const loadSortedListings = async () => { return sortListings(listings, holidays); }; +const loadListingAttributeFilterContext = async ( + request: Request, + listings: ListingWithCount[], + filterSource: ListingWithCount[], +): Promise => { + const attributesByListing = await getSelectedAttributesForListings( + listings.map((listing) => listing.id), + ); + const attributeFilters = attributeFilterGroupsForListings( + filterSource.map((listing) => listing.id), + attributesByListing, + ); + return { + activeAttributeFilters: selectedAttributeFiltersFromRequest( + request, + attributeFilters, + ), + attributeFilters, + attributesByListing, + }; +}; + /** * Handle GET /admin/ */ @@ -107,12 +136,22 @@ const handleAdminGet = (request: Request): Promise => // builder emits would be rejected by the server. A `bookable_alone` child // has its own page, so it stays bookable here. const listingIds = sortedListings.map((l) => l.id); - const [childIds, hiddenMemberIds, upcomingServicingEvents] = - await Promise.all([ - getNonStandaloneChildIds(listingIds), - getHiddenPackageMemberIds(listingIds), - getUpcomingServicingEvents(privateKey, todayInTz(settings.timezone)), - ]); + const activeListings = sortedListings.filter((listing) => listing.active); + const [ + childIds, + hiddenMemberIds, + upcomingServicingEvents, + attributeContext, + ] = await Promise.all([ + getNonStandaloneChildIds(listingIds), + getHiddenPackageMemberIds(listingIds), + getUpcomingServicingEvents(privateKey, todayInTz(settings.timezone)), + loadListingAttributeFilterContext( + request, + sortedListings, + activeListings, + ), + ]); const unbookableIds = new Set([...childIds, ...hiddenMemberIds]); return htmlResponse( adminDashboardPage( @@ -127,6 +166,7 @@ const handleAdminGet = (request: Request): Promise => holidays, unbookableIds, upcomingServicingEvents, + attributeContext, ), ); }, @@ -137,13 +177,15 @@ const handleAdminGet = (request: Request): Promise => * gated to content roles (staff + editor); the template renders role-aware * columns/links so editors see no financials or forbidden detail links. */ const handleAdminListingsGet: TypedRouteHandler<"GET /admin/listings"> = - contentPage(async (session) => - adminListingsPage( - await loadSortedListings(), + contentPage(async (session, request) => { + const listings = await loadSortedListings(); + return adminListingsPage( + listings, session, settings.listingColumnOrder, - ), - ); + await loadListingAttributeFilterContext(request, listings, listings), + ); + }); /** Handle GET /admin/listings/csv — export every listing (filtered by the same * ?type= category filter the listings views use) as a CSV download. */ diff --git a/src/features/admin/index.ts b/src/features/admin/index.ts index 4e4ee0aee1..48710fadf4 100644 --- a/src/features/admin/index.ts +++ b/src/features/admin/index.ts @@ -12,6 +12,7 @@ import { apiKeysRoutes } from "#routes/admin/api-keys.ts"; import { attendeeNotesRoutes } from "#routes/admin/attendee-notes.ts"; import { attendeeRefundRoutes } from "#routes/admin/attendee-refunds.ts"; import { attendeesRoutes } from "#routes/admin/attendees.ts"; +import { attributesRoutes } from "#routes/admin/attributes.ts"; import { authRoutes } from "#routes/admin/auth.ts"; import { backupRoutes } from "#routes/admin/backup.ts"; import { builderRoutes } from "#routes/admin/builder.ts"; @@ -88,6 +89,7 @@ const adminRouteModules: Record[] = [ bulkEmailRoutes, holidaysCrud.routes, imagesRoutes, + attributesRoutes, questionsRoutes, scannerRoutes, seedsRoutes, diff --git a/src/features/admin/listing-choice-post.ts b/src/features/admin/listing-choice-post.ts new file mode 100644 index 0000000000..01069fba83 --- /dev/null +++ b/src/features/admin/listing-choice-post.ts @@ -0,0 +1,37 @@ +import { ownerFormById } from "#routes/entity.ts"; +import { notFoundResponse, redirect } from "#routes/response.ts"; +import { logActivity } from "#shared/db/activityLog.ts"; +import { getListingWithCount } from "#shared/db/listings.ts"; +import type { FormParams } from "#shared/form-data.ts"; + +type ListingChoicePostConfig = { + fieldName: string; + label: string; + noun: string; + readIds?: (form: FormParams) => number[] | Promise; + saveIds: (listingId: number, ids: number[]) => Promise; + tab: string; +}; + +const countLabel = (count: number, noun: string): string => + `${count} ${noun}${count === 1 ? "" : "s"}`; + +export const createListingChoicePost = ({ + fieldName, + label, + noun, + readIds, + saveIds, + tab, +}: ListingChoicePostConfig) => + ownerFormById(async (id, _session, form) => { + const listing = await getListingWithCount(id); + if (!listing) return notFoundResponse(); + const ids = readIds ? await readIds(form) : form.getNumberArray(fieldName); + await saveIds(id, ids); + await logActivity( + `${label} updated for '${listing.name}' (${countLabel(ids.length, noun)})`, + listing, + ); + return redirect(`/admin/listing/${id}/${tab}`, `${label} updated`, true); + }); diff --git a/src/features/admin/listing-page-data.ts b/src/features/admin/listing-page-data.ts index a1d956f0a1..ee52dda0eb 100644 --- a/src/features/admin/listing-page-data.ts +++ b/src/features/admin/listing-page-data.ts @@ -24,6 +24,10 @@ import { decryptAttendees, getAttendeeNamesByIds, } from "#shared/db/attendees.ts"; +import { + getAllAttributesWithOptions, + getListingAttributeOptionIds, +} from "#shared/db/attributes.ts"; import { getHiddenPackageMemberIds } from "#shared/db/groups.ts"; import { getListingOverviewStats } from "#shared/db/listing-overview-stats.ts"; import { @@ -56,6 +60,7 @@ import { type ListingWithCount, } from "#shared/types.ts"; import { isIsoDate } from "#shared/validation/date.ts"; +import { ListingAttributesPanel } from "#templates/admin/attributes.tsx"; import { ListingQrPanel } from "#templates/admin/listing-qr.tsx"; import { ListingEditPanel } from "#templates/admin/listings/edit-panel.tsx"; import { @@ -359,24 +364,43 @@ export const loadListingImagesPanel = ({ }: LoadedListing): Promise => loadItemImagesPanel("listing", listing.id, `/admin/listing/${listing.id}`); +const listingChoicePanelLoader = + ( + loadItems: () => Promise, + loadSelectedIds: (listingId: number) => Promise, + render: ( + listing: ListingWithCount, + items: Item[], + selectedIds: Set, + error: string | undefined, + ) => JSX.Element, + ) => + async ({ listing }: LoadedListing, error?: string): Promise => { + const [items, selectedIds] = await Promise.all([ + loadItems(), + loadSelectedIds(listing.id), + ]); + return render(listing, items, new Set(selectedIds), error); + }; + /** Build the Questions tab: assign the site's questions to this listing. The * tab is owner-only (matching the route's own gate). `error` is set only on an * in-place 400 re-render. */ -export const loadListingQuestionsPanel = async ( - { listing }: LoadedListing, - error?: string, -): Promise => { - const [allQuestions, assignedIds] = await Promise.all([ - getAllQuestionsWithAnswers(), - getListingQuestionIds(listing.id), - ]); - return ListingQuestionsPanel({ - allQuestions, - assignedIds: new Set(assignedIds), - error, - listing, - }); -}; +export const loadListingQuestionsPanel = listingChoicePanelLoader( + getAllQuestionsWithAnswers, + getListingQuestionIds, + (listing, allQuestions, assignedIds, error) => + ListingQuestionsPanel({ allQuestions, assignedIds, error, listing }), +); + +/** Build the Attributes tab: choose the public attributes displayed for this + * listing. `error` is set only on an in-place 400 re-render. */ +export const loadListingAttributesPanel = listingChoicePanelLoader( + getAllAttributesWithOptions, + getListingAttributeOptionIds, + (listing, attributes, selectedOptionIds, error) => + ListingAttributesPanel({ attributes, error, listing, selectedOptionIds }), +); /** Build the QR tab: the booking-QR generation form. The tab is hidden for a * child / hidden-package listing (no standalone booking page), so the loader diff --git a/src/features/admin/listing-page.ts b/src/features/admin/listing-page.ts index e48f219029..94ca1cefc3 100644 --- a/src/features/admin/listing-page.ts +++ b/src/features/admin/listing-page.ts @@ -35,6 +35,7 @@ import { listingHasEmailableAttendees, loadListingActivity, loadListingActivityPreview, + loadListingAttributesPanel, loadListingEditPanel, loadListingForPage, loadListingImagesPanel, @@ -192,6 +193,17 @@ export const listingPage: EntityPage = defineEntityPage({ slug: "images", visible: () => !isReadOnly() && isStorageEnabled(), }, + { + labelKey: "entity.tab.attributes", + sections: [ + { + kind: "custom", + load: (entity) => loadListingAttributesPanel(entity), + }, + ], + slug: "attributes", + visible: (_entity, session) => session.adminLevel === "owner", + }, { labelKey: "entity.tab.questions", sections: [ diff --git a/src/features/admin/questions.ts b/src/features/admin/questions.ts index e758e8f7d5..042624ddb4 100644 --- a/src/features/admin/questions.ts +++ b/src/features/admin/questions.ts @@ -28,7 +28,7 @@ import { createAuthedHandler, } from "#shared/app-forms.ts"; import { logActivity } from "#shared/db/activityLog.ts"; -import { getAllListings, getListingWithCount } from "#shared/db/listings.ts"; +import { getAllListings } from "#shared/db/listings.ts"; import { getAllModifiers } from "#shared/db/modifiers.ts"; import { type Answer, @@ -83,6 +83,7 @@ import { formattingHint } from "#templates/components/formatting-hint.ts"; import { answerAggregateFields } from "#templates/fields/aggregate.ts"; /* jscpd:ignore-end */ +import { createListingChoicePost } from "./listing-choice-post.ts"; export const questionTextForm = defineForm({ fields: [ @@ -552,20 +553,12 @@ const handleMoveQuestionUp = moveQuestionHandler(-1); /** Handle POST /admin/questions/:id/move-down */ const handleMoveQuestionDown = moveQuestionHandler(1); -/** Handle POST /admin/listing/:id/questions — the listing entity page's - * Questions tab (GET) posts here, and the save returns to that tab. */ -const handleListingQuestionsPost = ownerFormById(async (id, _session, form) => { - const listing = await getListingWithCount(id); - if (!listing) return notFoundResponse(); - const questionIds = form.getNumberArray("question_ids"); - await setListingQuestions(id, questionIds); - await logActivity( - `Questions updated for '${listing.name}' (${questionIds.length} question${ - questionIds.length !== 1 ? "s" : "" - })`, - listing, - ); - return redirect(`/admin/listing/${id}/questions`, "Questions updated", true); +const handleListingQuestionsPost = createListingChoicePost({ + fieldName: "question_ids", + label: "Questions", + noun: "question", + saveIds: setListingQuestions, + tab: "questions", }); /** Questions routes */ diff --git a/src/features/public/pages.ts b/src/features/public/pages.ts index e72505c6b0..cb3494d821 100644 --- a/src/features/public/pages.ts +++ b/src/features/public/pages.ts @@ -21,6 +21,7 @@ import { import { signCsrfToken } from "#shared/csrf.ts"; import { getBookableStartDates, parseIsoDateParam } from "#shared/dates.ts"; import { getListingRemainingForRange } from "#shared/db/attendees.ts"; +import { getSelectedAttributesForListings } from "#shared/db/attributes.ts"; import { getActiveHolidays } from "#shared/db/holidays.ts"; import { settings } from "#shared/db/settings.ts"; import type { FormParams } from "#shared/form-data.ts"; @@ -212,11 +213,13 @@ export const handlePublicListings = ( const requestedDate = parseIsoDateParam( new URL(request.url).searchParams.get("date"), ); - const [ticketListings, dateFilter, soldOutPackages] = await Promise.all([ - buildTicketListingsWithGroupCapacity(listings), - buildDailyDateFilter(listings, requestedDate), - soldOutPackageIds(groups, requestedDate), - ]); + const [ticketListings, dateFilter, soldOutPackages, attributesByListing] = + await Promise.all([ + buildTicketListingsWithGroupCapacity(listings), + buildDailyDateFilter(listings, requestedDate), + soldOutPackageIds(groups, requestedDate), + getSelectedAttributesForListings(listings.map((listing) => listing.id)), + ]); return htmlResponse( homepagePage( applyParentSoldOut(ticketListings, classification), @@ -230,6 +233,7 @@ export const handlePublicListings = ( nav, soldOutPackages, requestedDate, + attributesByListing, ), ); }); diff --git a/src/features/public/ticket-submit.ts b/src/features/public/ticket-submit.ts index 95c30fd581..9acf4333af 100644 --- a/src/features/public/ticket-submit.ts +++ b/src/features/public/ticket-submit.ts @@ -23,6 +23,7 @@ import { getGroupRemainingByListingId, getSharedGroupCapacities, } from "#shared/db/attendees.ts"; +import { getSelectedAttributesForListings } from "#shared/db/attributes.ts"; import { getGroupIdsByListingIds } from "#shared/db/groups.ts"; import { getActiveHolidays } from "#shared/db/holidays.ts"; import { getImagesForItem } from "#shared/db/images.ts"; @@ -345,24 +346,34 @@ const renderCtx = async (ctx: TicketCtx): Promise => { const children = [...ctx.childrenByParentId.values()] .flat() .map((child) => child.listing); - const [childCaps, childOwnRemaining, holidays, membership, galleryImages] = - await Promise.all([ - getSharedGroupCapacities(children), - getGroupRemainingByListingId(children), - getActiveHolidays(), - getGroupIdsByListingIds([ - ...ctx.listings.map((l) => l.listing.id), - ...children.map((c) => c.id), - ]), - // The header entity's image gallery — read only here, on the render path, - // never on the submit/quote/API flows that don't show it. - ctx.galleryTarget - ? getImagesForItem(ctx.galleryTarget.type, ctx.galleryTarget.id) - : Promise.resolve([]), - ]); + const [ + childCaps, + childOwnRemaining, + holidays, + membership, + galleryImages, + attributesByListing, + ] = await Promise.all([ + getSharedGroupCapacities(children), + getGroupRemainingByListingId(children), + getActiveHolidays(), + getGroupIdsByListingIds([ + ...ctx.listings.map((l) => l.listing.id), + ...children.map((c) => c.id), + ]), + // The header entity's image gallery — read only here, on the render path, + // never on the submit/quote/API flows that don't show it. + ctx.galleryTarget + ? getImagesForItem(ctx.galleryTarget.type, ctx.galleryTarget.id) + : Promise.resolve([]), + getSelectedAttributesForListings( + ctx.listings.map((entry) => entry.listing.id), + ), + ]); const caps = childCapacityInfo(childCaps, childOwnRemaining, membership); return { ...ctx, + attributesByListing, galleryImages, // The PER-GROUP remaining drives the per-parent quantity clamp keyed by the // SPECIFIC group a parent and child share: a parent sharing a capped diff --git a/src/features/public/types.ts b/src/features/public/types.ts index 9698df0d14..bef9acca16 100644 --- a/src/features/public/types.ts +++ b/src/features/public/types.ts @@ -7,6 +7,7 @@ import type { TicketListing, } from "#shared/booking/model.ts"; import type { PagePackage } from "#shared/booking/page-packages.ts"; +import type { ListingAttributesById } from "#shared/db/attributes.ts"; import type { AddOnOption } from "#shared/db/modifier-resolve.ts"; import type { QuestionWithAnswers } from "#shared/db/question-types.ts"; import type { QuestionListingMap } from "#shared/db/questions/queries.ts"; @@ -90,6 +91,8 @@ export type TicketCtx = TicketSharedContext & { * path alongside groupRemainingByGroupId so the shared-group quantity clamps * resolve the group a parent and child actually share. Omitted on submit/quote. */ groupIdsByListingId?: ReadonlyMap; + /** Selected listing attributes for display. Present on render paths only. */ + attributesByListing?: ListingAttributesById; baseUrl?: string; prefill?: BookingPrefill | undefined; }; diff --git a/src/locales/en/attributes.json b/src/locales/en/attributes.json new file mode 100644 index 0000000000..5477bee396 --- /dev/null +++ b/src/locales/en/attributes.json @@ -0,0 +1,33 @@ +{ + "attributes.title": "Listing Attributes", + "attributes.add_submit": "Add Attribute", + "attributes.none": "No listing attributes yet.", + "attributes.order_column": "Order", + "attributes.attribute_column": "Attribute", + "attributes.options_column": "Options", + "attributes.option_column": "Option", + "attributes.detail_title": "Attribute: {name}", + "attributes.update": "Update", + "attributes.options_heading": "Options", + "attributes.add_option": "Add Option", + "attributes.no_options": "No options yet.", + "attributes.option_text_label": "Option text", + "attributes.guide_link": "Listings guide", + "attributes.delete.link": "Delete Attribute", + "attributes.delete.heading": "Delete Attribute", + "attributes.delete.warning": "This will permanently delete the attribute, all its options, and those selections from listings.", + "attributes.delete.confirm_label": "Attribute name", + "attributes.delete.confirm_prompt": "To delete this attribute, type its name \"{name}\" into the box below:", + "attributes.delete.submit": "Delete Attribute", + "attributes.delete_option.heading": "Delete Option", + "attributes.delete_option.warning": "This will remove \"{option}\" from {attribute} and from every listing that uses it.", + "attributes.delete_option.confirm_label": "Option text", + "attributes.delete_option.confirm_prompt": "To delete this option, type \"{text}\" into the box below:", + "attributes.delete_option.submit": "Delete Option", + "attributes.listing.heading": "Attributes for {listing}", + "attributes.listing.none": "No attributes created yet.", + "attributes.listing.create_first": "Create attributes", + "attributes.listing.no_options": "No options for this attribute yet.", + "attributes.listing.manage": "Manage attributes", + "attributes.filter.all": "All" +} diff --git a/src/locales/en/entity-pages.json b/src/locales/en/entity-pages.json index 84e029aa7e..2c9a28fd54 100644 --- a/src/locales/en/entity-pages.json +++ b/src/locales/en/entity-pages.json @@ -7,6 +7,7 @@ "entity.tab.images": "Images", "entity.tab.ledger": "Ledger", "entity.tab.attendees": "Attendees", + "entity.tab.attributes": "Attributes", "entity.tab.questions": "Questions", "entity.tab.qr": "Booking QR", "entity.tab.activity": "Activity", diff --git a/src/locales/en/index.ts b/src/locales/en/index.ts index 3ddcc0fab8..fe668cec98 100644 --- a/src/locales/en/index.ts +++ b/src/locales/en/index.ts @@ -5,6 +5,7 @@ import addressLookup from "./address-lookup.json" with { type: "json" }; import admin from "./admin.json" with { type: "json" }; import attendees from "./attendees.json" with { type: "json" }; +import attributes from "./attributes.json" with { type: "json" }; import availability from "./availability.json" with { type: "json" }; import backup from "./backup.json" with { type: "json" }; import builder from "./builder.json" with { type: "json" }; @@ -53,6 +54,7 @@ import users from "./users.json" with { type: "json" }; const en: Record = { ...addressLookup, ...admin, + ...attributes, ...availability, ...attendees, ...backup, diff --git a/src/locales/en/terms.json b/src/locales/en/terms.json index b586b1f790..fad2f0e434 100644 --- a/src/locales/en/terms.json +++ b/src/locales/en/terms.json @@ -3,6 +3,8 @@ "terms.attendees": "Attendees", "terms.listing": "Listing", "terms.listings": "Listings", + "terms.attribute": "Attribute", + "terms.attributes": "Attributes", "terms.group": "Group", "terms.groups": "Groups", "terms.image": "Image", diff --git a/src/shared/admin-pages.ts b/src/shared/admin-pages.ts index b89d3e6cf7..c8058ae699 100644 --- a/src/shared/admin-pages.ts +++ b/src/shared/admin-pages.ts @@ -267,6 +267,7 @@ const ADMIN_NAV: readonly SectionDef[] = [ }, { href: "/admin/settings/statuses", labelKey: "nav.sub.statuses" }, { href: "/admin/privacy", labelKey: "nav.sub.privacy" }, + { href: "/admin/attributes", labelKey: "terms.attributes" }, { href: "/admin/questions", labelKey: "terms.questions" }, { href: "/admin/logistics", labelKey: "nav.logistics" }, { href: "/admin/emails", labelKey: "nav.emails" }, diff --git a/src/shared/db/attributes.ts b/src/shared/db/attributes.ts new file mode 100644 index 0000000000..528198dbbe --- /dev/null +++ b/src/shared/db/attributes.ts @@ -0,0 +1,317 @@ +/** + * Listing attributes: reusable multiple-choice metadata shown on listing pages. + * + * Attributes do not participate in booking. A listing stores selected option + * ids only; display code resolves those ids back to ordered attribute groups. + */ + +import { map, reduce, unique } from "#fp"; +import { decrypt, encrypt } from "#shared/crypto/encryption.ts"; +import { + executeBatch, + inPlaceholders, + queryAll, + queryOne, +} from "#shared/db/client.ts"; +import { linkTableSide } from "#shared/db/link-table.ts"; +import { swapSortOrder } from "#shared/db/query.ts"; +import { col, defineTable } from "#shared/db/table.ts"; + +export type Attribute = { + id: number; + name: string; + sort_order: number; +}; + +export type AttributeOption = { + attribute_id: number; + id: number; + sort_order: number; + text: string; +}; + +export type AttributeWithOptions = Attribute & { + options: AttributeOption[]; +}; + +export type ListingAttributesById = Map; + +type AttributeInput = { + name: string; + sortOrder?: number; +}; + +type AttributeOptionInput = { + attributeId: number; + sortOrder: number; + text: string; +}; + +const generatedId = col.generated(); +const encryptedText = col.encrypted(encrypt, decrypt); + +export const attributesTable = defineTable({ + name: "attributes", + primaryKey: "id", + schema: { + id: generatedId, + name: encryptedText, + sort_order: col.withDefault(() => 0), + }, +}); + +export const attributeOptionsTable = defineTable< + AttributeOption, + AttributeOptionInput +>({ + name: "attribute_options", + primaryKey: "id", + schema: { + attribute_id: col.simple(), + id: generatedId, + sort_order: col.withDefault(() => 0), + text: encryptedText, + }, +}); + +export const listingAttributeOptions = linkTableSide( + "listing_attribute_options", + "listing_id", + "option_id", +); + +const ATTRIBUTE_COLS = `attribute.id AS attribute_id, + attribute.name AS attribute_name, + attribute.sort_order AS attribute_sort_order, + attributeOption.id AS option_id, + attributeOption.attribute_id AS option_attribute_id, + attributeOption.text AS option_text, + attributeOption.sort_order AS option_sort_order`; + +type JoinedAttributeRow = { + attribute_id: number; + attribute_name: string; + attribute_sort_order: number; + option_attribute_id: number | null; + option_id: number | null; + option_sort_order: number | null; + option_text: string | null; +}; + +type SelectedAttributeRow = JoinedAttributeRow & { + listing_id: number; +}; + +type AttributeGroup = { + name: string; + sortOrder: number; + options: AttributeOption[]; +}; + +const rowOption = (row: JoinedAttributeRow): AttributeOption | null => + row.option_id === null + ? null + : { + attribute_id: row.option_attribute_id!, + id: row.option_id, + sort_order: row.option_sort_order!, + text: row.option_text!, + }; + +const collectAttributeRow = ( + acc: Map, + row: JoinedAttributeRow, +): Map => { + const group = acc.get(row.attribute_id) ?? { + name: row.attribute_name, + options: [], + sortOrder: row.attribute_sort_order, + }; + const option = rowOption(row); + if (option) group.options.push(option); + return acc.set(row.attribute_id, group); +}; + +const decryptAttribute = async ( + id: number, + group: AttributeGroup, +): Promise => { + const [attribute, ...options] = await Promise.all([ + attributesTable.fromDb({ + id, + name: group.name, + sort_order: group.sortOrder, + }), + ...group.options.map((option) => attributeOptionsTable.fromDb(option)), + ]); + return { ...attribute, options }; +}; + +const groupAttributeRows = async ( + rows: JoinedAttributeRow[], +): Promise => + Promise.all( + [ + ...reduce(collectAttributeRow, new Map())(rows), + ].map(([id, group]) => decryptAttribute(id, group)), + ); + +export const getAllAttributesWithOptions = async (): Promise< + AttributeWithOptions[] +> => + groupAttributeRows( + await queryAll( + `SELECT ${ATTRIBUTE_COLS} + FROM attributes AS attribute + LEFT JOIN attribute_options AS attributeOption + ON attributeOption.attribute_id = attribute.id + ORDER BY attribute.sort_order, attribute.id, + attributeOption.sort_order, attributeOption.id`, + ), + ); + +export const getAttributeWithOptions = async ( + id: number, +): Promise => { + const rows = await queryAll( + `SELECT ${ATTRIBUTE_COLS} + FROM attributes AS attribute + LEFT JOIN attribute_options AS attributeOption + ON attributeOption.attribute_id = attribute.id + WHERE attribute.id = ? + ORDER BY attributeOption.sort_order, attributeOption.id`, + [id], + ); + const [attribute] = await groupAttributeRows(rows); + return attribute ?? null; +}; + +export const assignNextAttributeSortOrder = async ( + attributeId: number, +): Promise => { + await executeBatch([ + { + args: [attributeId, attributeId], + sql: `UPDATE attributes + SET sort_order = COALESCE( + (SELECT MAX(sort_order) FROM attributes WHERE id != ?), 0 + ) + 1 + WHERE id = ?`, + }, + ]); +}; + +export const getNextAttributeOptionSortOrder = async ( + attributeId: number, +): Promise => + (await queryOne<{ next_order: number }>( + "SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM attribute_options WHERE attribute_id = ?", + [attributeId], + ))!.next_order; + +export const swapAttributeOrder = (id1: number, id2: number): Promise => + swapSortOrder("attributes", id1, id2); + +export const swapAttributeOptionOrder = ( + id1: number, + id2: number, +): Promise => swapSortOrder("attribute_options", id1, id2); + +export const deleteAttributeOption = async ( + optionId: number, +): Promise => { + await executeBatch([ + { + args: [optionId], + sql: "DELETE FROM listing_attribute_options WHERE option_id = ?", + }, + { args: [optionId], sql: "DELETE FROM attribute_options WHERE id = ?" }, + ]); +}; + +export const deleteAttribute = async (attributeId: number): Promise => { + await executeBatch([ + { + args: [attributeId], + sql: + "DELETE FROM listing_attribute_options WHERE option_id IN " + + "(SELECT id FROM attribute_options WHERE attribute_id = ?)", + }, + { + args: [attributeId], + sql: "DELETE FROM attribute_options WHERE attribute_id = ?", + }, + { args: [attributeId], sql: "DELETE FROM attributes WHERE id = ?" }, + ]); +}; + +const selectedOptionRows = ( + listingIds: number[], +): Promise => + listingIds.length === 0 + ? Promise.resolve([]) + : queryAll( + `SELECT listingAttribute.listing_id, ${ATTRIBUTE_COLS} + FROM listing_attribute_options AS listingAttribute + JOIN attribute_options AS attributeOption + ON attributeOption.id = listingAttribute.option_id + JOIN attributes AS attribute + ON attribute.id = attributeOption.attribute_id + WHERE listingAttribute.listing_id IN (${inPlaceholders(listingIds)}) + ORDER BY listingAttribute.listing_id, + attribute.sort_order, attribute.id, + attributeOption.sort_order, attributeOption.id`, + listingIds, + ); + +const selectedRowsForListing = ( + rows: SelectedAttributeRow[], +): Map => + reduce( + (acc: Map, row: SelectedAttributeRow) => { + const listingRows = acc.get(row.listing_id) ?? []; + listingRows.push(row); + return acc.set(row.listing_id, listingRows); + }, + new Map(), + )(rows); + +export const getSelectedAttributesForListings = async ( + listingIds: number[], +): Promise => { + const rowsByListing = selectedRowsForListing( + await selectedOptionRows(unique(listingIds)), + ); + const entries = await Promise.all( + [...rowsByListing].map( + async ([listingId, rows]) => + [listingId, await groupAttributeRows(rows)] as const, + ), + ); + return new Map(entries); +}; + +export const getListingAttributeOptionIds = ( + listingId: number, +): Promise => listingAttributeOptions.getIds(listingId); + +export const setListingAttributeOptions = async ( + listingId: number, + optionIds: number[], +): Promise => + listingAttributeOptions.setIds(listingId, unique(optionIds)); + +export const optionIdsForAttributes = ( + attributes: AttributeWithOptions[], +): number[] => + attributes.flatMap((attribute) => + map((option: AttributeOption) => option.id)(attribute.options), + ); + +export const pruneInvalidAttributeOptionIds = ( + allAttributes: AttributeWithOptions[], + optionIds: number[], +): number[] => { + const valid = new Set(optionIdsForAttributes(allAttributes)); + return optionIds.filter((id) => valid.has(id)); +}; diff --git a/src/shared/db/listings.ts b/src/shared/db/listings.ts index 1148da84ee..8130fed7ff 100644 --- a/src/shared/db/listings.ts +++ b/src/shared/db/listings.ts @@ -629,8 +629,8 @@ export const isSlugTaken = ( * Delete a listing and its own bookings in a single database round-trip. * * Only the deleted listing's rows are touched: its `listing_attendees` links, - * its `listing_questions` assignments, its `listing_parents` edges (on either - * side), its `activity_log` entries, and the listing itself. Attendees are + * its `listing_questions`/attribute assignments, its `listing_parents` edges + * (on either side), its `activity_log` entries, and the listing itself. Attendees are * deliberately left alone — an attendee booked * onto another listing keeps that booking (and all of its answers/payments) * completely untouched, and an attendee left with no bookings is simply @@ -655,6 +655,10 @@ export const deleteListing = async (listingId: number): Promise => { args: [listingId], sql: "DELETE FROM listing_questions WHERE listing_id = ?", }, + { + args: [listingId], + sql: "DELETE FROM listing_attribute_options WHERE listing_id = ?", + }, { // Remove this listing from both sides of every parent/child edge. args: [listingId, listingId], diff --git a/src/shared/db/migrations.ts b/src/shared/db/migrations.ts index 230531b9a1..8fb541da79 100644 --- a/src/shared/db/migrations.ts +++ b/src/shared/db/migrations.ts @@ -99,6 +99,7 @@ import packageSlotIdentityMigration from "./migrations/2026-07-05_package_slot_i import listingAttendeesEndStartIndexMigration from "./migrations/2026-07-06_listing_attendees_end_start_index.ts"; import newsPostsMigration from "./migrations/2026-07-06_news_posts.ts"; import contactAttendeeTokensMigration from "./migrations/2026-07-07_contact_attendee_tokens.ts"; +import listingAttributesMigration from "./migrations/2026-07-09_listing_attributes.ts"; import { repairLegacyRenames } from "./migrations/rename-utils.ts"; import { LATEST_UPDATE, @@ -321,6 +322,8 @@ export const MIGRATIONS: Migration[] = [ listingAttendeesEndStartIndexMigration, // Add the encrypted per-contact list of booked ticket tokens. contactAttendeeTokensMigration, + // Public listing attributes and their multiple-choice options. + listingAttributesMigration, ].map((build) => build(migrationContext)); export const MIGRATION_IDS: string[] = MIGRATIONS.map( diff --git a/src/shared/db/migrations/2026-07-09_listing_attributes.ts b/src/shared/db/migrations/2026-07-09_listing_attributes.ts new file mode 100644 index 0000000000..27caa57720 --- /dev/null +++ b/src/shared/db/migrations/2026-07-09_listing_attributes.ts @@ -0,0 +1,19 @@ +import { schemaMigration } from "./define.ts"; + +/** + * Public listing attributes: reusable attribute names, reusable multiple-choice + * options, and a listing-option link table for display and admin filtering. + */ +export default schemaMigration( + "2026-07-09_listing_attributes", + "Add listing attributes with reusable multiple-choice options.", + { + indexes: [ + "idx_attributes_sort_order", + "idx_attribute_options_attribute", + "idx_listing_attribute_options_pair", + "idx_listing_attribute_options_option", + ], + newTables: ["attributes", "attribute_options", "listing_attribute_options"], + }, +); diff --git a/src/shared/db/migrations/schema.ts b/src/shared/db/migrations/schema.ts index 3040481c37..b3467ea982 100644 --- a/src/shared/db/migrations/schema.ts +++ b/src/shared/db/migrations/schema.ts @@ -34,7 +34,7 @@ export type Trigger = { // ─── Version — update LATEST_UPDATE to describe each change ───── export const LATEST_UPDATE = - "Add idx_listing_attendees_end_start so the Logistics tab's cross-listing overlap query stays bounded."; + "Add listing attributes with reusable multiple-choice options."; // ─── Schema (ordered: tables with no FK deps first) ───────────── @@ -839,6 +839,60 @@ export const SCHEMA: [name: string, table: Table][] = [ }, ], + [ + // Reusable public listing attributes. The attribute name and option text are + // encrypted like listing descriptions and custom questions; filtering uses + // option ids, so no plaintext index is needed. + "attributes", + { + columns: [ + ["id", "INTEGER PRIMARY KEY AUTOINCREMENT"], + ["name", "TEXT NOT NULL"], + ["sort_order", "INTEGER NOT NULL DEFAULT 0"], + ], + indexes: [{ columns: ["sort_order"], name: "idx_attributes_sort_order" }], + }, + ], + + [ + "attribute_options", + { + columns: [ + ["id", "INTEGER PRIMARY KEY AUTOINCREMENT"], + ["attribute_id", "INTEGER NOT NULL"], + ["text", "TEXT NOT NULL"], + ["sort_order", "INTEGER NOT NULL DEFAULT 0"], + ], + indexes: [ + { columns: ["attribute_id"], name: "idx_attribute_options_attribute" }, + ], + }, + ], + + [ + // A selected option on a listing. The option points to its attribute, so the + // listing does not need a separate attribute row: one path handles one or + // many selected options. + "listing_attribute_options", + { + columns: [ + ["listing_id", "INTEGER NOT NULL"], + ["option_id", "INTEGER NOT NULL"], + ], + indexes: [ + { + columns: ["listing_id", "option_id"], + name: "idx_listing_attribute_options_pair", + unique: true, + }, + { + columns: ["option_id"], + name: "idx_listing_attribute_options_option", + }, + ], + }, + ], + [ "built_sites", { diff --git a/src/shared/listing-attribute-filter.ts b/src/shared/listing-attribute-filter.ts new file mode 100644 index 0000000000..58717e35ee --- /dev/null +++ b/src/shared/listing-attribute-filter.ts @@ -0,0 +1,128 @@ +import { reduce } from "#fp"; +import type { + AttributeOption, + AttributeWithOptions, + ListingAttributesById, +} from "#shared/db/attributes.ts"; +import type { ListingWithCount } from "#shared/types.ts"; +import { parsePositiveInt } from "#shared/validation/number.ts"; + +export type AttributeFilterOption = Pick< + AttributeOption, + "id" | "sort_order" | "text" +>; + +export type AttributeFilterGroup = { + id: number; + name: string; + options: AttributeFilterOption[]; + sort_order: number; +}; + +export type SelectedAttributeFilters = Map; + +export const attributeFilterParam = (attributeId: number): string => + `attribute_${attributeId}`; + +type MutableFilterGroup = Omit & { + options: Map; +}; + +const optionSort = ( + left: AttributeFilterOption, + right: AttributeFilterOption, +): number => left.sort_order - right.sort_order || left.id - right.id; + +const attributeSort = ( + left: AttributeFilterGroup, + right: AttributeFilterGroup, +): number => left.sort_order - right.sort_order || left.id - right.id; + +const addAttributeOptions = ( + filters: Map, + attribute: AttributeWithOptions, +): Map => { + const group = filters.get(attribute.id) ?? { + id: attribute.id, + name: attribute.name, + options: new Map(), + sort_order: attribute.sort_order, + }; + for (const option of attribute.options) { + group.options.set(option.id, { + id: option.id, + sort_order: option.sort_order, + text: option.text, + }); + } + return filters.set(attribute.id, group); +}; + +const addListingAttributes = ( + filters: Map, + attributes: AttributeWithOptions[], +): Map => + reduce(addAttributeOptions, filters)(attributes); + +const freezeFilterGroup = ( + group: MutableFilterGroup, +): AttributeFilterGroup => ({ + ...group, + options: [...group.options.values()].toSorted(optionSort), +}); + +export const attributeFilterGroupsForListings = ( + listingIds: number[], + attributesByListing: ListingAttributesById, +): AttributeFilterGroup[] => + [ + ...reduce( + (filters: Map, listingId: number) => + addListingAttributes(filters, attributesByListing.get(listingId) ?? []), + new Map(), + )(listingIds).values(), + ] + .map(freezeFilterGroup) + .filter((group) => group.options.length > 0) + .toSorted(attributeSort); + +export const selectedAttributeFiltersFromRequest = ( + request: Request, + filters: AttributeFilterGroup[], +): SelectedAttributeFilters => { + const params = new URL(request.url).searchParams; + const selected = filters.flatMap((group) => { + const value = params.get(attributeFilterParam(group.id)); + const optionId = value === null ? null : parsePositiveInt(value); + return optionId !== null && + group.options.some((option) => option.id === optionId) + ? [[group.id, optionId] as const] + : []; + }); + return new Map(selected); +}; + +const selectedOptionIds = ( + attributes: AttributeWithOptions[] | undefined, +): Set => + new Set( + (attributes ?? []).flatMap((attribute) => + attribute.options.map((option) => option.id), + ), + ); + +export const filterListingsByAttributes = + ( + selected: SelectedAttributeFilters, + attributesByListing: ListingAttributesById, + ) => + (listings: ListingWithCount[]): ListingWithCount[] => { + const required = [...selected.values()]; + if (required.length === 0) return listings; + return listings.filter((listing) => { + const listingOptions = selectedOptionIds( + attributesByListing.get(listing.id), + ); + return required.every((optionId) => listingOptions.has(optionId)); + }); + }; diff --git a/src/ui/static/style.scss b/src/ui/static/style.scss index 4744e86eeb..eac4b59591 100644 --- a/src/ui/static/style.scss +++ b/src/ui/static/style.scss @@ -235,6 +235,27 @@ article > * { margin-bottom: 0; } +.listing-attributes { + display: grid; + gap: var(--space-xs); + margin-block: var(--space-s); +} + +.listing-attributes > div { + display: flex; + flex-wrap: wrap; + gap: 0 var(--space-xs); +} + +.listing-attributes dt { + color: var(--color-text-secondary); + font-weight: 700; +} + +.listing-attributes dd { + margin: 0; +} + /* Agent run sheet: the top-level booking list has no bullets or indent; the nested job/detail lists drop their bullets but keep their padding so they still read as indented sub-lists. */ @@ -2918,7 +2939,10 @@ a.cal-day-selected:active { border-color: var(--color-secondary); } -.news-gallery-radio:focus-visible + .news-gallery-full + .news-gallery-thumb img { +.news-gallery-radio:focus-visible + + .news-gallery-full + + .news-gallery-thumb + img { outline: 2px solid var(--color-secondary); outline-offset: 2px; } diff --git a/src/ui/templates/admin/attributes.tsx b/src/ui/templates/admin/attributes.tsx new file mode 100644 index 0000000000..e36b86dbe7 --- /dev/null +++ b/src/ui/templates/admin/attributes.tsx @@ -0,0 +1,314 @@ +/* jscpd:ignore-start */ +import { t } from "#i18n"; +import { Raw } from "#jsx/jsx-runtime.ts"; +import { + attributeNameForm, + attributeOptionForm, +} from "#routes/admin/attributes.ts"; +import type { + AttributeOption, + AttributeWithOptions, +} from "#shared/db/attributes.ts"; +import { CsrfForm } from "#shared/forms.tsx"; +import type { AdminSession } from "#shared/types.ts"; +import { errorAdminPage } from "#templates/admin/admin-page.tsx"; +import { ConfirmPage } from "#templates/admin/confirm-page.tsx"; +import { + BackButton, + GuideFooter, + SubmitButton, +} from "#templates/components/actions.tsx"; +import { + FormSections, + IdCheckboxLabel, +} from "#templates/components/aggregate-sections.tsx"; +import { + ReorderCell, + ReorderTable, + reorderLinkTableAt, +} from "#templates/components/reorder-table.tsx"; +import { colClass } from "#templates/components/table-columns.ts"; +import { + type ListingPanelProps, + listingChoicePanel, +} from "./listing-panel-frame.tsx"; +/* jscpd:ignore-end */ + +export const attributeNameFlat = (name: string): string => + name.replace(/\r?\n/g, " / "); + +export const adminAttributesPage = ( + attributes: AttributeWithOptions[], + session: AdminSession, + error?: string, +): string => + errorAdminPage(t("attributes.title"), "/admin/attributes")(session, error)( + <> + + + {t("attributes.add_submit")} + + + {attributes.length === 0 ? ( +

+ {t("attributes.none")} +

+ ) : ( + reorderLinkTableAt( + "/admin/attributes", + t("attributes.order_column"), + <> + {t("attributes.attribute_column")} + + {t("attributes.options_column")} + + , + attributes, + (attribute) => attributeNameFlat(attribute.name), + (attribute) => ( + {attribute.options.length} + ), + ) + )} + + + {t("attributes.guide_link")} + + , + ); + +const OptionRow = ({ + attribute, + option, + index, +}: { + attribute: AttributeWithOptions; + option: AttributeOption; + index: number; +}): JSX.Element => ( + + + `/admin/attributes/${attribute.id}/options/${option.id}/move-${direction}` + } + count={attribute.options.length} + index={index} + /> + + + + {t("common.save")} + + + + + {t("common.delete")} + + + +); + +export const adminAttributePage = ( + attribute: AttributeWithOptions, + session: AdminSession, + error?: string, +): string => + errorAdminPage( + t("attributes.detail_title", { name: attributeNameFlat(attribute.name) }), + "/admin/attributes", + )( + session, + error, + )( + <> +

{attributeNameFlat(attribute.name)}

+ + + + {t("attributes.update")} + + +

{t("attributes.options_heading")}

+ + + {t("attributes.add_option")} + + + {attribute.options.length === 0 ? ( +

+ {t("attributes.no_options")} +

+ ) : ( + + {t("attributes.option_column")} + {t("common.actions")} + + } + orderLabel={t("attributes.order_column")} + > + {attribute.options.map((option, index) => ( + + ))} + + )} + +

+ + {t("attributes.delete.link")} + +

+ , + ); + +const attributeConfirmPage = ({ + action, + buttonText, + error, + heading, + label, + name, + prompt, + session, + warning, +}: { + action: string; + buttonText: string; + error: string | undefined; + heading: string; + label: string; + name: string; + prompt: { args: Record; key: string }; + session: AdminSession; + warning: JSX.Element; +}): string => + ConfirmPage({ + action, + active: "/admin/attributes", + buttonText, + error, + heading, + label, + name, + prompt, + session, + title: heading, + warning, + }); + +export const adminAttributeDeletePage = ( + attribute: AttributeWithOptions, + session: AdminSession, + error?: string | undefined, +): string => { + const name = attributeNameFlat(attribute.name); + return attributeConfirmPage({ + action: `/admin/attributes/${attribute.id}/delete`, + buttonText: t("attributes.delete.submit"), + error, + heading: t("attributes.delete.heading"), + label: t("attributes.delete.confirm_label"), + name, + prompt: { + args: { name }, + key: "attributes.delete.confirm_prompt", + }, + session, + warning:

{t("attributes.delete.warning")}

, + }); +}; + +export const adminAttributeOptionDeletePage = ( + attribute: AttributeWithOptions, + option: AttributeOption, + session: AdminSession, + error?: string | undefined, +): string => + attributeConfirmPage({ + action: `/admin/attributes/${attribute.id}/options/${option.id}/delete`, + buttonText: t("attributes.delete_option.submit"), + error, + heading: t("attributes.delete_option.heading"), + label: t("attributes.delete_option.confirm_label"), + name: option.text, + prompt: { + args: { text: option.text }, + key: "attributes.delete_option.confirm_prompt", + }, + session, + warning: ( +

+ {t("attributes.delete_option.warning", { + attribute: attributeNameFlat(attribute.name), + option: option.text, + })} +

+ ), + }); + +type ListingAttributesPanelProps = ListingPanelProps & { + attributes: AttributeWithOptions[]; + selectedOptionIds: Set; +}; + +export const ListingAttributesPanel = ( + props: ListingAttributesPanelProps, +): JSX.Element => { + const { attributes, error, listing, selectedOptionIds } = props; + return listingChoicePanel( + t("attributes.listing.heading", { listing: listing.name }), + error, +

+ + {t("attributes.listing.manage")} + +

, + attributes, + () => ( +

+ {t("attributes.listing.none")}{" "} + {t("attributes.listing.create_first")}. +

+ ), + (availableAttributes) => ( + + ({ + children: + attribute.options.length === 0 ? ( +

+ {t("attributes.listing.no_options")} +

+ ) : ( + attribute.options.map((option) => ( + + )) + ), + legend: attribute.name, + }))} + /> + {t("common.save")} +
+ ), + ); +}; diff --git a/src/ui/templates/admin/dashboard.tsx b/src/ui/templates/admin/dashboard.tsx index a245146c71..cdbc0c1849 100644 --- a/src/ui/templates/admin/dashboard.tsx +++ b/src/ui/templates/admin/dashboard.tsx @@ -24,6 +24,7 @@ import type { ServicingEventSummary } from "#shared/db/attendees/servicing.ts"; import type { ActiveListingStats } from "#shared/db/attendees.ts"; import { isReadOnly } from "#shared/env.ts"; import { Raw } from "#shared/jsx/jsx-runtime.ts"; +import { filterListingsByAttributes } from "#shared/listing-attribute-filter.ts"; import { filterListingsByType, type ListingFilter, @@ -38,6 +39,13 @@ import type { } from "#shared/types.ts"; import { AdminPage, flashAdminPage } from "#templates/admin/admin-page.tsx"; import { HolidayTable } from "#templates/admin/holidays.tsx"; +import { + attributeFilterHref, + emptyAttributeFilterView, + type ListingAttributeFilterView, + renderAttributeFilterBars, + typeFilterHref, +} from "#templates/admin/listing-attribute-filters.ts"; import { AttendeeTable } from "#templates/attendee-table.tsx"; import { ActionButton, GuideFooter } from "#templates/components/actions.tsx"; import { escapeHtml } from "#templates/layout.tsx"; @@ -352,6 +360,7 @@ export const adminDashboardPage = ( upcomingHolidays: Holiday[] = [], unbookableIds: ReadonlySet = new Set(), upcomingServicingEvents: ServicingEventSummary[] = [], + attributeFilterView: ListingAttributeFilterView = emptyAttributeFilterView(), ): string => { const { columnKeys, filters } = resolveColumnLayout( listingColumnTemplate ?? "", @@ -372,13 +381,24 @@ export const adminDashboardPage = ( (e: ListingWithCount) => !unbookableIds.has(e.id), )(activeListings); const categories = unique(listings.map(listingCategory)); - const shownListings = filterListingsByType(activeType)(activeListings); + const { activeAttributeFilters, attributeFilters, attributesByListing } = + attributeFilterView; + const shownListings = filterListingsByAttributes( + activeAttributeFilters, + attributesByListing, + )(filterListingsByType(activeType)(activeListings)); const typeFilterHtml = categories.length > 1 ? renderTypeFilter(activeType, categories, (f) => - f === "all" ? "/admin/" : `/admin/?type=${f}`, + typeFilterHref("/admin/", activeAttributeFilters)(f), ) : ""; + const attributeFilterHtml = renderAttributeFilterBars( + attributeFilters, + activeAttributeFilters, + attributeFilterHref("/admin/", activeType, activeAttributeFilters), + ); + const filterHtml = `${typeFilterHtml}${attributeFilterHtml}`; return flashAdminPage(t("terms.listings"), "/admin/")( session, @@ -391,7 +411,7 @@ export const adminDashboardPage = ( @@ -425,6 +445,7 @@ export const adminListingsPage = ( listings: ListingWithCount[], session: AdminSession, listingColumnTemplate?: string, + attributeFilterView: ListingAttributeFilterView = emptyAttributeFilterView(), ): string => { // Editors see a money-free, edit-linked table on a fixed order (their saved // column template is irrelevant and never references the omitted columns), and @@ -442,6 +463,17 @@ export const adminListingsPage = ( const deactivatedListings = filter((e: ListingWithCount) => !e.active)( listings, ); + const { activeAttributeFilters, attributeFilters, attributesByListing } = + attributeFilterView; + const filterByAttribute = filterListingsByAttributes( + activeAttributeFilters, + attributesByListing, + ); + const attributeFilterHtml = renderAttributeFilterBars( + attributeFilters, + activeAttributeFilters, + attributeFilterHref("/admin/listings", "all", activeAttributeFilters), + ); return String( {deactivatedListings.length > 0 && ( @@ -462,7 +495,7 @@ export const adminListingsPage = (

{t("admin.dashboard.deactivated")}

({ + activeAttributeFilters: new Map(), + attributeFilters: [], + attributesByListing: new Map(), +}); + +const filterParams = ( + activeType: ListingFilter, + activeAttributes: SelectedAttributeFilters, +): URLSearchParams => { + const params = new URLSearchParams(); + if (activeType !== "all") params.set("type", activeType); + for (const [attributeId, optionId] of activeAttributes) { + params.set(attributeFilterParam(attributeId), String(optionId)); + } + return params; +}; + +const hrefWithParams = (path: string, params: URLSearchParams): string => { + const query = params.toString(); + return query ? `${path}?${query}` : path; +}; + +export const typeFilterHref = + (path: string, activeAttributes: SelectedAttributeFilters) => + (type: ListingFilter): string => + hrefWithParams(path, filterParams(type, activeAttributes)); + +export const attributeFilterHref = + ( + path: string, + activeType: ListingFilter, + activeAttributes: SelectedAttributeFilters, + ) => + (attributeId: number, optionId: number | null): string => { + const params = filterParams(activeType, activeAttributes); + const name = attributeFilterParam(attributeId); + if (optionId === null) params.delete(name); + else params.set(name, String(optionId)); + return hrefWithParams(path, params); + }; + +export const renderAttributeFilterBars = ( + filters: AttributeFilterGroup[], + activeFilters: SelectedAttributeFilters, + hrefFor: (attributeId: number, optionId: number | null) => string, +): string => + filters + .map((filterGroup) => + renderFilterBar(escapeHtml(filterGroup.name), [ + { + active: !activeFilters.has(filterGroup.id), + href: hrefFor(filterGroup.id, null), + label: t("attributes.filter.all"), + }, + ...filterGroup.options.map((option) => ({ + active: activeFilters.get(filterGroup.id) === option.id, + href: hrefFor(filterGroup.id, option.id), + label: escapeHtml(option.text), + })), + ]), + ) + .join(""); diff --git a/src/ui/templates/admin/listing-panel-frame.tsx b/src/ui/templates/admin/listing-panel-frame.tsx new file mode 100644 index 0000000000..55c10694c5 --- /dev/null +++ b/src/ui/templates/admin/listing-panel-frame.tsx @@ -0,0 +1,40 @@ +import type { Child } from "#jsx/jsx-runtime.ts"; +import { Flash } from "#shared/forms.tsx"; +import type { ListingWithCount } from "#shared/types.ts"; + +export type ListingPanelProps = { + listing: ListingWithCount; + error?: string | undefined; +}; + +const ListingPanelFrame = ({ + children, + error, + footer, + heading, +}: { + children: Child; + error?: string | undefined; + footer: Child; + heading: string; +}): JSX.Element => ( + <> +

{heading}

+ + {children} + {footer} + +); + +export const listingChoicePanel = ( + heading: string, + error: string | undefined, + footer: Child, + items: T[], + renderEmpty: () => Child, + renderItems: (items: T[]) => Child, +): JSX.Element => ( + + {items.length === 0 ? renderEmpty() : renderItems(items)} + +); diff --git a/src/ui/templates/admin/questions.tsx b/src/ui/templates/admin/questions.tsx index 3987467be0..b7a63a8fb4 100644 --- a/src/ui/templates/admin/questions.tsx +++ b/src/ui/templates/admin/questions.tsx @@ -4,7 +4,6 @@ import { map } from "#fp"; import { t } from "#i18n"; -import type { Child } from "#jsx/jsx-runtime.ts"; import { Raw } from "#jsx/jsx-runtime.ts"; import { answerTextForm, questionTextForm } from "#routes/admin/questions.ts"; import type { Answer, QuestionWithAnswers } from "#shared/db/question-types.ts"; @@ -12,7 +11,7 @@ import type { AnswerAggregateField, AnswerAggregateRecalculation, } from "#shared/db/questions/aggregates.ts"; -import { CsrfForm, Flash, renderFields } from "#shared/forms.tsx"; +import { CsrfForm, renderFields } from "#shared/forms.tsx"; import type { AdminSession, ListingWithCount } from "#shared/types.ts"; import { errorAdminPage } from "#templates/admin/admin-page.tsx"; import { ConfirmPage } from "#templates/admin/confirm-page.tsx"; @@ -32,18 +31,24 @@ import { import { CheckboxForm, CheckboxLabel, + IdCheckboxLabel, } from "#templates/components/aggregate-sections.tsx"; import { LinkedItemsCheckboxes, toLinkedItemOptions, } from "#templates/components/linked-items.tsx"; import { - ReorderArrows, - type ReorderProps, -} from "#templates/components/reorder.tsx"; + ReorderCell, + ReorderTable, + reorderLinkTableAt, +} from "#templates/components/reorder-table.tsx"; import { SelectField } from "#templates/components/select-field.tsx"; import { colClass } from "#templates/components/table-columns.ts"; import { answerAggregateFields } from "#templates/fields/aggregate.ts"; +import { + type ListingPanelProps, + listingChoicePanel, +} from "./listing-panel-frame.tsx"; /** Render question text flat for admin display: line breaks are replaced with * " / " so the text fits on one line in tables, headings, and confirmation @@ -53,42 +58,6 @@ import { answerAggregateFields } from "#templates/fields/aggregate.ts"; export const questionTextFlat = (text: string): string => text.replace(/\r?\n/g, " / "); -/** Move-up / move-down reorder controls used as the first column of the - * question and answer tables. `action` builds the move path for a direction. */ -const ReorderControls = ({ - action, - index, - count, -}: ReorderProps): JSX.Element => ( - - - -); - -/** A reorderable admin table: the scroll wrapper, the shared leading "order" - * column header, the caller's remaining column headers, and the row body. The - * question and answer tables differ only in their non-order columns, so this - * keeps that scaffold (table-scroll → table → thead → order th) in one place. */ -const ReorderTable = ({ - columns, - children, -}: { - columns: Child; - children: Child; -}): JSX.Element => ( -
- - - - - {columns} - - - {children} -
{t("questions.order_column")}
-
-); - /** Listings cell for a question row: a count whose title attribute spells out * the assigned listing names (comma + space separated), or "All" when the * question is assigned to every listing. */ @@ -133,40 +102,31 @@ export const adminQuestionsPage = ( {t("questions.no_questions")}

) : ( - + {t("questions.question_column")} + + {t("questions.answers_column")} + + + {t("questions.listings_column")} + + , + questions, + (q) => questionTextFlat(q.text), + (q) => ( <> - {t("questions.question_column")} - - {t("questions.answers_column")} - - - {t("questions.listings_column")} - - - } - > - {questions.map((q, i) => ( - - `/admin/questions/${q.id}/move-${d}`} - count={questions.length} - index={i} - /> - - - {questionTextFlat(q.text)} - - {q.answers.length} - - ))} - + + ), + ) )} Questions guide @@ -248,10 +208,11 @@ export const adminQuestionPage = ( } + orderLabel={t("questions.order_column")} > {question.answers.map((a, i) => ( - `/admin/questions/${question.id}/answers/${a.id}/move-${d}` } @@ -590,37 +551,39 @@ export const adminAnswerDeletePage = ( * Rendered as the listing entity page's Questions tab (owner-only). Carries its * own error flash for in-place 400 re-renders. */ -export const ListingQuestionsPanel = ({ - listing, - allQuestions, - assignedIds, - error, -}: { - listing: ListingWithCount; +type ListingQuestionsPanelProps = ListingPanelProps & { allQuestions: QuestionWithAnswers[]; assignedIds: Set; - error?: string | undefined; -}): JSX.Element => ( - <> -

{t("questions.listing.heading", { listing: listing.name })}

- +}; - {allQuestions.length === 0 ? ( +export const ListingQuestionsPanel = ( + props: ListingQuestionsPanelProps, +): JSX.Element => { + const { allQuestions, assignedIds, error, listing } = props; + return listingChoicePanel( + t("questions.listing.heading", { listing: listing.name }), + error, +

+ {t("questions.listing.manage")} +

, + allQuestions, + () => (

No questions created yet.{" "} Create questions first.

- ) : ( + ), + (questions) => ( {map((q: QuestionWithAnswers) => ( - {" "} @@ -630,12 +593,9 @@ export const ListingQuestionsPanel = ({ )} ) - - ))(allQuestions)} + + ))(questions)} - )} -

- {t("questions.listing.manage")} -

- -); + ), + ); +}; diff --git a/src/ui/templates/components/aggregate-sections.tsx b/src/ui/templates/components/aggregate-sections.tsx index 0e0b41439c..1e2b765889 100644 --- a/src/ui/templates/components/aggregate-sections.tsx +++ b/src/ui/templates/components/aggregate-sections.tsx @@ -123,6 +123,29 @@ export const CheckboxLabel = ({ ); +export const IdCheckboxLabel = ({ + checkedIds, + children, + id, + label, + name, +}: { + checkedIds: ReadonlySet; + children?: Child; + id: number; + label: Child; + name: string; +}): JSX.Element => ( + + {children} + +); + export const CheckboxesFieldset = ({ fieldName, noneMessage, diff --git a/src/ui/templates/components/reorder-table.tsx b/src/ui/templates/components/reorder-table.tsx new file mode 100644 index 0000000000..2261fc7669 --- /dev/null +++ b/src/ui/templates/components/reorder-table.tsx @@ -0,0 +1,82 @@ +import type { Child } from "#jsx/jsx-runtime.ts"; +import { + ReorderArrows, + type ReorderProps, +} from "#templates/components/reorder.tsx"; +import { colClass } from "#templates/components/table-columns.ts"; + +export const ReorderCell = ({ + action, + index, + count, +}: ReorderProps): JSX.Element => ( + + + +); + +const ReorderLinkRow = ({ + action, + children, + count, + href, + index, + label, +}: ReorderProps & { + children?: Child; + href: string; + label: Child; +}): JSX.Element => ( + + + + {label} + + {children} + +); + +export const reorderLinkTableAt = ( + path: string, + orderLabel: string, + columns: Child, + items: T[], + label: (item: T) => Child, + children: (item: T) => Child, +): JSX.Element => ( + + {items.map((item, index) => ( + `${path}/${item.id}/move-${direction}`} + count={items.length} + href={`${path}/${item.id}`} + index={index} + label={label(item)} + > + {children(item)} + + ))} + +); + +export const ReorderTable = ({ + columns, + children, + orderLabel, +}: { + columns: Child; + children: Child; + orderLabel: string; +}): JSX.Element => ( +
+ + + + + {columns} + + + {children} +
{orderLabel}
+
+); diff --git a/src/ui/templates/public/homepage.tsx b/src/ui/templates/public/homepage.tsx index 20a106a3f2..500825f559 100644 --- a/src/ui/templates/public/homepage.tsx +++ b/src/ui/templates/public/homepage.tsx @@ -3,12 +3,14 @@ import { joinStrings, partition } from "#fp"; import { t } from "#i18n"; import type { TicketListing } from "#shared/booking/model.ts"; import { formatDateLabel, formatDatetimeLabel } from "#shared/dates.ts"; +import type { ListingAttributesById } from "#shared/db/attributes.ts"; import { isReadOnly } from "#shared/env.ts"; import { Raw } from "#shared/jsx/jsx-runtime.ts"; import { renderMarkdown } from "#shared/markdown.ts"; import type { Group } from "#shared/types.ts"; import { Badge } from "#templates/components/badge.tsx"; import { escapeHtml } from "#templates/layout.tsx"; +import { renderListingAttributes } from "./listing-attributes.ts"; import { compareGroupsByName, PackagesSection, @@ -182,6 +184,7 @@ const renderListingCard = ( childStateOf: (id: number) => ChildCardState, dateFilter: DailyDateFilter | null, + attributesByListing: ListingAttributesById, ) => (info: TicketListing): RenderedCard => { const { listing } = info; @@ -194,6 +197,9 @@ const renderListingCard = const descriptionHtml = listing.description ? renderMarkdown(listing.description) : ""; + const attributesHtml = renderListingAttributes( + attributesByListing.get(listing.id), + ); const cta = renderListingCardCta( info, childStateOf(listing.id), @@ -202,7 +208,7 @@ const renderListingCard = const proseHtml = `

${escapeHtml( listing.name, - )}

${dateHtml}${locationHtml}${descriptionHtml}${ + )}${dateHtml}${locationHtml}${descriptionHtml}${attributesHtml}${ cta.insideProse ? cta.html : "" }
`; return { @@ -221,7 +227,9 @@ const renderGroupCard = (group: Group, soldOut: boolean): string => { if (soldOut) { return `

${escapeHtml( group.name, - )}

${descriptionHtml}${statusBadgeParagraph(t("public.sold_out"))}
`; + )}${descriptionHtml}${statusBadgeParagraph( + t("public.sold_out"), + )}`; } const linkHtml = isReadOnly() ? `

${t("public.registration_closed")}

` @@ -246,6 +254,7 @@ export const homepagePage = ( nav: PublicNavProps, soldOutPackageIds: ReadonlySet, requestedDate: string | null, + attributesByListing: ListingAttributesById = new Map(), ): string => { const listingsTitle = t("terms.listings"); const title = websiteTitle @@ -290,7 +299,7 @@ export const homepagePage = ( : partition((g: Group) => !soldOutPackageIds.has(g.id)) )(packageGroups); const listingCards = listings.map( - renderListingCard(childStateOf, dateFilter), + renderListingCard(childStateOf, dateFilter, attributesByListing), ); const [availableCards, unavailableCards] = ( requestedDate === null diff --git a/src/ui/templates/public/listing-attributes.ts b/src/ui/templates/public/listing-attributes.ts new file mode 100644 index 0000000000..5daada8d52 --- /dev/null +++ b/src/ui/templates/public/listing-attributes.ts @@ -0,0 +1,19 @@ +import type { AttributeWithOptions } from "#shared/db/attributes.ts"; +import { escapeHtml } from "#templates/layout.tsx"; + +const attributeOptionsText = (attribute: AttributeWithOptions): string => + attribute.options.map((option) => option.text).join(", "); + +const renderAttribute = (attribute: AttributeWithOptions): string => + `
${escapeHtml(attribute.name)}
${escapeHtml( + attributeOptionsText(attribute), + )}
`; + +export const renderListingAttributes = ( + attributes: readonly AttributeWithOptions[] | undefined, +): string => + attributes && attributes.length > 0 + ? `
${attributes + .map(renderAttribute) + .join("")}
` + : ""; diff --git a/src/ui/templates/public/reservations.tsx b/src/ui/templates/public/reservations.tsx index e7875999a0..e2339b88c5 100644 --- a/src/ui/templates/public/reservations.tsx +++ b/src/ui/templates/public/reservations.tsx @@ -46,6 +46,10 @@ import { formatDateLabel, formatDatetimeLabel, } from "#shared/dates.ts"; +import type { + AttributeWithOptions, + ListingAttributesById, +} from "#shared/db/attributes.ts"; import type { AddOnOption } from "#shared/db/modifier-resolve.ts"; import type { QuestionWithAnswers } from "#shared/db/question-types.ts"; import type { QuestionListingMap } from "#shared/db/questions/queries.ts"; @@ -81,6 +85,7 @@ import { } from "#templates/components/question-text.tsx"; import { getTicketFields } from "#templates/fields/ticket.ts"; import { escapeHtml, Layout } from "#templates/layout.tsx"; +import { renderListingAttributes } from "./listing-attributes.ts"; import { PublicImageGallery, renderListingImage } from "./shared.tsx"; /** OpenGraph meta tags for a public listing page. */ export const buildOgTags = ( @@ -1009,6 +1014,8 @@ export type TicketPageOptions = { /** The header entity's images, shown as the shared CSS gallery above the * form (empty ⇒ falls back to the single header image). */ galleryImages?: readonly Image[]; + /** Selected listing attributes, populated only on render paths. */ + attributesByListing?: ListingAttributesById; prefill?: BookingPrefill | undefined; /** Override the
URL. Defaults to `/ticket/`. */ actionUrl?: string; @@ -1051,6 +1058,7 @@ const TicketPageHeader = ({ headerDescription, headerImage, galleryImages, + listingAttributes, singleListing, pastDays, }: { @@ -1058,6 +1066,7 @@ const TicketPageHeader = ({ headerDescription: string | null | undefined; headerImage: ItemImageProjection | null; galleryImages: readonly Image[]; + listingAttributes: AttributeWithOptions[] | undefined; singleListing: ListingWithCount | null; pastDays: number | null; }): JSX.Element => ( @@ -1095,6 +1104,7 @@ const TicketPageHeader = ({ {singleListing.location}

)} + ); @@ -1683,6 +1693,7 @@ export const ticketPage = ({ packages = [], packageGroupRemainingByGroupId = new Map(), packageMemberGroupIds = new Map(), + attributesByListing = new Map(), }: TicketPageOptions): string => { // The canonical booking tree drives node identity + the stable form field // names (via nodeQuantityFieldName/nodePriceFieldName): one node per @@ -1804,6 +1815,11 @@ export const ticketPage = ({ headerDescription={headerDescription} headerImage={headerImage} headerName={headerName} + listingAttributes={ + singleListing + ? attributesByListing.get(singleListing.id) + : undefined + } pastDays={pastDays} singleListing={singleListing} /> diff --git a/test/lib/db/migration-schema-guard.test.ts b/test/lib/db/migration-schema-guard.test.ts index a0887e362d..e66794f118 100644 --- a/test/lib/db/migration-schema-guard.test.ts +++ b/test/lib/db/migration-schema-guard.test.ts @@ -79,8 +79,9 @@ describe("db > migrations > schema change guard", () => { "2026-07-06_news_posts", "2026-07-06_listing_attendees_end_start_index", "2026-07-07_contact_attendee_tokens", + "2026-07-09_listing_attributes", ], - schemaHash: "16pymqt", + schemaHash: "16iusrh", }); }); }); diff --git a/test/lib/server-attributes.test.ts b/test/lib/server-attributes.test.ts new file mode 100644 index 0000000000..b891dc7ab3 --- /dev/null +++ b/test/lib/server-attributes.test.ts @@ -0,0 +1,380 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { handleRequest } from "#routes"; +import { + getAllAttributesWithOptions, + getAttributeWithOptions, + getListingAttributeOptionIds, + setListingAttributeOptions, +} from "#shared/db/attributes.ts"; +import { + adminFormPost, + adminGet, + createTestAttributeWithOptions, + createTestListing, + describeWithEnv, + expectFlash, + expectFlashRedirect, + expectHtmlResponse, + expectStatus, + getTestSession, + testRequiresAuth, +} from "#test-utils"; + +const postRepeatedOptions = async ( + path: string, + optionIds: number[], +): Promise => { + const { cookie, csrfToken } = await getTestSession(); + const body = new URLSearchParams([["csrf_token", csrfToken]]); + for (const optionId of optionIds) body.append("option_ids", String(optionId)); + return handleRequest( + new Request(`http://localhost${path}`, { + body: body.toString(), + headers: { + "content-type": "application/x-www-form-urlencoded", + cookie, + host: "localhost", + }, + method: "POST", + }), + ); +}; + +const createAttributeViaRoute = async (name: string): Promise => { + const { response } = await adminFormPost("/admin/attributes", { name }); + expect(response.status).toBe(302); + expectFlash(response, "Attribute created"); + const attribute = (await getAllAttributesWithOptions()).find( + (item) => item.name === name, + ); + expect(attribute).toBeTruthy(); + return attribute!.id; +}; + +describeWithEnv("server (admin attributes)", { db: true }, () => { + describe("GET /admin/attributes", () => { + testRequiresAuth("/admin/attributes"); + + test("shows the empty state", async () => { + await expectHtmlResponse( + await adminGet("/admin/attributes"), + 200, + "Listing Attributes", + "No listing attributes yet.", + ); + }); + + test("lists existing attributes with option counts", async () => { + await createTestAttributeWithOptions("Difficulty", ["Easy", "Hard"]); + + await expectHtmlResponse( + await adminGet("/admin/attributes"), + 200, + "Difficulty", + "col-quantity", + ">2", + ); + }); + }); + + describe("attribute CRUD", () => { + testRequiresAuth("/admin/attributes", { + body: { name: "Auth attribute" }, + method: "POST", + }); + + test("creates an attribute and redirects to its detail page", async () => { + const id = await createAttributeViaRoute("Difficulty"); + + await expectHtmlResponse( + await adminGet(`/admin/attributes/${id}`), + 200, + "Difficulty", + "No options yet.", + ); + }); + + test("redirects invalid attribute forms back to the right page", async () => { + const id = await createAttributeViaRoute("Required fields"); + + await expectFlashRedirect( + "/admin/attributes", + expect.any(String), + false, + )((await adminFormPost("/admin/attributes")).response); + await expectFlashRedirect( + `/admin/attributes/${id}`, + expect.any(String), + false, + )((await adminFormPost(`/admin/attributes/${id}/edit`)).response); + await expectFlashRedirect( + `/admin/attributes/${id}`, + expect.any(String), + false, + )((await adminFormPost(`/admin/attributes/${id}/options`)).response); + }); + + test("updates an attribute name", async () => { + const id = await createAttributeViaRoute("Old name"); + + const { response } = await adminFormPost(`/admin/attributes/${id}/edit`, { + name: "New name", + }); + + await expectFlashRedirect( + `/admin/attributes/${id}`, + "Attribute updated", + )(response); + expect((await getAttributeWithOptions(id))?.name).toBe("New name"); + }); + + test("returns 404 when changing a missing attribute", async () => { + expectStatus(404)( + ( + await adminFormPost("/admin/attributes/999999/edit", { + name: "Missing", + }) + ).response, + ); + expectStatus(404)( + ( + await adminFormPost("/admin/attributes/999999/options", { + text: "Missing", + }) + ).response, + ); + }); + + test("adds, edits, and reorders options", async () => { + const id = await createAttributeViaRoute("Format"); + await adminFormPost(`/admin/attributes/${id}/options`, { + text: "Online", + }); + await adminFormPost(`/admin/attributes/${id}/options`, { + text: "In person", + }); + const before = (await getAttributeWithOptions(id))!; + const second = before.options[1]!; + + const edited = await adminFormPost( + `/admin/attributes/${id}/options/${second.id}/edit`, + { text: "In-person" }, + ); + await expectFlashRedirect( + `/admin/attributes/${id}`, + "Option updated", + )(edited.response); + await adminFormPost( + `/admin/attributes/${id}/options/${second.id}/move-up`, + ); + + const after = (await getAttributeWithOptions(id))!; + expect(after.options.map((option) => option.text)).toEqual([ + "In-person", + "Online", + ]); + }); + + test("returns 404 or redirects when changing a missing or invalid option", async () => { + const attribute = await createTestAttributeWithOptions("Missing option", [ + "Only", + ]); + const option = attribute.options[0]!; + + expectStatus(404)( + await adminGet( + `/admin/attributes/${attribute.id}/options/999999/delete`, + ), + ); + expectStatus(404)( + ( + await adminFormPost( + `/admin/attributes/${attribute.id}/options/999999/edit`, + { text: "Missing" }, + ) + ).response, + ); + await expectFlashRedirect( + `/admin/attributes/${attribute.id}`, + expect.any(String), + false, + )( + ( + await adminFormPost( + `/admin/attributes/${attribute.id}/options/${option.id}/edit`, + ) + ).response, + ); + }); + + test("reorders attributes and keeps edge moves harmless", async () => { + const first = await createTestAttributeWithOptions("First", []); + const second = await createTestAttributeWithOptions("Second", []); + + await expectFlashRedirect( + "/admin/attributes", + "Attribute moved", + )( + (await adminFormPost(`/admin/attributes/${first.id}/move-up`)).response, + ); + expect( + (await getAllAttributesWithOptions()).map((item) => item.name), + ).toEqual(["First", "Second"]); + + await expectFlashRedirect( + "/admin/attributes", + "Attribute moved", + )( + (await adminFormPost(`/admin/attributes/${second.id}/move-up`)) + .response, + ); + expect( + (await getAllAttributesWithOptions()).map((item) => item.name), + ).toEqual(["Second", "First"]); + }); + + test("deletes an option after confirmation", async () => { + const attribute = await createTestAttributeWithOptions("Season", [ + "Spring", + ]); + + await expectHtmlResponse( + await adminGet( + `/admin/attributes/${attribute.id}/options/${ + attribute.options[0]!.id + }/delete`, + ), + 200, + "Delete Option", + "Spring", + ); + + const { response } = await adminFormPost( + `/admin/attributes/${attribute.id}/options/${ + attribute.options[0]!.id + }/delete`, + { confirm_identifier: "Spring" }, + ); + await expectFlashRedirect( + `/admin/attributes/${attribute.id}`, + "Option deleted", + )(response); + expect((await getAttributeWithOptions(attribute.id))?.options).toEqual( + [], + ); + }); + + test("deletes an attribute after confirmation", async () => { + const attribute = await createTestAttributeWithOptions("Audience", [ + "Adults", + ]); + + await expectHtmlResponse( + await adminGet(`/admin/attributes/${attribute.id}/delete`), + 200, + "Delete Attribute", + "Audience", + ); + + const { response } = await adminFormPost( + `/admin/attributes/${attribute.id}/delete`, + { confirm_identifier: "Audience" }, + ); + await expectFlashRedirect( + "/admin/attributes", + "Attribute deleted", + )(response); + expect(await getAttributeWithOptions(attribute.id)).toBeNull(); + }); + }); + + describe("listing attributes tab", () => { + testRequiresAuth("/admin/listing/1/attributes", { + setup: async () => { + await createTestListing({ name: "Auth listing" }); + }, + }); + + testRequiresAuth("/admin/listing/1/attributes", { + body: { option_ids: "1" }, + method: "POST", + setup: async () => { + await createTestListing({ name: "Auth listing post" }); + }, + }); + + test("returns 404 for a missing listing", async () => { + expectStatus(404)(await adminGet("/admin/listing/999999/attributes")); + expectStatus(404)( + await postRepeatedOptions("/admin/listing/999999/attributes", [1]), + ); + }); + + test("shows the empty state when no attributes exist", async () => { + const listing = await createTestListing({ name: "No attributes" }); + + await expectHtmlResponse( + await adminGet(`/admin/listing/${listing.id}/attributes`), + 200, + "Attributes for No attributes", + "No attributes created yet.", + ); + }); + + test("shows available options and checked selections", async () => { + const listing = await createTestListing({ name: "Tagged listing" }); + const attribute = await createTestAttributeWithOptions("Difficulty", [ + "Easy", + "Hard", + ]); + await setListingAttributeOptions(listing.id, [attribute.options[1]!.id]); + + const html = await expectHtmlResponse( + await adminGet(`/admin/listing/${listing.id}/attributes`), + 200, + "Difficulty", + "Easy", + "Hard", + ); + expect(html).toContain( + `checked name="option_ids" type="checkbox" value="${ + attribute.options[1]!.id + }"`, + ); + }); + + test("shows attributes that do not have options yet", async () => { + const listing = await createTestListing({ name: "No option listing" }); + await createTestAttributeWithOptions("Audience", []); + + await expectHtmlResponse( + await adminGet(`/admin/listing/${listing.id}/attributes`), + 200, + "Audience", + "No options for this attribute yet.", + ); + }); + + test("saves repeated option ids and drops invalid ids", async () => { + const listing = await createTestListing({ name: "Saved attributes" }); + const attribute = await createTestAttributeWithOptions("Format", [ + "Online", + "In person", + ]); + + const response = await postRepeatedOptions( + `/admin/listing/${listing.id}/attributes`, + [attribute.options[0]!.id, 999_999, attribute.options[1]!.id], + ); + + await expectFlashRedirect( + `/admin/listing/${listing.id}/attributes`, + "Attributes updated", + )(response); + expect(await getListingAttributeOptionIds(listing.id)).toEqual( + attribute.options.map((option) => option.id), + ); + }); + }); +}); diff --git a/test/lib/server-listings-filter.test.ts b/test/lib/server-listings-filter.test.ts index cb82699a12..5377956d8b 100644 --- a/test/lib/server-listings-filter.test.ts +++ b/test/lib/server-listings-filter.test.ts @@ -4,6 +4,8 @@ import { handleRequest } from "#routes"; import { settings } from "#shared/db/settings.ts"; import { adminGet, + assignTestAttributeOptions, + createTestAttributeWithOptions, createTestListing, describeWithEnv, expectHtmlResponse, @@ -87,6 +89,82 @@ describeWithEnv("listings type filter", { db: true }, () => { expect(html).toContain(`href="/admin/listing/${daily.id}"`); expect(html).toContain("All"); }); + + test("filters the listing table by selected listing attribute", async () => { + const easyListing = await createTestListing({ name: "Easy Listing" }); + const hardListing = await createTestListing({ name: "Hard Listing" }); + const difficulty = await createTestAttributeWithOptions("Difficulty", [ + "Easy", + "Hard", + ]); + await assignTestAttributeOptions(easyListing.id, [ + difficulty.options[0]!, + ]); + await assignTestAttributeOptions(hardListing.id, [ + difficulty.options[1]!, + ]); + + const response = await adminGet( + `/admin?attribute_${difficulty.id}=${difficulty.options[0]!.id}`, + ); + const html = await response.text(); + + expect(html).toContain(`href="/admin/listing/${easyListing.id}"`); + expect(html).not.toContain(`href="/admin/listing/${hardListing.id}"`); + expect(html).toContain("Difficulty:"); + expect(html).toContain("Easy"); + expect(html).toContain( + `attribute_${difficulty.id}=${difficulty.options[1]!.id}`, + ); + }); + + test("keeps attribute filters in type links and type filters in attribute links", async () => { + const standard = await createTestListing({ name: "Standard Easy" }); + const daily = await createTestListing({ name: "Daily Hard", ...DAILY }); + const difficulty = await createTestAttributeWithOptions("Difficulty", [ + "Easy", + "Hard", + ]); + await assignTestAttributeOptions(standard.id, [difficulty.options[0]!]); + await assignTestAttributeOptions(daily.id, [difficulty.options[1]!]); + + const response = await adminGet( + `/admin?type=daily&attribute_${difficulty.id}=${ + difficulty.options[1]!.id + }`, + ); + const html = await response.text(); + + expect(html).toContain( + `/admin/?type=standard&attribute_${difficulty.id}=${ + difficulty.options[1]!.id + }`, + ); + expect(html).toContain(`href="/admin/?type=daily">All`); + }); + }); + + describe("admin listings index", () => { + test("filters listings by selected listing attribute", async () => { + const shown = await createTestListing({ name: "Shown" }); + const hidden = await createTestListing({ name: "Hidden" }); + const difficulty = await createTestAttributeWithOptions("Difficulty", [ + "Easy", + "Hard", + ]); + await assignTestAttributeOptions(shown.id, [difficulty.options[0]!]); + await assignTestAttributeOptions(hidden.id, [difficulty.options[1]!]); + + const response = await adminGet( + `/admin/listings?attribute_${difficulty.id}=${ + difficulty.options[0]!.id + }`, + ); + const html = await response.text(); + + expect(html).toContain(`href="/admin/listing/${shown.id}"`); + expect(html).not.toContain(`href="/admin/listing/${hidden.id}"`); + }); }); describe("public listings page", () => { @@ -105,5 +183,40 @@ describeWithEnv("listings type filter", { db: true }, () => { ); expect(html).not.toContain("Showing:"); }); + + test("shows selected listing attributes on listing cards", async () => { + const listing = await createTestListing({ name: "Attribute Card" }); + const difficulty = await createTestAttributeWithOptions("Difficulty", [ + "Easy", + ]); + await assignTestAttributeOptions(listing.id, difficulty.options); + + const html = await expectHtmlResponse( + await get("/listings"), + 200, + "Attribute Card", + "listing-attributes", + "Difficulty", + "Easy", + ); + expect(html).not.toContain("Showing:"); + }); + + test("shows selected listing attributes on a single ticket page", async () => { + const listing = await createTestListing({ name: "Ticket Attribute" }); + const difficulty = await createTestAttributeWithOptions("Difficulty", [ + "Easy", + ]); + await assignTestAttributeOptions(listing.id, difficulty.options); + + await expectHtmlResponse( + await get(`/ticket/${listing.slug}`), + 200, + "Ticket Attribute", + "listing-attributes", + "Difficulty", + "Easy", + ); + }); }); }); diff --git a/test/shared/db/attributes.test.ts b/test/shared/db/attributes.test.ts new file mode 100644 index 0000000000..6d8ff81edc --- /dev/null +++ b/test/shared/db/attributes.test.ts @@ -0,0 +1,177 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { + deleteAttribute, + deleteAttributeOption, + getAllAttributesWithOptions, + getAttributeWithOptions, + getListingAttributeOptionIds, + getSelectedAttributesForListings, + pruneInvalidAttributeOptionIds, + setListingAttributeOptions, + swapAttributeOptionOrder, + swapAttributeOrder, +} from "#shared/db/attributes.ts"; +import { + createTestAttribute, + createTestAttributeOption, + createTestAttributeWithOptions, + createTestListing, + describeWithEnv, +} from "#test-utils"; + +const names = (items: T[]): string[] => + items.map((item) => item.name); + +const optionTexts = (items: T[]): string[] => + items.map((item) => item.text); + +describeWithEnv("db > attributes", { db: true }, () => { + test("lists attributes and options in their display order", async () => { + const first = await createTestAttributeWithOptions("Difficulty", [ + "Beginner", + "Advanced", + ]); + const second = await createTestAttributeWithOptions("Format", [ + "Online", + "In person", + ]); + + await swapAttributeOrder(first.id, second.id); + await swapAttributeOptionOrder(first.options[0]!.id, first.options[1]!.id); + + const attributes = await getAllAttributesWithOptions(); + expect(names(attributes)).toEqual(["Format", "Difficulty"]); + expect(optionTexts(attributes[1]!.options)).toEqual([ + "Advanced", + "Beginner", + ]); + }); + + test("loads one attribute with its options, or null for a missing attribute", async () => { + const attribute = await createTestAttributeWithOptions("Access", [ + "Step-free", + ]); + + const found = await getAttributeWithOptions(attribute.id); + expect(found?.name).toBe("Access"); + expect(optionTexts(found?.options ?? [])).toEqual(["Step-free"]); + expect(await getAttributeWithOptions(999_999)).toBeNull(); + }); + + test("stores selected option ids for a listing and replaces them on save", async () => { + const listing = await createTestListing({ name: "Mapped listing" }); + const attribute = await createTestAttributeWithOptions("Level", [ + "Gentle", + "Intense", + ]); + + await setListingAttributeOptions(listing.id, [ + attribute.options[1]!.id, + attribute.options[1]!.id, + attribute.options[0]!.id, + ]); + expect(await getListingAttributeOptionIds(listing.id)).toEqual([ + attribute.options[0]!.id, + attribute.options[1]!.id, + ]); + + await setListingAttributeOptions(listing.id, [attribute.options[0]!.id]); + expect(await getListingAttributeOptionIds(listing.id)).toEqual([ + attribute.options[0]!.id, + ]); + }); + + test("resolves selected attributes for many listings", async () => { + const morning = await createTestListing({ name: "Morning" }); + const evening = await createTestListing({ name: "Evening" }); + const difficulty = await createTestAttributeWithOptions("Difficulty", [ + "Easy", + "Hard", + ]); + const place = await createTestAttributeWithOptions("Place", [ + "Studio", + "Online", + ]); + + await setListingAttributeOptions(morning.id, [ + difficulty.options[1]!.id, + place.options[0]!.id, + ]); + await setListingAttributeOptions(evening.id, [difficulty.options[0]!.id]); + + const byListing = await getSelectedAttributesForListings([ + morning.id, + evening.id, + morning.id, + ]); + expect(names(byListing.get(morning.id) ?? [])).toEqual([ + "Difficulty", + "Place", + ]); + expect(optionTexts(byListing.get(morning.id)?.[0]?.options ?? [])).toEqual([ + "Hard", + ]); + expect(names(byListing.get(evening.id) ?? [])).toEqual(["Difficulty"]); + expect(byListing.has(999_999)).toBe(false); + }); + + test("returns an empty map when no listing ids are requested", async () => { + expect(await getSelectedAttributesForListings([])).toEqual(new Map()); + }); + + test("deleting an option removes its listing assignments only", async () => { + const listing = await createTestListing({ name: "Option delete listing" }); + const attribute = await createTestAttributeWithOptions("Season", [ + "Spring", + "Autumn", + ]); + await setListingAttributeOptions( + listing.id, + attribute.options.map((option) => option.id), + ); + + await deleteAttributeOption(attribute.options[0]!.id); + + expect(await getListingAttributeOptionIds(listing.id)).toEqual([ + attribute.options[1]!.id, + ]); + const found = await getAttributeWithOptions(attribute.id); + expect(optionTexts(found?.options ?? [])).toEqual(["Autumn"]); + }); + + test("deleting an attribute removes its options and listing assignments", async () => { + const listing = await createTestListing({ + name: "Attribute delete listing", + }); + const attribute = await createTestAttributeWithOptions("Audience", [ + "Adults", + "Families", + ]); + await setListingAttributeOptions( + listing.id, + attribute.options.map((option) => option.id), + ); + + await deleteAttribute(attribute.id); + + expect(await getAttributeWithOptions(attribute.id)).toBeNull(); + expect(await getListingAttributeOptionIds(listing.id)).toEqual([]); + }); + + test("keeps only option ids that belong to known attributes", async () => { + const attribute = await createTestAttributeWithOptions("Food", [ + "Vegan", + "Gluten-free", + ]); + const hidden = await createTestAttribute("Hidden"); + await createTestAttributeOption(hidden.id, "Ignored"); + + expect( + pruneInvalidAttributeOptionIds( + [attribute], + [attribute.options[1]!.id, 123_456, attribute.options[0]!.id], + ), + ).toEqual([attribute.options[1]!.id, attribute.options[0]!.id]); + }); +}); diff --git a/test/shared/db/listings/delete.test.ts b/test/shared/db/listings/delete.test.ts index a7875758dc..2348ac8cea 100644 --- a/test/shared/db/listings/delete.test.ts +++ b/test/shared/db/listings/delete.test.ts @@ -26,7 +26,9 @@ import { saveAttendeeAnswers } from "#shared/db/questions/attendee-answers/save. import { setListingQuestions } from "#shared/db/questions/queries.ts"; import { answersTable, questionsTable } from "#shared/db/questions/tables.ts"; import { + assignTestAttributeOptions, createTestAttendee, + createTestAttributeWithOptions, createTestListing, describeWithEnv, expectNoDecryptedAttendees, @@ -193,6 +195,23 @@ describeWithEnv("db > listings", { db: true, triggers: true }, () => { expect(rows.map((r) => r.listing_id)).toEqual([listing2.id]); }); + test("removes the deleted listing's attribute assignments, keeping other listings'", async () => { + const listing1 = await createTestListing({ maxAttendees: 50 }); + const listing2 = await createTestListing({ maxAttendees: 50 }); + const attribute = await createTestAttributeWithOptions("Difficulty", [ + "Easy", + ]); + await assignTestAttributeOptions(listing1.id, attribute.options); + await assignTestAttributeOptions(listing2.id, attribute.options); + + await deleteListing(listing1.id); + + const rows = await queryAll<{ listing_id: number }>( + "SELECT listing_id FROM listing_attribute_options ORDER BY listing_id", + ); + expect(rows.map((r) => r.listing_id)).toEqual([listing2.id]); + }); + test("keeps the shared attendee's processed payment when one listing is deleted", async () => { const { attendeeId, listing1 } = await bookAttendeeOnTwoListings(); await reserveSession("sess_multi_listing"); diff --git a/test/shared/listing-attribute-filter.test.ts b/test/shared/listing-attribute-filter.test.ts new file mode 100644 index 0000000000..d9164f9bfa --- /dev/null +++ b/test/shared/listing-attribute-filter.test.ts @@ -0,0 +1,144 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import type { + AttributeOption, + AttributeWithOptions, + ListingAttributesById, +} from "#shared/db/attributes.ts"; +import { + attributeFilterGroupsForListings, + attributeFilterParam, + filterListingsByAttributes, + selectedAttributeFiltersFromRequest, +} from "#shared/listing-attribute-filter.ts"; +import { testListingWithCount } from "#test-utils"; + +const option = ( + attributeId: number, + id: number, + text: string, + sortOrder: number, +): AttributeOption => ({ + attribute_id: attributeId, + id, + sort_order: sortOrder, + text, +}); + +const attribute = ( + id: number, + name: string, + sortOrder: number, + options: AttributeOption[], +): AttributeWithOptions => ({ + id, + name, + options, + sort_order: sortOrder, +}); + +const listingAttributes = (): ListingAttributesById => { + const difficulty = attribute(1, "Difficulty", 2, [ + option(1, 12, "Hard", 2), + option(1, 11, "Easy", 1), + ]); + const place = attribute(2, "Place", 1, [option(2, 21, "Studio", 1)]); + return new Map([ + [101, [difficulty, place]], + [102, [attribute(1, "Difficulty", 2, [option(1, 12, "Hard", 2)])]], + ]); +}; + +describe("listing attribute filters", () => { + test("uses a stable query parameter name per attribute", () => { + expect(attributeFilterParam(42)).toBe("attribute_42"); + }); + + test("builds filter groups from the listings currently being shown", () => { + const filters = attributeFilterGroupsForListings( + [102, 101], + listingAttributes(), + ); + + expect(filters.map((filter) => filter.name)).toEqual([ + "Place", + "Difficulty", + ]); + expect(filters[1]!.options.map((item) => item.text)).toEqual([ + "Easy", + "Hard", + ]); + }); + + test("uses ids to break attribute and option sort ties", () => { + const filters = attributeFilterGroupsForListings( + [101], + new Map([ + [ + 101, + [ + attribute(2, "Second", 1, [ + option(2, 22, "Later", 1), + option(2, 21, "Earlier", 1), + ]), + attribute(1, "First", 1, [ + option(1, 12, "Later", 1), + option(1, 11, "Earlier", 1), + ]), + ], + ], + ]), + ); + + expect(filters.map((filter) => filter.id)).toEqual([1, 2]); + expect(filters[0]!.options.map((item) => item.id)).toEqual([11, 12]); + }); + + test("reads only valid selected options from the request", () => { + const filters = attributeFilterGroupsForListings( + [101], + listingAttributes(), + ); + const request = new Request( + "http://localhost/admin?attribute_1=11&attribute_2=999&attribute_3=21", + ); + + expect([...selectedAttributeFiltersFromRequest(request, filters)]).toEqual([ + [1, 11], + ]); + }); + + test("ignores missing and non-numeric selected option values", () => { + const filters = attributeFilterGroupsForListings( + [101], + listingAttributes(), + ); + const request = new Request("http://localhost/admin?attribute_1=abc"); + + expect(selectedAttributeFiltersFromRequest(request, filters).size).toBe(0); + }); + + test("keeps listings matching every selected attribute option", () => { + const a = testListingWithCount({ id: 101, name: "A" }); + const b = testListingWithCount({ id: 102, name: "B" }); + const c = testListingWithCount({ id: 103, name: "C" }); + + const result = filterListingsByAttributes( + new Map([ + [1, 11], + [2, 21], + ]), + listingAttributes(), + )([a, b, c]); + + expect(result).toEqual([a]); + }); + + test("passes listings through when no attribute filters are selected", () => { + const listings = [testListingWithCount({ id: 101 })]; + + expect( + filterListingsByAttributes(new Map(), listingAttributes())(listings), + ).toBe(listings); + }); +}); diff --git a/test/test-utils.ts b/test/test-utils.ts index 9a5975a3cb..b0aff28743 100644 --- a/test/test-utils.ts +++ b/test/test-utils.ts @@ -11,6 +11,7 @@ export * from "./test-utils/csrf.ts"; export * from "./test-utils/db.ts"; export * from "./test-utils/db-helpers/attendee-payments.ts"; export * from "./test-utils/db-helpers/attendees.ts"; +export * from "./test-utils/db-helpers/attributes.ts"; export * from "./test-utils/db-helpers/built-sites.ts"; export * from "./test-utils/db-helpers/groups.ts"; export * from "./test-utils/db-helpers/holidays.ts"; diff --git a/test/test-utils/db-helpers/attributes.ts b/test/test-utils/db-helpers/attributes.ts new file mode 100644 index 0000000000..7e8515fc1b --- /dev/null +++ b/test/test-utils/db-helpers/attributes.ts @@ -0,0 +1,49 @@ +import { + type AttributeOption, + type AttributeWithOptions, + assignNextAttributeSortOrder, + attributeOptionsTable, + attributesTable, + setListingAttributeOptions, +} from "#shared/db/attributes.ts"; + +export const createTestAttribute = async ( + name = "Test attribute", +): Promise => { + const attribute = await attributesTable.insert({ name }); + await assignNextAttributeSortOrder(attribute.id); + return { ...attribute, options: [] }; +}; + +export const createTestAttributeOption = ( + attributeId: number, + text: string, + sortOrder = 0, +): Promise => + attributeOptionsTable.insert({ + attributeId, + sortOrder, + text, + }); + +export const createTestAttributeWithOptions = async ( + name: string, + optionTexts: string[], +): Promise => { + const attribute = await createTestAttribute(name); + const options = await Promise.all( + optionTexts.map((text, sortOrder) => + createTestAttributeOption(attribute.id, text, sortOrder), + ), + ); + return { ...attribute, options }; +}; + +export const assignTestAttributeOptions = ( + listingId: number, + options: AttributeOption[], +): Promise => + setListingAttributeOptions( + listingId, + options.map((option) => option.id), + ); diff --git a/test/ui/templates/admin/listing-attribute-filters.test.ts b/test/ui/templates/admin/listing-attribute-filters.test.ts new file mode 100644 index 0000000000..41121b2d7c --- /dev/null +++ b/test/ui/templates/admin/listing-attribute-filters.test.ts @@ -0,0 +1,62 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { + attributeFilterHref, + emptyAttributeFilterView, + renderAttributeFilterBars, + typeFilterHref, +} from "#templates/admin/listing-attribute-filters.ts"; + +describe("listing attribute filter template helpers", () => { + test("creates an empty filter view", () => { + const view = emptyAttributeFilterView(); + + expect(view.activeAttributeFilters.size).toBe(0); + expect(view.attributeFilters).toEqual([]); + expect(view.attributesByListing.size).toBe(0); + }); + + test("keeps active attribute filters in type filter links", () => { + const href = typeFilterHref("/admin/", new Map([[1, 11]])); + + expect(href("daily")).toBe("/admin/?type=daily&attribute_1=11"); + expect(href("all")).toBe("/admin/?attribute_1=11"); + }); + + test("adds and removes one attribute filter while keeping the type filter", () => { + const href = attributeFilterHref("/admin/", "daily", new Map([[1, 11]])); + + expect(href(2, 21)).toBe( + "/admin/?type=daily&attribute_1=11&attribute_2=21", + ); + expect(href(1, null)).toBe("/admin/?type=daily"); + }); + + test("renders escaped filter bars with active options", () => { + const html = renderAttributeFilterBars( + [ + { + id: 1, + name: "A & B", + options: [ + { id: 11, sort_order: 1, text: "Easy " }, + { id: 12, sort_order: 2, text: "Hard" }, + ], + sort_order: 1, + }, + ], + new Map([[1, 12]]), + (attributeId, optionId) => + optionId === null + ? `/admin/?clear=${attributeId}` + : `/admin/?attribute_${attributeId}=${optionId}`, + ); + + expect(html).toContain("A & B:"); + expect(html).toContain( + 'href="/admin/?attribute_1=11">Easy <Hard>', + ); + expect(html).toContain("Hard"); + expect(html).toContain('href="/admin/?clear=1">All'); + }); +}); From eabc0713b5051c2c1134089657a8d542dbed0271 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 10 Jul 2026 01:42:18 +0100 Subject: [PATCH 02/11] Save WIP --- src/features/admin/attributes.ts | 38 +++++--- src/features/admin/dashboard.ts | 17 ++-- src/shared/db/attributes.ts | 146 +++++++++++++++++++---------- src/ui/static/style.scss | 4 +- test/lib/server-attributes.test.ts | 10 +- test/shared/db/attributes.test.ts | 23 +++-- 6 files changed, 153 insertions(+), 85 deletions(-) diff --git a/src/features/admin/attributes.ts b/src/features/admin/attributes.ts index 2bbf351314..6fe30488ea 100644 --- a/src/features/admin/attributes.ts +++ b/src/features/admin/attributes.ts @@ -30,7 +30,10 @@ import { attributesTable, deleteAttribute, deleteAttributeOption, + getAllAttributeOptionIds, getAllAttributesWithOptions, + getAttributeId, + getAttributeIdsOrdered, getAttributeWithOptions, getNextAttributeOptionSortOrder, pruneInvalidAttributeOptionIds, @@ -128,6 +131,17 @@ const withAttribute = async ( return attribute ? handle(attribute) : notFoundResponse(); }; +const logAttributeOptionActivity = ( + optionText: string, + action: string, + attribute: AttributeWithOptions, +) => + logActivity( + `Attribute option '${optionText}' ${action} ${attributeNameFlat( + attribute.name, + )}`, + ); + const handleAttributeEdit = createAuthedFormRoute< { name: string }, AttributeParams @@ -161,7 +175,7 @@ const handleAddOption = createAuthedFormRoute< sortOrder: await getNextAttributeOptionSortOrder(params.id), text, }); - await logActivity(`Attribute option '${text}' added to ${attribute.id}`); + await logAttributeOptionActivity(text, "added to", attribute); return redirect(`/admin/attributes/${params.id}`, "Option added", true); }), }); @@ -231,9 +245,7 @@ const handleDeleteOptionPost = createVerifiedFormRoute< mismatchRedirect: (_context, params) => optionDeletePath(params), onConfirm: async ({ context: { attribute, option } }) => { await deleteAttributeOption(option.id); - await logActivity( - `Attribute option '${option.text}' deleted from ${attribute.id}`, - ); + await logAttributeOptionActivity(option.text, "deleted from", attribute); return redirect( `/admin/attributes/${attribute.id}`, "Option deleted", @@ -257,7 +269,7 @@ const handleEditOptionPost = createAuthedFormRoute< errorRedirect(editOptionPath(params), error), onValid: async ({ context: { attribute, option }, values: { text } }) => { await attributeOptionsTable.update(option.id, { text }); - await logActivity(`Attribute option '${text}' updated in ${attribute.id}`); + await logAttributeOptionActivity(text, "updated in", attribute); return redirect( `/admin/attributes/${attribute.id}`, "Option updated", @@ -286,16 +298,16 @@ const moveOptionHandler = (direction: -1 | 1) => }); const moveAttributeHandler = (direction: -1 | 1) => - createAuthedHandler({ + createAuthedHandler({ auth: OWNER_FORM, - handle: async ({ context: attribute }) => { - const attributes = await getAllAttributesWithOptions(); - const index = attributes.findIndex((item) => item.id === attribute.id); - const neighbor = attributes[index + direction]; - if (neighbor) await swapAttributeOrder(attribute.id, neighbor.id); + handle: async ({ context: attributeId }) => { + const attributeIds = await getAttributeIdsOrdered(); + const index = attributeIds.indexOf(attributeId); + const neighborId = attributeIds[index + direction]; + if (neighborId) await swapAttributeOrder(attributeId, neighborId); return redirect("/admin/attributes", "Attribute moved", true); }, - loadContext: ({ id }) => getAttributeWithOptions(id), + loadContext: ({ id }) => getAttributeId(id), }); const handleListingAttributesPost = createListingChoicePost({ @@ -304,7 +316,7 @@ const handleListingAttributesPost = createListingChoicePost({ noun: "option", readIds: async (form) => pruneInvalidAttributeOptionIds( - await getAllAttributesWithOptions(), + await getAllAttributeOptionIds(), form.getNumberArray("option_ids"), ), saveIds: setListingAttributeOptions, diff --git a/src/features/admin/dashboard.ts b/src/features/admin/dashboard.ts index b7cada0a9c..0a9199060a 100644 --- a/src/features/admin/dashboard.ts +++ b/src/features/admin/dashboard.ts @@ -2,7 +2,7 @@ * Admin dashboard route */ -import { compact, unique } from "#fp"; +import { compact, filter, unique } from "#fp"; import { csvResponse, loadAttendeeLinkRefs } from "#routes/admin/actions.ts"; import { generateListingsCsv } from "#routes/admin/listings-csv.ts"; import { @@ -85,11 +85,10 @@ const loadSortedListings = async () => { const loadListingAttributeFilterContext = async ( request: Request, - listings: ListingWithCount[], filterSource: ListingWithCount[], ): Promise => { const attributesByListing = await getSelectedAttributesForListings( - listings.map((listing) => listing.id), + filterSource.map((listing) => listing.id), ); const attributeFilters = attributeFilterGroupsForListings( filterSource.map((listing) => listing.id), @@ -136,7 +135,9 @@ const handleAdminGet = (request: Request): Promise => // builder emits would be rejected by the server. A `bookable_alone` child // has its own page, so it stays bookable here. const listingIds = sortedListings.map((l) => l.id); - const activeListings = sortedListings.filter((listing) => listing.active); + const activeListings = filter( + (listing: ListingWithCount) => listing.active, + )(sortedListings); const [ childIds, hiddenMemberIds, @@ -146,11 +147,7 @@ const handleAdminGet = (request: Request): Promise => getNonStandaloneChildIds(listingIds), getHiddenPackageMemberIds(listingIds), getUpcomingServicingEvents(privateKey, todayInTz(settings.timezone)), - loadListingAttributeFilterContext( - request, - sortedListings, - activeListings, - ), + loadListingAttributeFilterContext(request, activeListings), ]); const unbookableIds = new Set([...childIds, ...hiddenMemberIds]); return htmlResponse( @@ -183,7 +180,7 @@ const handleAdminListingsGet: TypedRouteHandler<"GET /admin/listings"> = listings, session, settings.listingColumnOrder, - await loadListingAttributeFilterContext(request, listings, listings), + await loadListingAttributeFilterContext(request, listings), ); }); diff --git a/src/shared/db/attributes.ts b/src/shared/db/attributes.ts index 528198dbbe..4e580b0873 100644 --- a/src/shared/db/attributes.ts +++ b/src/shared/db/attributes.ts @@ -102,10 +102,9 @@ type SelectedAttributeRow = JoinedAttributeRow & { listing_id: number; }; -type AttributeGroup = { - name: string; - sortOrder: number; - options: AttributeOption[]; +type DecryptedAttributeRows = { + attributes: Map; + options: Map; }; const rowOption = (row: JoinedAttributeRow): AttributeOption | null => @@ -118,43 +117,86 @@ const rowOption = (row: JoinedAttributeRow): AttributeOption | null => text: row.option_text!, }; -const collectAttributeRow = ( - acc: Map, +const rowAttribute = (row: JoinedAttributeRow): Attribute => ({ + id: row.attribute_id, + name: row.attribute_name, + sort_order: row.attribute_sort_order, +}); + +const collectUniqueAttributes = ( + acc: Map, row: JoinedAttributeRow, -): Map => { - const group = acc.get(row.attribute_id) ?? { - name: row.attribute_name, - options: [], - sortOrder: row.attribute_sort_order, - }; +): Map => + acc.has(row.attribute_id) + ? acc + : acc.set(row.attribute_id, rowAttribute(row)); + +const collectUniqueOptions = ( + acc: Map, + row: JoinedAttributeRow, +): Map => { const option = rowOption(row); - if (option) group.options.push(option); - return acc.set(row.attribute_id, group); + return option && !acc.has(option.id) ? acc.set(option.id, option) : acc; }; -const decryptAttribute = async ( - id: number, - group: AttributeGroup, -): Promise => { - const [attribute, ...options] = await Promise.all([ - attributesTable.fromDb({ - id, - name: group.name, - sort_order: group.sortOrder, - }), - ...group.options.map((option) => attributeOptionsTable.fromDb(option)), +const decryptAttributeRows = async ( + rows: JoinedAttributeRow[], +): Promise => { + const [attributes, options] = await Promise.all([ + Promise.all( + [ + ...reduce(collectUniqueAttributes, new Map())(rows), + ].map( + async ([id, attribute]) => + [id, await attributesTable.fromDb(attribute)] as const, + ), + ), + Promise.all( + [ + ...reduce( + collectUniqueOptions, + new Map(), + )(rows), + ].map( + async ([id, option]) => + [id, await attributeOptionsTable.fromDb(option)] as const, + ), + ), ]); - return { ...attribute, options }; + return { + attributes: new Map(attributes), + options: new Map(options), + }; }; +const known = (items: Map, id: number): T => items.get(id)!; + +const buildAttributeGroups = ( + rows: JoinedAttributeRow[], + decrypted: DecryptedAttributeRows, +): AttributeWithOptions[] => [ + ...reduce( + (acc: Map, row: JoinedAttributeRow) => { + const group = acc.get(row.attribute_id) ?? { + ...known(decrypted.attributes, row.attribute_id), + options: [], + }; + if (row.option_id !== null) { + group.options.push(known(decrypted.options, row.option_id)); + } + return acc.set(row.attribute_id, group); + }, + new Map(), + )(rows).values(), +]; + +const queryIds = async (sql: string): Promise => + map((row: { id: number }) => row.id)(await queryAll<{ id: number }>(sql)); + const groupAttributeRows = async ( rows: JoinedAttributeRow[], ): Promise => - Promise.all( - [ - ...reduce(collectAttributeRow, new Map())(rows), - ].map(([id, group]) => decryptAttribute(id, group)), - ); + buildAttributeGroups(rows, await decryptAttributeRows(rows)); export const getAllAttributesWithOptions = async (): Promise< AttributeWithOptions[] @@ -186,6 +228,20 @@ export const getAttributeWithOptions = async ( return attribute ?? null; }; +export const getAttributeId = async (id: number): Promise => + ( + await queryOne<{ id: number }>( + "SELECT id FROM attributes WHERE id = ? LIMIT 1", + [id], + ) + )?.id ?? null; + +export const getAttributeIdsOrdered = async (): Promise => + queryIds("SELECT id FROM attributes ORDER BY sort_order, id"); + +export const getAllAttributeOptionIds = async (): Promise> => + new Set(await queryIds("SELECT id FROM attribute_options")); + export const assignNextAttributeSortOrder = async ( attributeId: number, ): Promise => { @@ -279,16 +335,14 @@ const selectedRowsForListing = ( export const getSelectedAttributesForListings = async ( listingIds: number[], ): Promise => { - const rowsByListing = selectedRowsForListing( - await selectedOptionRows(unique(listingIds)), - ); - const entries = await Promise.all( - [...rowsByListing].map( - async ([listingId, rows]) => - [listingId, await groupAttributeRows(rows)] as const, + const rows = await selectedOptionRows(unique(listingIds)); + const decrypted = await decryptAttributeRows(rows); + return new Map( + [...selectedRowsForListing(rows)].map( + ([listingId, listingRows]) => + [listingId, buildAttributeGroups(listingRows, decrypted)] as const, ), ); - return new Map(entries); }; export const getListingAttributeOptionIds = ( @@ -301,17 +355,7 @@ export const setListingAttributeOptions = async ( ): Promise => listingAttributeOptions.setIds(listingId, unique(optionIds)); -export const optionIdsForAttributes = ( - attributes: AttributeWithOptions[], -): number[] => - attributes.flatMap((attribute) => - map((option: AttributeOption) => option.id)(attribute.options), - ); - export const pruneInvalidAttributeOptionIds = ( - allAttributes: AttributeWithOptions[], + validOptionIds: Set, optionIds: number[], -): number[] => { - const valid = new Set(optionIdsForAttributes(allAttributes)); - return optionIds.filter((id) => valid.has(id)); -}; +): number[] => optionIds.filter((id) => validOptionIds.has(id)); diff --git a/src/ui/static/style.scss b/src/ui/static/style.scss index eac4b59591..252b904edf 100644 --- a/src/ui/static/style.scss +++ b/src/ui/static/style.scss @@ -237,14 +237,14 @@ article > * { .listing-attributes { display: grid; - gap: var(--space-xs); + gap: calc(var(--space-s) / 2); margin-block: var(--space-s); } .listing-attributes > div { display: flex; flex-wrap: wrap; - gap: 0 var(--space-xs); + gap: 0 calc(var(--space-s) / 2); } .listing-attributes dt { diff --git a/test/lib/server-attributes.test.ts b/test/lib/server-attributes.test.ts index b891dc7ab3..d79ee07843 100644 --- a/test/lib/server-attributes.test.ts +++ b/test/lib/server-attributes.test.ts @@ -144,6 +144,9 @@ describeWithEnv("server (admin attributes)", { db: true }, () => { }) ).response, ); + expectStatus(404)( + (await adminFormPost("/admin/attributes/999999/move-up")).response, + ); }); test("adds, edits, and reorders options", async () => { @@ -365,7 +368,12 @@ describeWithEnv("server (admin attributes)", { db: true }, () => { const response = await postRepeatedOptions( `/admin/listing/${listing.id}/attributes`, - [attribute.options[0]!.id, 999_999, attribute.options[1]!.id], + [ + attribute.options[0]!.id, + attribute.options[0]!.id, + 999_999, + attribute.options[1]!.id, + ], ); await expectFlashRedirect( diff --git a/test/shared/db/attributes.test.ts b/test/shared/db/attributes.test.ts index 6d8ff81edc..804b4887c8 100644 --- a/test/shared/db/attributes.test.ts +++ b/test/shared/db/attributes.test.ts @@ -3,6 +3,7 @@ import { it as test } from "@std/testing/bdd"; import { deleteAttribute, deleteAttributeOption, + getAllAttributeOptionIds, getAllAttributesWithOptions, getAttributeWithOptions, getListingAttributeOptionIds, @@ -159,19 +160,25 @@ describeWithEnv("db > attributes", { db: true }, () => { expect(await getListingAttributeOptionIds(listing.id)).toEqual([]); }); - test("keeps only option ids that belong to known attributes", async () => { + test("keeps only option ids that exist", async () => { const attribute = await createTestAttributeWithOptions("Food", [ "Vegan", "Gluten-free", ]); - const hidden = await createTestAttribute("Hidden"); - await createTestAttributeOption(hidden.id, "Ignored"); + const other = await createTestAttribute("Other"); + const otherOption = await createTestAttributeOption(other.id, "Other"); expect( - pruneInvalidAttributeOptionIds( - [attribute], - [attribute.options[1]!.id, 123_456, attribute.options[0]!.id], - ), - ).toEqual([attribute.options[1]!.id, attribute.options[0]!.id]); + pruneInvalidAttributeOptionIds(await getAllAttributeOptionIds(), [ + attribute.options[1]!.id, + 123_456, + otherOption.id, + attribute.options[0]!.id, + ]), + ).toEqual([ + attribute.options[1]!.id, + otherOption.id, + attribute.options[0]!.id, + ]); }); }); From 594fdfbebd164e686e37394bf2dc9426068cda3e Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 10 Jul 2026 02:15:04 +0100 Subject: [PATCH 03/11] Show listing attributes on order cards and multi-listing ticket pages The order gallery and multi-listing ticket pages loaded attributes for every listing but never rendered them. Now each order card shows its selected attributes, and each listing row on a multi-listing ticket page shows its own attributes (the single-listing header already did). --- src/features/public/order.ts | 5 +++++ src/ui/templates/public/order-gallery.tsx | 22 +++++++++++++++------- src/ui/templates/public/reservations.tsx | 8 ++++++++ test/lib/server-listings-filter.test.ts | 19 +++++++++++++++++++ test/lib/server-order.test.ts | 16 ++++++++++++++++ 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/features/public/order.ts b/src/features/public/order.ts index b507ba7a09..5fbd72a93a 100644 --- a/src/features/public/order.ts +++ b/src/features/public/order.ts @@ -42,6 +42,7 @@ import type { TicketListing } from "#shared/booking/model.ts"; import { getBookableStartDates } from "#shared/dates.ts"; import { getGroupRemainingForSpan } from "#shared/db/attendees/capacity.ts"; import { getListingRemainingForRange } from "#shared/db/attendees.ts"; +import { getSelectedAttributesForListings } from "#shared/db/attributes.ts"; import { getGroupIdsByListingIds, getGroupPackagePricesByGroupIds, @@ -404,6 +405,9 @@ const handleOrder = withEvaluatedOrder(async (catalog, evaluation) => { ); if (bookingUrl !== null) return redirectResponse(bookingUrl); + const attributesByListing = await getSelectedAttributesForListings( + catalog.ticketListings.map((info) => info.listing.id), + ); return htmlResponse( orderGalleryPage( catalog.ticketListings, @@ -416,6 +420,7 @@ const handleOrder = withEvaluatedOrder(async (catalog, evaluation) => { await publicNavProps(null), settings.websiteTitle, settings.orderIntroText || null, + attributesByListing, ), ); }); diff --git a/src/ui/templates/public/order-gallery.tsx b/src/ui/templates/public/order-gallery.tsx index e8598764dd..85c1c19165 100644 --- a/src/ui/templates/public/order-gallery.tsx +++ b/src/ui/templates/public/order-gallery.tsx @@ -3,6 +3,7 @@ import { map, pipe } from "#fp"; import { t } from "#i18n"; import type { TicketListing } from "#shared/booking/model.ts"; import { formatCurrency } from "#shared/currency.ts"; +import type { ListingAttributesById } from "#shared/db/attributes.ts"; import { isReadOnly } from "#shared/env.ts"; import { Raw } from "#shared/jsx/jsx-runtime.ts"; import { renderMarkdown } from "#shared/markdown.ts"; @@ -17,6 +18,7 @@ import type { Group, ListingWithCount } from "#shared/types.ts"; import { Icon, type IconName } from "#templates/components/actions.tsx"; import { CARD_GRID_CLASS, cardInner } from "#templates/components/card.tsx"; import { escapeHtml } from "#templates/layout.tsx"; +import { renderListingAttributes } from "./listing-attributes.ts"; import { compareGroupsByName, PackagesSection, @@ -93,9 +95,10 @@ const unavailableCard = ( imageHtml: string, name: string, status: string, + attributesHtml = "", ): string => `
${cardInner({ - detailHtml: `${status}`, + detailHtml: `${attributesHtml}${status}`, imageHtml, name, })} @@ -108,7 +111,7 @@ const unavailableCard = ( * non-selectable card so they can't be added to an order. */ const renderOrderCard = - (states: OrderGalleryStates) => + (states: OrderGalleryStates, attributesByListing: ListingAttributesById) => (info: TicketListing): string => { const { listing, isSoldOut, isClosed } = info; const imageHtml = renderListingImage(listing, "card-image", { @@ -120,16 +123,19 @@ const renderOrderCard = listing.can_pay_more ? t("availability.from_prefix") : "" }${escapeHtml(formatCurrency(listing.unit_price))}` : ""; + const attributesHtml = renderListingAttributes( + attributesByListing.get(listing.id), + ); if (isSoldOut || isClosed || isReadOnly()) { const status = isSoldOut && !isClosed ? t("public.sold_out") : t("public.unavailable"); - return unavailableCard(imageHtml, listing.name, status); + return unavailableCard(imageHtml, listing.name, status, attributesHtml); } const key = listingOptionKey(listing.id); return selectableCard({ - detailHtml: priceHtml, + detailHtml: `${priceHtml}${attributesHtml}`, fieldName: `${SELECT_PREFIX}${listing.id}`, imageHtml, key, @@ -196,12 +202,14 @@ export const orderGalleryPage = ( nav: PublicNavProps, websiteTitle: string, introText?: string | null, + attributesByListing: ListingAttributesById = new Map(), ): string => { const orderTitle = t("nav.public.order"); const title = websiteTitle ? `${orderTitle} - ${websiteTitle}` : orderTitle; - const cards = pipe(map(renderOrderCard(states)), (rows) => rows.join(""))( - listings, - ); + const cards = pipe( + map(renderOrderCard(states, attributesByListing)), + (rows) => rows.join(""), + )(listings); const packageCards = pipe(map(renderOrderPackageCard(states)), (rows) => rows.join(""), )(packages.toSorted((a, b) => compareGroupsByName(a.group, b.group))); diff --git a/src/ui/templates/public/reservations.tsx b/src/ui/templates/public/reservations.tsx index e2339b88c5..cf1695654e 100644 --- a/src/ui/templates/public/reservations.tsx +++ b/src/ui/templates/public/reservations.tsx @@ -791,6 +791,7 @@ const renderListingRow = ( hideQuantity = false, prefill?: TicketPrefill, childCtx?: ChildRenderCtx, + attributesHtml = "", ): string => { const { listing, isSoldOut, isClosed } = info; const imageHtml = renderListingImage(listing); @@ -811,6 +812,7 @@ const renderListingRow = ( ${imageHtml} ${renderListingDescription(listing.description)} + ${attributesHtml} ${t("public.sold_out")}
`; @@ -829,6 +831,7 @@ const renderListingRow = ( ${imageHtml} ${renderListingDescription(listing.description)} + ${attributesHtml} ${priceHtml} ${childBlock} @@ -1458,6 +1461,7 @@ const buildListingRows = ( hideQuantity: boolean, prefill: BookingPrefill | undefined, childCtxFor: (info: TicketListing) => ChildRenderCtx | undefined, + attributesByListing: ListingAttributesById = new Map(), ): string => isSingleListing ? renderSingleListingControls( @@ -1475,6 +1479,7 @@ const buildListingRows = ( hideQuantity, prefill?.listings.get(e.listing.id), childCtxFor(e), + renderListingAttributes(attributesByListing.get(e.listing.id)), ), ) .join(""); @@ -1496,6 +1501,7 @@ const buildPageListingRows = (opts: { hideQuantity: boolean; prefill?: BookingPrefill | undefined; childCtx?: ChildRenderCtx | undefined; + attributesByListing?: ListingAttributesById; }): string => { const membersOf = (pkg: PagePackage): TicketListing[] => { const memberIds = new Set(pkg.memberListingIds); @@ -1553,6 +1559,7 @@ const buildPageListingRows = (opts: { opts.hideQuantity, opts.prefill, (info) => (memberIds.has(info.listing.id) ? undefined : opts.childCtx), + opts.attributesByListing ?? new Map(), ) ); }; @@ -1775,6 +1782,7 @@ export const ticketPage = ({ // rows (each ×its fixed quantity); a mixed page shows each package as a titled // section above the per-listing controls. const listingRows = buildPageListingRows({ + attributesByListing, childCtx, hideQuantity, isSingleListing, diff --git a/test/lib/server-listings-filter.test.ts b/test/lib/server-listings-filter.test.ts index 5377956d8b..db68d47560 100644 --- a/test/lib/server-listings-filter.test.ts +++ b/test/lib/server-listings-filter.test.ts @@ -218,5 +218,24 @@ describeWithEnv("listings type filter", { db: true }, () => { "Easy", ); }); + + test("shows each listing's attributes on a multi-listing ticket page", async () => { + const listing1 = await createTestListing({ name: "Multi Attribute One" }); + const listing2 = await createTestListing({ name: "Multi Attribute Two" }); + const format = await createTestAttributeWithOptions("Format", [ + "In person", + ]); + await assignTestAttributeOptions(listing1.id, format.options); + + await expectHtmlResponse( + await get(`/ticket/${listing1.slug}+${listing2.slug}`), + 200, + "Multi Attribute One", + "Multi Attribute Two", + "listing-attributes", + "Format", + "In person", + ); + }); }); }); diff --git a/test/lib/server-order.test.ts b/test/lib/server-order.test.ts index ab7a885d29..21aba3a8c2 100644 --- a/test/lib/server-order.test.ts +++ b/test/lib/server-order.test.ts @@ -5,8 +5,10 @@ import { groups } from "#shared/db/groups.ts"; import { settings } from "#shared/db/settings.ts"; import { assertPublicHtml, + assignTestAttributeOptions, createDailyTestListing, createTestAttendee, + createTestAttributeWithOptions, createTestGroup, createTestListing, deactivateTestListing, @@ -117,6 +119,20 @@ describeWithEnv("server (public order)", { db: true, triggers: true }, () => { expect(html).toContain("From "); }); + test("shows selected listing attributes on order cards", async () => { + const listing = await createTestListing({ name: "Badge Card" }); + const format = await createTestAttributeWithOptions("Format", ["Online"]); + await assignTestAttributeOptions(listing.id, format.options); + + await assertPublicHtml( + "/order", + "Badge Card", + "listing-attributes", + "Format", + "Online", + ); + }); + test("marks a sold-out listing as unavailable and non-selectable", async () => { const sold = await createTestListing({ maxAttendees: 1, name: "Gone" }); await createTestAttendee(sold.id, sold.slug, "Buyer", "b@example.com"); From 84b966b46479b358cdd781441ef2d72457215ea2 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 10 Jul 2026 07:53:08 +0100 Subject: [PATCH 04/11] Address CodeRabbit follow-up: attributes on package rows and closed rows, CSV export filter, remove alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Thread attributesByListing through renderPackageMemberRow so package member rows show their selected attributes (same as standalone rows) - Render attributesHtml in the isClosed branch of renderListingRow so closed listings still show their attributes on multi-listing pages - Carry the active type + attribute filters through to the CSV export link and handler so the download stays aligned with the filtered table - Remove getListingAttributeOptionIds alias export (per AGENTS.md — callers use listingAttributeOptions.getIds directly) - Strengthen the multi-listing attribute test to assign distinct attributes to both listings --- src/features/admin/dashboard.ts | 12 ++++- src/features/admin/listing-page-data.ts | 4 +- src/shared/db/attributes.ts | 4 -- src/ui/templates/admin/dashboard.tsx | 6 ++- .../admin/listing-attribute-filters.ts | 11 +++++ src/ui/templates/public/reservations.tsx | 16 ++++++- test/lib/server-attributes.test.ts | 4 +- test/lib/server-listings-filter.test.ts | 46 +++++++++++++++++++ test/shared/db/attributes.test.ts | 10 ++-- 9 files changed, 96 insertions(+), 17 deletions(-) diff --git a/src/features/admin/dashboard.ts b/src/features/admin/dashboard.ts index 0a9199060a..afbe132d9e 100644 --- a/src/features/admin/dashboard.ts +++ b/src/features/admin/dashboard.ts @@ -37,6 +37,7 @@ import { settings } from "#shared/db/settings.ts"; import { getFlash } from "#shared/flash-context.ts"; import { attributeFilterGroupsForListings, + filterListingsByAttributes, selectedAttributeFiltersFromRequest, } from "#shared/listing-attribute-filter.ts"; import { @@ -185,14 +186,21 @@ const handleAdminListingsGet: TypedRouteHandler<"GET /admin/listings"> = }); /** Handle GET /admin/listings/csv — export every listing (filtered by the same - * ?type= category filter the listings views use) as a CSV download. */ + * ?type= category and attribute filters the listings views use) as a CSV + * download. */ const handleListingsCsvExport: TypedRouteHandler<"GET /admin/listings/csv"> = ( request, ) => requireSessionOr(request, async () => { const type = listingTypeFromRequest(request); const listings = filterListingsByType(type)(await loadSortedListings()); - const csv = generateListingsCsv(listings, settings.timezone); + const { activeAttributeFilters, attributesByListing } = + await loadListingAttributeFilterContext(request, listings); + const filteredListings = filterListingsByAttributes( + activeAttributeFilters, + attributesByListing, + )(listings); + const csv = generateListingsCsv(filteredListings, settings.timezone); const suffix = type === "all" ? "" : `_${type}`; await logActivity( `Listings CSV exported${type === "all" ? "" : ` (type: ${type})`}`, diff --git a/src/features/admin/listing-page-data.ts b/src/features/admin/listing-page-data.ts index ee52dda0eb..c3dc8c9f01 100644 --- a/src/features/admin/listing-page-data.ts +++ b/src/features/admin/listing-page-data.ts @@ -26,7 +26,7 @@ import { } from "#shared/db/attendees.ts"; import { getAllAttributesWithOptions, - getListingAttributeOptionIds, + listingAttributeOptions, } from "#shared/db/attributes.ts"; import { getHiddenPackageMemberIds } from "#shared/db/groups.ts"; import { getListingOverviewStats } from "#shared/db/listing-overview-stats.ts"; @@ -397,7 +397,7 @@ export const loadListingQuestionsPanel = listingChoicePanelLoader( * listing. `error` is set only on an in-place 400 re-render. */ export const loadListingAttributesPanel = listingChoicePanelLoader( getAllAttributesWithOptions, - getListingAttributeOptionIds, + listingAttributeOptions.getIds, (listing, attributes, selectedOptionIds, error) => ListingAttributesPanel({ attributes, error, listing, selectedOptionIds }), ); diff --git a/src/shared/db/attributes.ts b/src/shared/db/attributes.ts index 4e580b0873..e66a2aa084 100644 --- a/src/shared/db/attributes.ts +++ b/src/shared/db/attributes.ts @@ -345,10 +345,6 @@ export const getSelectedAttributesForListings = async ( ); }; -export const getListingAttributeOptionIds = ( - listingId: number, -): Promise => listingAttributeOptions.getIds(listingId); - export const setListingAttributeOptions = async ( listingId: number, optionIds: number[], diff --git a/src/ui/templates/admin/dashboard.tsx b/src/ui/templates/admin/dashboard.tsx index cdbc0c1849..aee5269118 100644 --- a/src/ui/templates/admin/dashboard.tsx +++ b/src/ui/templates/admin/dashboard.tsx @@ -41,6 +41,7 @@ import { AdminPage, flashAdminPage } from "#templates/admin/admin-page.tsx"; import { HolidayTable } from "#templates/admin/holidays.tsx"; import { attributeFilterHref, + csvExportHref, emptyAttributeFilterView, type ListingAttributeFilterView, renderAttributeFilterBars, @@ -326,10 +327,12 @@ const ListingsTableBlock = ({ columnKeys, filters, csvExport = false, + csvHref = "/admin/listings/csv", headerHtml = "", columns = LISTING_TABLE_COLUMNS, }: ListingTableArgs & { csvExport?: boolean; + csvHref?: string; headerHtml?: string; }): JSX.Element => (
@@ -339,7 +342,7 @@ const ListingsTableBlock = ({ /> {csvExport && ( )}
@@ -485,6 +488,7 @@ export const adminListingsPage = ( columnKeys={columnKeys} columns={columns} csvExport={!isEditor} + csvHref={csvExportHref("all", activeAttributeFilters)} filters={filters} headerHtml={attributeFilterHtml} listings={filterByAttribute(activeListings)} diff --git a/src/ui/templates/admin/listing-attribute-filters.ts b/src/ui/templates/admin/listing-attribute-filters.ts index 5e2c723db2..3d9203d93d 100644 --- a/src/ui/templates/admin/listing-attribute-filters.ts +++ b/src/ui/templates/admin/listing-attribute-filters.ts @@ -57,6 +57,17 @@ export const attributeFilterHref = return hrefWithParams(path, params); }; +/** Build the CSV-export URL so it carries the current type and attribute + * filters through, keeping the download aligned with the filtered table. */ +export const csvExportHref = ( + activeType: ListingFilter, + activeAttributes: SelectedAttributeFilters, +): string => + hrefWithParams( + "/admin/listings/csv", + filterParams(activeType, activeAttributes), + ); + export const renderAttributeFilterBars = ( filters: AttributeFilterGroup[], activeFilters: SelectedAttributeFilters, diff --git a/src/ui/templates/public/reservations.tsx b/src/ui/templates/public/reservations.tsx index cf1695654e..1366af1295 100644 --- a/src/ui/templates/public/reservations.tsx +++ b/src/ui/templates/public/reservations.tsx @@ -801,6 +801,7 @@ const renderListingRow = (
${imageHtml} + ${attributesHtml} ${t("public.registration_closed")}
`; @@ -847,6 +848,7 @@ const renderPackageMemberRow = ( info: TicketListing, fixedQty: number, childCtx: ChildRenderCtx | undefined, + attributesHtml = "", ): string => `
${renderListingImage(info.listing)} @@ -854,6 +856,7 @@ const renderPackageMemberRow = ( info.listing.name, )} ×${fixedQty} ${renderListingDescription(info.listing.description)} + ${attributesHtml} ${childCtx ? renderChildBlock(info, childCtx) : ""}
`; @@ -869,6 +872,7 @@ const renderPackageControls = ( members: TicketListing[], limit: number, childCtxFor: (memberListingId: number) => ChildRenderCtx | undefined, + attributesByListing: ListingAttributesById = new Map(), ): string => { // Every member as `id:fixedQty`, so the client knows which listing-scoped // questions to show/require once this package is selected — even when @@ -894,6 +898,7 @@ const renderPackageControls = ( e, pkg.quantities.get(e.listing.id) ?? 1, childCtxFor(e.listing.id), + renderListingAttributes(attributesByListing.get(e.listing.id)), ), ) .join(""); @@ -909,13 +914,20 @@ const renderPackageSection = ( members: TicketListing[], limit: number, childCtxFor: (memberListingId: number) => ChildRenderCtx | undefined, + attributesByListing: ListingAttributesById = new Map(), ): string => { const heading = `${escapeHtml(pkg.name)}`; const body = limit < 1 ? `${t("public.sold_out")}` : renderListingDescription(pkg.description) + - renderPackageControls(pkg, members, limit, childCtxFor); + renderPackageControls( + pkg, + members, + limit, + childCtxFor, + attributesByListing, + ); return `
${heading}${body}
`; @@ -1527,6 +1539,7 @@ const buildPageListingRows = (opts: { membersOf(pkg), opts.packageLimits.get(pkg.groupId)!, claimChildCtx, + opts.attributesByListing ?? new Map(), ); } const packageSections = opts.packages @@ -1536,6 +1549,7 @@ const buildPageListingRows = (opts: { membersOf(pkg), opts.packageLimits.get(pkg.groupId)!, claimChildCtx, + opts.attributesByListing ?? new Map(), ), ) .join(""); diff --git a/test/lib/server-attributes.test.ts b/test/lib/server-attributes.test.ts index d79ee07843..4eebc20f35 100644 --- a/test/lib/server-attributes.test.ts +++ b/test/lib/server-attributes.test.ts @@ -4,7 +4,7 @@ import { handleRequest } from "#routes"; import { getAllAttributesWithOptions, getAttributeWithOptions, - getListingAttributeOptionIds, + listingAttributeOptions, setListingAttributeOptions, } from "#shared/db/attributes.ts"; import { @@ -380,7 +380,7 @@ describeWithEnv("server (admin attributes)", { db: true }, () => { `/admin/listing/${listing.id}/attributes`, "Attributes updated", )(response); - expect(await getListingAttributeOptionIds(listing.id)).toEqual( + expect(await listingAttributeOptions.getIds(listing.id)).toEqual( attribute.options.map((option) => option.id), ); }); diff --git a/test/lib/server-listings-filter.test.ts b/test/lib/server-listings-filter.test.ts index db68d47560..08691e6d07 100644 --- a/test/lib/server-listings-filter.test.ts +++ b/test/lib/server-listings-filter.test.ts @@ -165,6 +165,46 @@ describeWithEnv("listings type filter", { db: true }, () => { expect(html).toContain(`href="/admin/listing/${shown.id}"`); expect(html).not.toContain(`href="/admin/listing/${hidden.id}"`); }); + + test("CSV export link carries the active attribute filter", async () => { + const listing = await createTestListing({ name: "Filtered CSV" }); + const difficulty = await createTestAttributeWithOptions("Difficulty", [ + "Easy", + ]); + await assignTestAttributeOptions(listing.id, difficulty.options); + + const response = await adminGet( + `/admin/listings?attribute_${difficulty.id}=${ + difficulty.options[0]!.id + }`, + ); + const html = await response.text(); + const filterParam = `attribute_${difficulty.id}=${ + difficulty.options[0]!.id + }`; + expect(html).toContain(`href="/admin/listings/csv?${filterParam}"`); + }); + + test("CSV export respects attribute filter", async () => { + const shown = await createTestListing({ name: "CSV Shown" }); + const hidden = await createTestListing({ name: "CSV Hidden" }); + const difficulty = await createTestAttributeWithOptions("Difficulty", [ + "Easy", + "Hard", + ]); + await assignTestAttributeOptions(shown.id, [difficulty.options[0]!]); + await assignTestAttributeOptions(hidden.id, [difficulty.options[1]!]); + + const response = await adminGet( + `/admin/listings/csv?attribute_${difficulty.id}=${ + difficulty.options[0]!.id + }`, + ); + const csv = await response.text(); + + expect(csv).toContain("CSV Shown"); + expect(csv).not.toContain("CSV Hidden"); + }); }); describe("public listings page", () => { @@ -225,7 +265,11 @@ describeWithEnv("listings type filter", { db: true }, () => { const format = await createTestAttributeWithOptions("Format", [ "In person", ]); + const audience = await createTestAttributeWithOptions("Audience", [ + "Adults", + ]); await assignTestAttributeOptions(listing1.id, format.options); + await assignTestAttributeOptions(listing2.id, audience.options); await expectHtmlResponse( await get(`/ticket/${listing1.slug}+${listing2.slug}`), @@ -235,6 +279,8 @@ describeWithEnv("listings type filter", { db: true }, () => { "listing-attributes", "Format", "In person", + "Audience", + "Adults", ); }); }); diff --git a/test/shared/db/attributes.test.ts b/test/shared/db/attributes.test.ts index 804b4887c8..2e54964dfd 100644 --- a/test/shared/db/attributes.test.ts +++ b/test/shared/db/attributes.test.ts @@ -6,8 +6,8 @@ import { getAllAttributeOptionIds, getAllAttributesWithOptions, getAttributeWithOptions, - getListingAttributeOptionIds, getSelectedAttributesForListings, + listingAttributeOptions, pruneInvalidAttributeOptionIds, setListingAttributeOptions, swapAttributeOptionOrder, @@ -72,13 +72,13 @@ describeWithEnv("db > attributes", { db: true }, () => { attribute.options[1]!.id, attribute.options[0]!.id, ]); - expect(await getListingAttributeOptionIds(listing.id)).toEqual([ + expect(await listingAttributeOptions.getIds(listing.id)).toEqual([ attribute.options[0]!.id, attribute.options[1]!.id, ]); await setListingAttributeOptions(listing.id, [attribute.options[0]!.id]); - expect(await getListingAttributeOptionIds(listing.id)).toEqual([ + expect(await listingAttributeOptions.getIds(listing.id)).toEqual([ attribute.options[0]!.id, ]); }); @@ -134,7 +134,7 @@ describeWithEnv("db > attributes", { db: true }, () => { await deleteAttributeOption(attribute.options[0]!.id); - expect(await getListingAttributeOptionIds(listing.id)).toEqual([ + expect(await listingAttributeOptions.getIds(listing.id)).toEqual([ attribute.options[1]!.id, ]); const found = await getAttributeWithOptions(attribute.id); @@ -157,7 +157,7 @@ describeWithEnv("db > attributes", { db: true }, () => { await deleteAttribute(attribute.id); expect(await getAttributeWithOptions(attribute.id)).toBeNull(); - expect(await getListingAttributeOptionIds(listing.id)).toEqual([]); + expect(await listingAttributeOptions.getIds(listing.id)).toEqual([]); }); test("keeps only option ids that exist", async () => { From 0629b69054e1efd53044defd02392886e1502493 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 10 Jul 2026 08:45:04 +0100 Subject: [PATCH 05/11] Use #fp curried helpers and fix jscpd clone in attribute modules - Replace array .map/.filter/.flatMap and for...of with curried map, filter, flatMap, reduce, and pipe from #fp in listing-attribute-filter.ts and attributes.ts (AGENTS.md: prefer #fp over imperative loops) - Inline col.generated/col.encrypted directly in attributeOptionsTable schema to eliminate the jscpd clone with questions/tables.ts - Drop unused export on AttributeFilterOption type (no external caller) --- src/shared/db/attributes.ts | 43 ++++++++++------------ src/shared/listing-attribute-filter.ts | 51 +++++++++++++++----------- 2 files changed, 49 insertions(+), 45 deletions(-) diff --git a/src/shared/db/attributes.ts b/src/shared/db/attributes.ts index e66a2aa084..d8a7627ee9 100644 --- a/src/shared/db/attributes.ts +++ b/src/shared/db/attributes.ts @@ -5,7 +5,7 @@ * ids only; display code resolves those ids back to ordered attribute groups. */ -import { map, reduce, unique } from "#fp"; +import { filter, map, reduce, unique } from "#fp"; import { decrypt, encrypt } from "#shared/crypto/encryption.ts"; import { executeBatch, @@ -47,15 +47,12 @@ type AttributeOptionInput = { text: string; }; -const generatedId = col.generated(); -const encryptedText = col.encrypted(encrypt, decrypt); - export const attributesTable = defineTable({ name: "attributes", primaryKey: "id", schema: { - id: generatedId, - name: encryptedText, + id: col.generated(), + name: col.encrypted(encrypt, decrypt), sort_order: col.withDefault(() => 0), }, }); @@ -68,9 +65,9 @@ export const attributeOptionsTable = defineTable< primaryKey: "id", schema: { attribute_id: col.simple(), - id: generatedId, - sort_order: col.withDefault(() => 0), - text: encryptedText, + id: col.generated(), + sort_order: col.simple(), + text: col.encrypted(encrypt, decrypt), }, }); @@ -144,23 +141,23 @@ const decryptAttributeRows = async ( ): Promise => { const [attributes, options] = await Promise.all([ Promise.all( - [ - ...reduce(collectUniqueAttributes, new Map())(rows), - ].map( - async ([id, attribute]) => + map( + async ([id, attribute]: [number, Attribute]) => [id, await attributesTable.fromDb(attribute)] as const, - ), + )([ + ...reduce(collectUniqueAttributes, new Map())(rows), + ]), ), Promise.all( - [ + map( + async ([id, option]: [number, AttributeOption]) => + [id, await attributeOptionsTable.fromDb(option)] as const, + )([ ...reduce( collectUniqueOptions, new Map(), )(rows), - ].map( - async ([id, option]) => - [id, await attributeOptionsTable.fromDb(option)] as const, - ), + ]), ), ]); return { @@ -338,10 +335,10 @@ export const getSelectedAttributesForListings = async ( const rows = await selectedOptionRows(unique(listingIds)); const decrypted = await decryptAttributeRows(rows); return new Map( - [...selectedRowsForListing(rows)].map( - ([listingId, listingRows]) => + map( + ([listingId, listingRows]: [number, JoinedAttributeRow[]]) => [listingId, buildAttributeGroups(listingRows, decrypted)] as const, - ), + )([...selectedRowsForListing(rows)]), ); }; @@ -354,4 +351,4 @@ export const setListingAttributeOptions = async ( export const pruneInvalidAttributeOptionIds = ( validOptionIds: Set, optionIds: number[], -): number[] => optionIds.filter((id) => validOptionIds.has(id)); +): number[] => filter((id: number) => validOptionIds.has(id))(optionIds); diff --git a/src/shared/listing-attribute-filter.ts b/src/shared/listing-attribute-filter.ts index 58717e35ee..6c8ccca29e 100644 --- a/src/shared/listing-attribute-filter.ts +++ b/src/shared/listing-attribute-filter.ts @@ -1,4 +1,4 @@ -import { reduce } from "#fp"; +import { filter, flatMap, map, pipe, reduce } from "#fp"; import type { AttributeOption, AttributeWithOptions, @@ -7,7 +7,7 @@ import type { import type { ListingWithCount } from "#shared/types.ts"; import { parsePositiveInt } from "#shared/validation/number.ts"; -export type AttributeFilterOption = Pick< +type AttributeFilterOption = Pick< AttributeOption, "id" | "sort_order" | "text" >; @@ -48,13 +48,15 @@ const addAttributeOptions = ( options: new Map(), sort_order: attribute.sort_order, }; - for (const option of attribute.options) { - group.options.set(option.id, { - id: option.id, - sort_order: option.sort_order, - text: option.text, - }); - } + reduce( + (options, option: AttributeOption) => + options.set(option.id, { + id: option.id, + sort_order: option.sort_order, + text: option.text, + }), + group.options, + )(attribute.options); return filters.set(attribute.id, group); }; @@ -74,31 +76,34 @@ const freezeFilterGroup = ( export const attributeFilterGroupsForListings = ( listingIds: number[], attributesByListing: ListingAttributesById, -): AttributeFilterGroup[] => - [ +): AttributeFilterGroup[] => { + const groups = [ ...reduce( (filters: Map, listingId: number) => addListingAttributes(filters, attributesByListing.get(listingId) ?? []), new Map(), )(listingIds).values(), - ] - .map(freezeFilterGroup) - .filter((group) => group.options.length > 0) - .toSorted(attributeSort); + ]; + return pipe( + map(freezeFilterGroup), + filter((group: AttributeFilterGroup) => group.options.length > 0), + (filtered: AttributeFilterGroup[]) => filtered.toSorted(attributeSort), + )(groups); +}; export const selectedAttributeFiltersFromRequest = ( request: Request, filters: AttributeFilterGroup[], ): SelectedAttributeFilters => { const params = new URL(request.url).searchParams; - const selected = filters.flatMap((group) => { + const selected = flatMap((group: AttributeFilterGroup) => { const value = params.get(attributeFilterParam(group.id)); const optionId = value === null ? null : parsePositiveInt(value); return optionId !== null && group.options.some((option) => option.id === optionId) ? [[group.id, optionId] as const] : []; - }); + })(filters); return new Map(selected); }; @@ -106,9 +111,11 @@ const selectedOptionIds = ( attributes: AttributeWithOptions[] | undefined, ): Set => new Set( - (attributes ?? []).flatMap((attribute) => - attribute.options.map((option) => option.id), - ), + pipe( + flatMap((attribute: AttributeWithOptions) => + map((option: AttributeOption) => option.id)(attribute.options), + ), + )(attributes ?? []), ); export const filterListingsByAttributes = @@ -119,10 +126,10 @@ export const filterListingsByAttributes = (listings: ListingWithCount[]): ListingWithCount[] => { const required = [...selected.values()]; if (required.length === 0) return listings; - return listings.filter((listing) => { + return filter((listing: ListingWithCount) => { const listingOptions = selectedOptionIds( attributesByListing.get(listing.id), ); return required.every((optionId) => listingOptions.has(optionId)); - }); + })(listings); }; From a3ae1b5a721a65ba20a143c06dc7dcd38beb0743 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 10 Jul 2026 08:46:54 +0100 Subject: [PATCH 06/11] Load attribute filter context before type narrowing; include child listing ids - CSV export: load loadListingAttributeFilterContext from the full listing set (before filterListingsByType) so an attribute that only exists on a different listing type is still recognised rather than silently dropped - Ticket page: include child listing ids (from childrenByParentId) in getSelectedAttributesForListings so attributes on add-on/child listings render on parent ticket pages --- src/features/admin/dashboard.ts | 11 +++++++---- src/features/public/ticket-submit.ts | 7 ++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/features/admin/dashboard.ts b/src/features/admin/dashboard.ts index afbe132d9e..a9df45c237 100644 --- a/src/features/admin/dashboard.ts +++ b/src/features/admin/dashboard.ts @@ -187,19 +187,22 @@ const handleAdminListingsGet: TypedRouteHandler<"GET /admin/listings"> = /** Handle GET /admin/listings/csv — export every listing (filtered by the same * ?type= category and attribute filters the listings views use) as a CSV - * download. */ + * download. The attribute filter context is loaded from the full listing set + * (before the type filter narrows it) so an attribute that only exists on a + * different listing type is still recognised by selectedAttributeFiltersFromRequest + * rather than silently dropped. */ const handleListingsCsvExport: TypedRouteHandler<"GET /admin/listings/csv"> = ( request, ) => requireSessionOr(request, async () => { + const allListings = await loadSortedListings(); const type = listingTypeFromRequest(request); - const listings = filterListingsByType(type)(await loadSortedListings()); const { activeAttributeFilters, attributesByListing } = - await loadListingAttributeFilterContext(request, listings); + await loadListingAttributeFilterContext(request, allListings); const filteredListings = filterListingsByAttributes( activeAttributeFilters, attributesByListing, - )(listings); + )(filterListingsByType(type)(allListings)); const csv = generateListingsCsv(filteredListings, settings.timezone); const suffix = type === "all" ? "" : `_${type}`; await logActivity( diff --git a/src/features/public/ticket-submit.ts b/src/features/public/ticket-submit.ts index 9acf4333af..9703c1dfce 100644 --- a/src/features/public/ticket-submit.ts +++ b/src/features/public/ticket-submit.ts @@ -366,9 +366,10 @@ const renderCtx = async (ctx: TicketCtx): Promise => { ctx.galleryTarget ? getImagesForItem(ctx.galleryTarget.type, ctx.galleryTarget.id) : Promise.resolve([]), - getSelectedAttributesForListings( - ctx.listings.map((entry) => entry.listing.id), - ), + getSelectedAttributesForListings([ + ...ctx.listings.map((entry) => entry.listing.id), + ...children.map((child) => child.id), + ]), ]); const caps = childCapacityInfo(childCaps, childOwnRemaining, membership); return { From 15c65d9ff0c4fa8a5fcf96e987032fb67a72a6fc Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 10 Jul 2026 08:52:22 +0100 Subject: [PATCH 07/11] Extract setupFilteredPair helper to eliminate jscpd clone in filter tests --- test/lib/server-listings-filter.test.ts | 46 +++++++++++++++---------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/test/lib/server-listings-filter.test.ts b/test/lib/server-listings-filter.test.ts index 08691e6d07..7e2ad93cbc 100644 --- a/test/lib/server-listings-filter.test.ts +++ b/test/lib/server-listings-filter.test.ts @@ -145,20 +145,32 @@ describeWithEnv("listings type filter", { db: true }, () => { }); describe("admin listings index", () => { - test("filters listings by selected listing attribute", async () => { - const shown = await createTestListing({ name: "Shown" }); - const hidden = await createTestListing({ name: "Hidden" }); + const setupFilteredPair = async (labels: [string, string]) => { + const shown = await createTestListing({ name: labels[0] }); + const hidden = await createTestListing({ name: labels[1] }); const difficulty = await createTestAttributeWithOptions("Difficulty", [ "Easy", "Hard", ]); await assignTestAttributeOptions(shown.id, [difficulty.options[0]!]); await assignTestAttributeOptions(hidden.id, [difficulty.options[1]!]); + return { difficulty, hidden, shown }; + }; + + const filterUrl = ( + path: string, + attributeId: number, + optionId: number, + ): string => `${path}?attribute_${attributeId}=${optionId}`; + + test("filters listings by selected listing attribute", async () => { + const { shown, hidden, difficulty } = await setupFilteredPair([ + "Shown", + "Hidden", + ]); const response = await adminGet( - `/admin/listings?attribute_${difficulty.id}=${ - difficulty.options[0]!.id - }`, + filterUrl("/admin/listings", difficulty.id, difficulty.options[0]!.id), ); const html = await response.text(); @@ -174,9 +186,7 @@ describeWithEnv("listings type filter", { db: true }, () => { await assignTestAttributeOptions(listing.id, difficulty.options); const response = await adminGet( - `/admin/listings?attribute_${difficulty.id}=${ - difficulty.options[0]!.id - }`, + filterUrl("/admin/listings", difficulty.id, difficulty.options[0]!.id), ); const html = await response.text(); const filterParam = `attribute_${difficulty.id}=${ @@ -186,19 +196,17 @@ describeWithEnv("listings type filter", { db: true }, () => { }); test("CSV export respects attribute filter", async () => { - const shown = await createTestListing({ name: "CSV Shown" }); - const hidden = await createTestListing({ name: "CSV Hidden" }); - const difficulty = await createTestAttributeWithOptions("Difficulty", [ - "Easy", - "Hard", + const { difficulty } = await setupFilteredPair([ + "CSV Shown", + "CSV Hidden", ]); - await assignTestAttributeOptions(shown.id, [difficulty.options[0]!]); - await assignTestAttributeOptions(hidden.id, [difficulty.options[1]!]); const response = await adminGet( - `/admin/listings/csv?attribute_${difficulty.id}=${ - difficulty.options[0]!.id - }`, + filterUrl( + "/admin/listings/csv", + difficulty.id, + difficulty.options[0]!.id, + ), ); const csv = await response.text(); From 6449211c1202bf36ad95d23999f133f9356e5efe Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 10 Jul 2026 09:11:18 +0100 Subject: [PATCH 08/11] Cover attributes on package member rows and package+standalone paths Add tests that pass attributesByListing with real data through the single-package and multi-package+standalone rendering paths in buildPageListingRows, covering the previously uncovered lines (1537-1542, 1545-1552, 1572-1576) and their ?? branches. --- .../public/ticket-page-packages.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/templates/public/ticket-page-packages.test.ts b/test/templates/public/ticket-page-packages.test.ts index d0d14afdaa..642a97db72 100644 --- a/test/templates/public/ticket-page-packages.test.ts +++ b/test/templates/public/ticket-page-packages.test.ts @@ -1,5 +1,6 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; +import type { AttributeWithOptions } from "#shared/db/attributes.ts"; import { FormParams } from "#shared/form-data.ts"; import { clearSavedFormData, setSavedFormData } from "#shared/forms.tsx"; import { ticketPage } from "#templates/public.tsx"; @@ -15,6 +16,17 @@ import { registerPublicTemplateHooks(); +const attributeWithOptions = ( + id: number, + name: string, + optionText: string, +): AttributeWithOptions => ({ + id, + name, + options: [{ attribute_id: id, id: id * 10, sort_order: 0, text: optionText }], + sort_order: 0, +}); + describe("ticketPage — packages", () => { test("shows all sold out message when every listing is sold out", () => { const listings = [ @@ -200,4 +212,34 @@ describe("ticketPage — packages", () => { expect(html).toContain("Hidden Bundle"); expect(html).not.toContain("SecretItem"); }); + + test("renders attributes on package member rows", () => { + const listings = bigAndSmallListings(); + const html = ticketPage({ + attributesByListing: new Map([ + [1, [attributeWithOptions(3, "Format", "Outdoor")]], + ]), + groupName: "Camp Kit", + listings, + packages: [pagePackage(5, [1, 2], { quantities: new Map([[2, 1]]) })], + slugs: [PKG_SLUG], + }); + expect(html).toContain("Format"); + expect(html).toContain("Outdoor"); + }); + + test("renders attributes on package sections alongside standalone rows", () => { + const listings = bigAndSmallListings(); + const html = ticketPage({ + attributesByListing: new Map([ + [1, [attributeWithOptions(3, "Level", "Beginner")]], + [2, [attributeWithOptions(4, "Level", "Advanced")]], + ]), + listings, + packages: [pagePackage(5, [1, 2])], + slugs: [PKG_SLUG, "big01", "sml01"], + }); + expect(html).toContain("Beginner"); + expect(html).toContain("Advanced"); + }); }); From b3ec26c6fb4c4dc85bc6c12138ea28da4a12fdde Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 10 Jul 2026 09:19:21 +0100 Subject: [PATCH 09/11] Render child listing attributes in add-on options; copy attributes on listing/group duplicate - Thread attributesByListing into ChildRenderCtx and render child attributes in renderChildOption/renderSoleChildOption so add-on child listings show their attributes on parent ticket pages - Add copyListingAttributeOptionsTx and call it from the single-listing duplicate afterWrite path so attribute selections are copied onto clones - Copy attribute selections in the group duplicate batch path too - Regression test: 'copies attribute selections onto the duplicate' --- src/features/admin/bulk-actions.ts | 8 ++++++ src/features/admin/listings-form.ts | 12 ++++++--- src/shared/db/attributes.ts | 13 ++++++++++ src/ui/templates/public/reservations.tsx | 31 ++++++++++++++++++------ test/lib/server-attributes.test.ts | 21 ++++++++++++++++ 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/features/admin/bulk-actions.ts b/src/features/admin/bulk-actions.ts index f6e79b3476..ff037960ec 100644 --- a/src/features/admin/bulk-actions.ts +++ b/src/features/admin/bulk-actions.ts @@ -297,6 +297,14 @@ const handleDuplicateGroupPost = groupFormPost(async (group, form) => { })), ), ]); + // Copy each source listing's attribute selections onto its clone. + await executeBatch( + cloneInputs.map(({ sourceId }) => ({ + args: [idMap.get(sourceId)!, sourceId], + sql: `INSERT INTO listing_attribute_options (listing_id, option_id) + SELECT ?, option_id FROM listing_attribute_options WHERE listing_id = ?`, + })), + ); // A cloned parent whose remapped edge set fails re-validation is left gateless // rather than written; surface those as a warning flash (mirroring the diff --git a/src/features/admin/listings-form.ts b/src/features/admin/listings-form.ts index ec652f2af9..0cafd16457 100644 --- a/src/features/admin/listings-form.ts +++ b/src/features/admin/listings-form.ts @@ -10,6 +10,7 @@ import { isBuilderEnabled } from "#routes/admin/builder.ts"; import { toMinorUnits } from "#shared/currency.ts"; import { normalizeDatetime } from "#shared/dates.ts"; +import { copyListingAttributeOptionsTx } from "#shared/db/attributes.ts"; import type { TxScope } from "#shared/db/client.ts"; import { copyPackageMemberOverridesTx, @@ -260,15 +261,18 @@ const writeListingGroups = async ( }; /** Create-only afterWrite: persist the memberships, then — for a duplicate — - * copy the source's package overrides onto the new membership rows in the SAME - * transaction, so the duplicate never commits as a live package member at the - * default price when the override copy fails. */ + * copy the source's package overrides and attribute selections onto the new + * rows in the SAME transaction, so the duplicate never commits as a live + * package member at the default price when the override copy fails. */ const writeCreateListingGroups = (form: FormParams) => async (tx: TxScope, id: number, input: ListingInput): Promise => { await writeListingGroups(tx, id, input); const sourceId = form.getOptionalInt("duplicated_from"); - if (sourceId !== null) await copyPackageMemberOverridesTx(tx, sourceId, id); + if (sourceId !== null) { + await copyPackageMemberOverridesTx(tx, sourceId, id); + await copyListingAttributeOptionsTx(tx, sourceId, id); + } }; /** diff --git a/src/shared/db/attributes.ts b/src/shared/db/attributes.ts index d8a7627ee9..5a47dff783 100644 --- a/src/shared/db/attributes.ts +++ b/src/shared/db/attributes.ts @@ -12,6 +12,7 @@ import { inPlaceholders, queryAll, queryOne, + type TxScope, } from "#shared/db/client.ts"; import { linkTableSide } from "#shared/db/link-table.ts"; import { swapSortOrder } from "#shared/db/query.ts"; @@ -348,6 +349,18 @@ export const setListingAttributeOptions = async ( ): Promise => listingAttributeOptions.setIds(listingId, unique(optionIds)); +export const copyListingAttributeOptionsTx = async ( + tx: TxScope, + sourceListingId: number, + newListingId: number, +): Promise => { + await tx.execute({ + args: [newListingId, sourceListingId], + sql: `INSERT INTO listing_attribute_options (listing_id, option_id) + SELECT ?, option_id FROM listing_attribute_options WHERE listing_id = ?`, + }); +}; + export const pruneInvalidAttributeOptionIds = ( validOptionIds: Set, optionIds: number[], diff --git a/src/ui/templates/public/reservations.tsx b/src/ui/templates/public/reservations.tsx index 1366af1295..dca2a99706 100644 --- a/src/ui/templates/public/reservations.tsx +++ b/src/ui/templates/public/reservations.tsx @@ -416,6 +416,8 @@ export type ChildRenderCtx = { rendered: Set; /** Child tickets already promised to parents on this page. */ foldReserveByChildId: ReadonlyMap; + /** Selected listing attributes, for rendering on child options. */ + attributesByListing: ListingAttributesById; }; /** Max parent tickets after checking the children it must book too. */ @@ -579,6 +581,7 @@ const renderChildOption = ( childLimit: number, childDatesById: ReadonlyMap, showZero: boolean, + attributesHtml = "", ): string => { const parentId = parent.id; const { listing } = child; @@ -610,7 +613,7 @@ const renderChildOption = ( restoredChildQty(parentId, listing.id, childLimit), )}` : ``; - return `${priceHtml}`; + return `${priceHtml}${attributesHtml}`; }; /** Render a sole bookable child as INFORMATIONAL (auto-select preserved): no @@ -639,6 +642,7 @@ const renderSoleChildOption = ( child: TicketListing, childDatesById: ReadonlyMap, showZero: boolean, + attributesHtml = "", ): string => { const parentId = parent.id; const { listing } = child; @@ -658,7 +662,7 @@ const renderSoleChildOption = ( parentId, child, childDatesById, - )}>${label}

${priceHtml}`; + )}>${label}

${priceHtml}${attributesHtml}`; }; /** @@ -695,9 +699,18 @@ const renderChildBlock = ( const isSole = (child: TicketListing): boolean => bookable.length === 1 && bookable[0]!.listing.id === child.listing.id; const options = children - .map((child) => - isSole(child) - ? renderSoleChildOption(parent, child, ctx.childDatesById, showZero) + .map((child) => { + const childAttributesHtml = renderListingAttributes( + ctx.attributesByListing.get(child.listing.id), + ); + return isSole(child) + ? renderSoleChildOption( + parent, + child, + ctx.childDatesById, + showZero, + childAttributesHtml, + ) : renderChildOption( parent, child, @@ -716,8 +729,9 @@ const renderChildBlock = ( : 0, ctx.childDatesById, showZero, - ), - ) + childAttributesHtml, + ); + }) .join(""); const questionsHtml = children .map((child) => { @@ -1350,6 +1364,7 @@ const splitChildQuestions = ( groupRemainingByGroupId: ReadonlyMap, childDatesById: ReadonlyMap, groupIdsByListingId: ReadonlyMap, + attributesByListing: ListingAttributesById, ): { pageQuestions: QuestionWithAnswers[]; childCtx?: ChildRenderCtx } => { if (!childrenByParentId || childrenByParentId.size === 0) { return { pageQuestions: questions }; @@ -1362,6 +1377,7 @@ const splitChildQuestions = ( const pageQuestions = questions.filter(isPageQuestion); return { childCtx: { + attributesByListing, childDatesById, children: childrenByParentId, foldReserveByChildId: foldReserveByChildId(listings, childrenByParentId), @@ -1790,6 +1806,7 @@ export const ticketPage = ({ groupRemainingByGroupId, childDatesById ?? new Map(), groupIdsByListingId, + attributesByListing, ); // A package page shows one "number of packages" selector plus read-only member diff --git a/test/lib/server-attributes.test.ts b/test/lib/server-attributes.test.ts index 4eebc20f35..bcd2e88582 100644 --- a/test/lib/server-attributes.test.ts +++ b/test/lib/server-attributes.test.ts @@ -10,9 +10,11 @@ import { import { adminFormPost, adminGet, + assignTestAttributeOptions, createTestAttributeWithOptions, createTestListing, describeWithEnv, + duplicateTestListing, expectFlash, expectFlashRedirect, expectHtmlResponse, @@ -385,4 +387,23 @@ describeWithEnv("server (admin attributes)", { db: true }, () => { ); }); }); + + describe("listing duplication", () => { + test("copies attribute selections onto the duplicate", async () => { + const source = await createTestListing({ name: "Attr Source" }); + const format = await createTestAttributeWithOptions("Format", [ + "Online", + "In person", + ]); + await assignTestAttributeOptions(source.id, format.options); + + const duplicate = await duplicateTestListing(source.id, { + name: "Attr Duplicate", + }); + + expect(await listingAttributeOptions.getIds(duplicate.id)).toEqual( + format.options.map((option) => option.id), + ); + }); + }); }); From e08af2a0eecdb44cdbe4bb66a31e8f696b6502b6 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 10 Jul 2026 09:22:20 +0100 Subject: [PATCH 10/11] Add copyLinksTx to LinkTableSide and use it for attribute copy, fixing jscpd clone --- src/shared/db/attributes.ts | 11 +++-------- src/shared/db/link-table.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/shared/db/attributes.ts b/src/shared/db/attributes.ts index 5a47dff783..36a417efb9 100644 --- a/src/shared/db/attributes.ts +++ b/src/shared/db/attributes.ts @@ -349,17 +349,12 @@ export const setListingAttributeOptions = async ( ): Promise => listingAttributeOptions.setIds(listingId, unique(optionIds)); -export const copyListingAttributeOptionsTx = async ( +export const copyListingAttributeOptionsTx = ( tx: TxScope, sourceListingId: number, newListingId: number, -): Promise => { - await tx.execute({ - args: [newListingId, sourceListingId], - sql: `INSERT INTO listing_attribute_options (listing_id, option_id) - SELECT ?, option_id FROM listing_attribute_options WHERE listing_id = ?`, - }); -}; +): Promise => + listingAttributeOptions.copyLinksTx(tx, sourceListingId, newListingId); export const pruneInvalidAttributeOptionIds = ( validOptionIds: Set, diff --git a/src/shared/db/link-table.ts b/src/shared/db/link-table.ts index f38a0c7f96..4eeb8e691e 100644 --- a/src/shared/db/link-table.ts +++ b/src/shared/db/link-table.ts @@ -37,6 +37,13 @@ export type LinkTableSide = { addIdsTx: TxIdsWrite; /** Remove every row for this key (used before deleting the record). */ clear: (keyId: number) => Promise; + /** Copy all links from one key to another inside an existing write + * transaction, so a duplicated record keeps its links atomically. */ + copyLinksTx: ( + tx: TxScope, + sourceKeyId: number, + newKeyId: number, + ) => Promise; /** The linked ids for a key, ascending. */ getIds: (keyId: number) => Promise; /** Replace a key's linked set with exactly `ids` (deduped): delete the key's @@ -88,6 +95,13 @@ export const linkTableSide = ( await tx.execute(insertStatement(keyId, deduped)); }, clear: (keyId) => deleteByField(table, keyColumn, keyId), + copyLinksTx: async (tx, sourceKeyId, newKeyId) => { + await tx.execute({ + args: [newKeyId, sourceKeyId], + sql: `INSERT INTO ${table} (${keyColumn}, ${valueColumn}) + SELECT ?, ${valueColumn} FROM ${table} WHERE ${keyColumn} = ?`, + }); + }, getIds: (keyId) => queryIdColumn( `SELECT ${valueColumn} AS id FROM ${table} WHERE ${keyColumn} = ? ORDER BY ${valueColumn} ASC`, From 70ef16a9512c7849d1264860aa2ec1d6e6f69337 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 10 Jul 2026 09:41:10 +0100 Subject: [PATCH 11/11] Make attributesByListing required on buildPageListingRows to kill dead ?? branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three ?? new Map() fallbacks were dead code — ticketPage always passes attributesByListing (defaulted to new Map() at the ticketPage level), so the ?? right-hand side never fired. Making the property required and destructuring it removes the uncovered branches. --- src/ui/templates/public/reservations.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ui/templates/public/reservations.tsx b/src/ui/templates/public/reservations.tsx index dca2a99706..97443a5500 100644 --- a/src/ui/templates/public/reservations.tsx +++ b/src/ui/templates/public/reservations.tsx @@ -1529,8 +1529,9 @@ const buildPageListingRows = (opts: { hideQuantity: boolean; prefill?: BookingPrefill | undefined; childCtx?: ChildRenderCtx | undefined; - attributesByListing?: ListingAttributesById; + attributesByListing: ListingAttributesById; }): string => { + const { attributesByListing } = opts; const membersOf = (pkg: PagePackage): TicketListing[] => { const memberIds = new Set(pkg.memberListingIds); return opts.listings.filter((info) => memberIds.has(info.listing.id)); @@ -1555,7 +1556,7 @@ const buildPageListingRows = (opts: { membersOf(pkg), opts.packageLimits.get(pkg.groupId)!, claimChildCtx, - opts.attributesByListing ?? new Map(), + attributesByListing, ); } const packageSections = opts.packages @@ -1565,7 +1566,7 @@ const buildPageListingRows = (opts: { membersOf(pkg), opts.packageLimits.get(pkg.groupId)!, claimChildCtx, - opts.attributesByListing ?? new Map(), + attributesByListing, ), ) .join(""); @@ -1589,7 +1590,7 @@ const buildPageListingRows = (opts: { opts.hideQuantity, opts.prefill, (info) => (memberIds.has(info.listing.id) ? undefined : opts.childCtx), - opts.attributesByListing ?? new Map(), + attributesByListing, ) ); };