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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions doc/promo-code-apply-to-all-audience-restriction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# 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.
- `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

- 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 `</div>` 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 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.

## Tasks

### Task 1: i18n copy

**File:** `src/i18n/en.json`

**Changes:**

- 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."`

**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 `<div className="form-check abc-checkbox">...</div>` (line 30 closing), add a small bootstrap-styled helper paragraph:

```jsx
<small className="form-text text-muted">
{T.translate("edit_promocode.apply_to_all_tix_helper")}
</small>
```

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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -28,6 +28,9 @@ const DiscountBasePCForm = (props) => {
{T.translate("edit_promocode.apply_to_all_tix")}
</label>
</div>
<small className="form-text text-muted">
{T.translate("edit_promocode.apply_to_all_tix_helper")}
</small>
</div>
</div>
{props.entity.apply_to_all_tix && (
Expand Down
11 changes: 7 additions & 4 deletions src/components/forms/speakers-promo-code-spec-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -227,6 +227,9 @@ class SpeakerPromoCodeSpecForm extends React.Component {
{T.translate("edit_promocode.apply_to_all_tix")}
</label>
</div>
<small className="form-text text-muted">
{T.translate("edit_promocode.apply_to_all_tix_helper")}
</small>
</div>
</div>
{!entity.applyToAllTix && (
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading