From a694adae2a5a2515ca9497be2f625e16c70b0126 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 11 May 2026 10:12:05 -0500 Subject: [PATCH 1/2] fix(promocode): clarify Apply-to-all scope (Audience: All only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend tightens canBeAppliedTo() so the implicit "apply to all" branch only covers Audience = All ticket types (see companion API SDS). This change updates the admin copy so the checkbox label and a new helper-text row name the restriction explicitly. WithInvitation, WithoutInvitation, and WithPromoCode ticket types must be opted in via the picker after saving. No JS logic change — the apply_to_all_tix flag is and remains UI-only. Refs: ClickUp 86b9vrpxp; companion summit-api branch feat/promo-code-apply-to-all-audience-restriction --- ...-code-apply-to-all-audience-restriction.md | 139 ++++++++++++++++++ .../promocode-form.integration.test.js | 63 ++++++++ .../forms/discount-base-pc-form.js | 5 +- src/i18n/en.json | 3 +- 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 doc/promo-code-apply-to-all-audience-restriction.md diff --git a/doc/promo-code-apply-to-all-audience-restriction.md b/doc/promo-code-apply-to-all-audience-restriction.md new file mode 100644 index 000000000..0d0560ab3 --- /dev/null +++ b/doc/promo-code-apply-to-all-audience-restriction.md @@ -0,0 +1,139 @@ +# Promo Code "Apply to All" Audience Restriction — summit-admin Spec + +Created: 2026-05-11 +Author: casey@caseylocker.com +Status: PENDING +Type: Change Request (UI copy + admin clarity) +ClickUp: [86b9vrpxp](https://app.clickup.com/t/86b9vrpxp) +Parent feature: [86b952pgc](https://app.clickup.com/t/86b952pgc) — Promo Codes for Early Registration +Parent feature plan (local working doc): `docs/superpowers/plans/2026-04-21-promo-codes-for-early-registration-access.md` +API counterpart SDS: `summit-api/doc/promo-code-apply-to-all-audience-restriction.md` + +## Summary + +**Goal:** Update the "Apply to all Ticket Types" checkbox copy in the discount-code editor so it accurately reflects the (now stricter) backend behavior: the implicit sweep only covers ticket types with `Audience = All`. Add helper text that tells the admin how to associate non-All-audience ticket types (WithInvitation, WithoutInvitation, WithPromoCode) — they must be added explicitly via the picker after saving. + +**Why:** The backend has been tightened (see API SDS) so an empty `allowed_ticket_types` collection no longer means "apply to every ticket type, regardless of audience." Without a label/helper update, admins will keep ticking the box expecting the old behavior and be surprised that `WithoutInvitation`/`WithInvitation`/`WithPromoCode` ticket types are not covered. Parent feature SDS Resolved Decision #8 — _"audience controls visibility, type controls access"_ — depends on the admin understanding this distinction. + +**Approach:** Pure i18n + minor JSX. Two label changes and one helper-text row in `discount-base-pc-form.js`. No JS logic change: the FE `apply_to_all_tix` checkbox is already a UI-only flag that hides the per-ticket-type picker; the BE carries the load-bearing enforcement. + +## Scope + +### In Scope + +- `src/i18n/en.json` — update `edit_promocode.apply_to_all_tix` copy; add `edit_promocode.apply_to_all_tix_helper` for the inline explainer. +- `src/components/forms/promocode-form/forms/discount-base-pc-form.js` — render the helper text below the checkbox. +- Extend `src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js` (existing — extended for this change) with assertions for the new label and helper text. + +### Out of Scope + +- Client-side ticket-type filtering. The FE never had "select all" behavior on this checkbox; it only hides the per-type picker. No JS logic change is required. +- Changes to the picker/`DiscountTicketTable` component. Audience visibility filtering in the picker — if needed at all — is a separate concern; the picker already surfaces every audience because the parent feature explicitly relies on this for opting `WithPromoCode` types in. +- Backend enforcement. Covered entirely by the API SDS. + +## Truths (Authoritative Decisions) + +1. **The FE `apply_to_all_tix` flag is UI-only and stays UI-only.** It is deleted before send (`src/actions/promocode-actions.js:160`) and rederived from `ticket_types_rules.length === 0` (`src/reducers/promocodes/promocode-reducer.js:179`). The backend has no corresponding stored flag — it infers "apply to all" from an empty `allowed_ticket_types` collection. Do not change this contract. + +2. **No "select all on click" behavior is added.** The existing UX is: checking the box hides the per-type picker and saves the code with no `ticket_types_rules`. Adding any kind of FE pre-population would conflict with the BE's empty-collection semantics. The helper text instead tells the admin to use the picker for non-All audiences after saving. + +3. **Helper text wording is required, not optional.** The change request explicitly calls out admin confusion as the failure mode. Without the inline explainer below the checkbox, admins will keep encountering surprise. + +4. **Copy keys live under `edit_promocode.*`** to match the existing namespace at `src/i18n/en.json:992`-`1008`. + +## Approach + +**Chosen:** Two-key i18n update + one helper-text JSX row beneath the existing checkbox. + +**Why:** Smallest possible change that addresses the admin clarity gap. Pure copy. Mirrors the surrounding bootstrap form styling. No JSX restructuring or component refactor. + +**Alternatives considered:** + +- _Filter the per-type picker to hide `WithPromoCode` rows by default._ Rejected — the parent feature explicitly requires admins to be able to opt `WithPromoCode` types into a discount code via the picker. Hiding them would block the only legitimate path. +- _Pre-populate `ticket_types_rules` with all `Audience = All` types when the box is checked._ Rejected — adds FE logic that diverges from the BE's empty-collection semantics; would also reverse-break existing discount codes on first edit. +- _Disable the checkbox entirely on edit forms with mixed-audience summits._ Rejected — overreaches; the box still has a useful "apply to all Audience = All types" function. + +## Context for Implementer + +- **Entry point:** `src/components/forms/promocode-form/forms/discount-base-pc-form.js` is the only JSX file that changes. The checkbox sits at lines 19-30; the helper row goes immediately after the closing `` of the `form-check` block (line 30) but still inside the `col-md-4` (line 15). +- **i18n keys:** existing `edit_promocode.apply_to_all_tix` (line 1001) gets a copy update. New key `edit_promocode.apply_to_all_tix_helper` is added beside it. +- **Existing tests touched:** `src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js` already asserts label rendering for the discount-code form; extend rather than duplicate. +- **Style:** keep the helper-text styling consistent with surrounding form rows (bootstrap legacy classes — `form-text`, `text-muted`, or equivalent — no MUI). The skill file `skills/react-frontend.md` in the fn-skills vault calls out the legacy-Bootstrap convention for this area. + +## Tasks + +### Task 1: i18n copy + +**File:** `src/i18n/en.json` + +**Changes:** + +- Update `edit_promocode.apply_to_all_tix` (line 1001) from `"Apply to all Ticket Types"` to `"Apply to all ticket types (Audience: All)"`. +- Add new key beneath it: `edit_promocode.apply_to_all_tix_helper` with value: + `"Only ticket types with Audience = All are covered. WithInvitation, WithoutInvitation, and WithPromoCode ticket types must be added explicitly via the ticket-type picker after saving."` + +**Definition of Done:** + +- [ ] Both keys present in `src/i18n/en.json` under the `edit_promocode` namespace. +- [ ] No duplicate keys, no JSON syntax errors (`yarn build` / lint runs cleanly). + +### Task 2: Helper-text row in the discount form + +**File:** `src/components/forms/promocode-form/forms/discount-base-pc-form.js` + +**Change:** Below the existing checkbox `
...
` (line 30 closing), add a small bootstrap-styled helper paragraph: + +```jsx + + {T.translate("edit_promocode.apply_to_all_tix_helper")} + +``` + +This sits inside the same `col-md-4` container as the checkbox. + +**Definition of Done:** + +- [ ] Helper text renders only on this discount form (no leakage into other promo-code variants). +- [ ] Visible whether the box is checked or unchecked (it's always relevant context). +- [ ] Styling matches surrounding form rows (no MUI components introduced). + +### Task 3: Test coverage + +**File:** `src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js` + +**Add:** new `it()` cases under the existing discount-code suite asserting: + +- The "Apply to all ticket types (Audience: All)" label string is rendered. +- The helper text including "WithPromoCode" (case-insensitive) is rendered immediately below. + +**Definition of Done:** + +- [ ] Both assertions pass. +- [ ] Existing tests in the file still pass. +- [ ] `yarn test --watchAll=false src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js` exits 0. + +## Test Plan (Manual) + +1. Run `yarn start` against a summit that has all four audience values represented across its ticket types. +2. Open or create a discount-code promo code (Domain-Authorized or plain Summit Discount). +3. Verify the checkbox label reads "Apply to all ticket types (Audience: All)". +4. Verify the helper text "Only ticket types with Audience = All are covered..." appears beneath the checkbox. +5. Check the box and save — code should save with no `ticket_types_rules`. Reload — checkbox should still be checked (derived state). +6. Uncheck and use the picker to opt in a `WithPromoCode` ticket type — picker continues to expose it. + +## Acceptance Criteria + +- [ ] New label string conveys "Audience: All" scope clearly. +- [ ] Helper text appears beneath the checkbox and names the three excluded audiences. +- [ ] No FE logic change; no regression in existing form behavior. +- [ ] Integration test asserts both label and helper text. +- [ ] API counterpart (`summit-api/doc/promo-code-apply-to-all-audience-restriction.md`) merged or scheduled in the same release. + +## References + +- API SDS: `summit-api/doc/promo-code-apply-to-all-audience-restriction.md` +- Parent feature plan (local): `docs/superpowers/plans/2026-04-21-promo-codes-for-early-registration-access.md` +- Form file: `src/components/forms/promocode-form/forms/discount-base-pc-form.js` +- i18n file: `src/i18n/en.json` +- Reducer note explaining the UI-only flag: `src/reducers/promocodes/promocode-reducer.js:166-180` +- ClickUp: 86b9vrpxp diff --git a/src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js b/src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js index 98c331f70..05cf847f7 100644 --- a/src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js +++ b/src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js @@ -634,3 +634,66 @@ describe("regression — handleSubmit must not dehydrate allowed_ticket_types", }); }); }); + +describe("DiscountBasePCForm — Apply to all Ticket Types audience restriction copy", () => { + // i18n-react renders raw keys in the jest env (no translator configured), + // so we match against the key strings, not the translated copy. See the + // existing comment around line 368 for the same convention. + const APPLY_ALL_KEY = "edit_promocode.apply_to_all_tix"; + const HELPER_KEY = "edit_promocode.apply_to_all_tix_helper"; + + // Classes that route through DiscountBasePCForm and therefore should + // render both the (Audience: All) label and the helper-text row. + // The helper text belongs to the same col-md-4 column that hosts the + // apply_to_all_tix checkbox. Scope the selector to that column to avoid + // colliding with other `small.form-text.text-muted` rows (e.g. the + // allowed_email_domains caption on the DOMAIN_AUTHORIZED form). + const getApplyToAllHelper = (container) => + container + .querySelector("#apply_to_all_tix") + .closest(".col-md-4") + .querySelector("small.form-text.text-muted"); + + it.each([ + ["SUMMIT_DISCOUNT_CODE"], + ["MEMBER_DISCOUNT_CODE"], + ["SPEAKER_DISCOUNT_CODE"], + ["SPONSOR_DISCOUNT_CODE"], + ["SPEAKERS_DISCOUNT_CODE"], + ["DOMAIN_AUTHORIZED_DISCOUNT_CODE"] + ])( + "for %s: renders the Audience=All label and helper-text below the checkbox", + (class_name) => { + const { container } = renderForm(baseEntity({ class_name })); + + const checkboxLabel = container.querySelector( + "label[for=\"apply_to_all_tix\"]" + ); + expect(checkboxLabel).toBeInTheDocument(); + expect(checkboxLabel.textContent).toBe(APPLY_ALL_KEY); + + const helper = getApplyToAllHelper(container); + expect(helper).toBeInTheDocument(); + expect(helper.textContent).toBe(HELPER_KEY); + } + ); + + it("renders the helper text even when apply_to_all_tix is unchecked", () => { + const { container } = renderForm( + baseEntity({ + class_name: "SUMMIT_DISCOUNT_CODE", + apply_to_all_tix: false + }) + ); + + // Checkbox state reflects the override. + const checkbox = container.querySelector("#apply_to_all_tix"); + expect(checkbox).toBeInTheDocument(); + expect(checkbox.checked).toBe(false); + + // Helper text is always rendered — it's reference copy, not state-gated. + const helper = getApplyToAllHelper(container); + expect(helper).toBeInTheDocument(); + expect(helper.textContent).toBe(HELPER_KEY); + }); +}); diff --git a/src/components/forms/promocode-form/forms/discount-base-pc-form.js b/src/components/forms/promocode-form/forms/discount-base-pc-form.js index aa368b930..b0081856d 100644 --- a/src/components/forms/promocode-form/forms/discount-base-pc-form.js +++ b/src/components/forms/promocode-form/forms/discount-base-pc-form.js @@ -1,7 +1,7 @@ import React from "react"; import T from "i18n-react"; -import BasePCForm from "./base-pc-form"; import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"; +import BasePCForm from "./base-pc-form"; import { DiscountTicketTable } from "../../../tables/dicount-ticket-table"; const DiscountBasePCForm = (props) => { @@ -28,6 +28,9 @@ const DiscountBasePCForm = (props) => { {T.translate("edit_promocode.apply_to_all_tix")} + + {T.translate("edit_promocode.apply_to_all_tix_helper")} + {props.entity.apply_to_all_tix && ( diff --git a/src/i18n/en.json b/src/i18n/en.json index 5b87a0d74..42b6c822a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1000,7 +1000,8 @@ "allowed_ticket_types": "Ticket Types", "amount": "Amount", "rate": "Rate", - "apply_to_all_tix": "Apply to all Ticket Types", + "apply_to_all_tix": "Apply to all ticket types (Audience: All)", + "apply_to_all_tix_helper": "Only ticket types with Audience = All are covered. WithInvitation, WithoutInvitation, and WithPromoCode ticket types must be added explicitly via the ticket-type picker after saving.", "badge_features_apply_to_all_tix_retroactively": "Apply to Tickets already using this Promo Code ?", "promocode_saved": "Promo Code saved successfully.", "promocode_created": "Promo Code created successfully.", From cab35a39f20f82c6ecd0626c761d097292188940 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 11 May 2026 10:33:27 -0500 Subject: [PATCH 2/2] fix(promocode): add Audience=All helper to speakers-discount form The `edit_promocode.apply_to_all_tix` i18n key is also rendered by speakers-promo-code-spec-form.js under the AUTO_GENERATED_SPEAKERS_DISCOUNT_CODE branch (a discount-code path). The label update from the prior commit lands there too, so the same helper row belongs beneath that checkbox to keep the admin clarity consistent across both forms. Spec line citations updated to reflect the actual en.json position. Refs: ClickUp 86b9vrpxp; Codex review of a694adae. --- doc/promo-code-apply-to-all-audience-restriction.md | 5 +++-- src/components/forms/speakers-promo-code-spec-form.js | 11 +++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/doc/promo-code-apply-to-all-audience-restriction.md b/doc/promo-code-apply-to-all-audience-restriction.md index 0d0560ab3..fc0a0e1fc 100644 --- a/doc/promo-code-apply-to-all-audience-restriction.md +++ b/doc/promo-code-apply-to-all-audience-restriction.md @@ -23,6 +23,7 @@ API counterpart SDS: `summit-api/doc/promo-code-apply-to-all-audience-restrictio - `src/i18n/en.json` — update `edit_promocode.apply_to_all_tix` copy; add `edit_promocode.apply_to_all_tix_helper` for the inline explainer. - `src/components/forms/promocode-form/forms/discount-base-pc-form.js` — render the helper text below the checkbox. +- `src/components/forms/speakers-promo-code-spec-form.js` — same checkbox label is reused here under the `AUTO_GENERATED_SPEAKERS_DISCOUNT_CODE` branch; render the same helper text so admins on the bulk-speaker-discount path get the same clarity. - Extend `src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js` (existing — extended for this change) with assertions for the new label and helper text. ### Out of Scope @@ -56,7 +57,7 @@ API counterpart SDS: `summit-api/doc/promo-code-apply-to-all-audience-restrictio ## Context for Implementer - **Entry point:** `src/components/forms/promocode-form/forms/discount-base-pc-form.js` is the only JSX file that changes. The checkbox sits at lines 19-30; the helper row goes immediately after the closing `` of the `form-check` block (line 30) but still inside the `col-md-4` (line 15). -- **i18n keys:** existing `edit_promocode.apply_to_all_tix` (line 1001) gets a copy update. New key `edit_promocode.apply_to_all_tix_helper` is added beside it. +- **i18n keys:** existing `edit_promocode.apply_to_all_tix` (line 1003 in `en.json`) gets a copy update. New key `edit_promocode.apply_to_all_tix_helper` is added beside it. The same key is also rendered by `src/components/forms/speakers-promo-code-spec-form.js:227` under the `AUTO_GENERATED_SPEAKERS_DISCOUNT_CODE` branch — that form gets a matching helper-text row. - **Existing tests touched:** `src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js` already asserts label rendering for the discount-code form; extend rather than duplicate. - **Style:** keep the helper-text styling consistent with surrounding form rows (bootstrap legacy classes — `form-text`, `text-muted`, or equivalent — no MUI). The skill file `skills/react-frontend.md` in the fn-skills vault calls out the legacy-Bootstrap convention for this area. @@ -68,7 +69,7 @@ API counterpart SDS: `summit-api/doc/promo-code-apply-to-all-audience-restrictio **Changes:** -- Update `edit_promocode.apply_to_all_tix` (line 1001) from `"Apply to all Ticket Types"` to `"Apply to all ticket types (Audience: All)"`. +- Update `edit_promocode.apply_to_all_tix` (around line 1003 in `en.json`) from `"Apply to all Ticket Types"` to `"Apply to all ticket types (Audience: All)"`. - Add new key beneath it: `edit_promocode.apply_to_all_tix_helper` with value: `"Only ticket types with Audience = All are covered. WithInvitation, WithoutInvitation, and WithPromoCode ticket types must be added explicitly via the ticket-type picker after saving."` diff --git a/src/components/forms/speakers-promo-code-spec-form.js b/src/components/forms/speakers-promo-code-spec-form.js index 73085c346..a5516cace 100644 --- a/src/components/forms/speakers-promo-code-spec-form.js +++ b/src/components/forms/speakers-promo-code-spec-form.js @@ -14,10 +14,10 @@ import React from "react"; import T from "i18n-react/dist/i18n-react"; import { connect } from "react-redux"; -import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown" -import Input from "openstack-uicore-foundation/lib/components/inputs/text-input" -import PromocodeInput from "openstack-uicore-foundation/lib/components/inputs/promocode-input" -import TagInput from "openstack-uicore-foundation/lib/components/inputs/tag-input" +import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"; +import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"; +import PromocodeInput from "openstack-uicore-foundation/lib/components/inputs/promocode-input"; +import TagInput from "openstack-uicore-foundation/lib/components/inputs/tag-input"; import TicketTypesInput from "openstack-uicore-foundation/lib/components/inputs/ticket-types-input"; import BadgeFeatureInput from "../inputs/badge-feature-input"; import { @@ -227,6 +227,9 @@ class SpeakerPromoCodeSpecForm extends React.Component { {T.translate("edit_promocode.apply_to_all_tix")} + + {T.translate("edit_promocode.apply_to_all_tix_helper")} + {!entity.applyToAllTix && (