From c71af86ef3840b3684f9acee15f37d2f073b68a4 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Fri, 8 May 2026 14:53:46 -0500 Subject: [PATCH] fix(promocode): preserve ticket type chip labels after validation error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PromocodeForm.handleSubmit mutated state.entity.allowed_ticket_types from [{id,name}] to [id] before calling onSubmit. The mutated entity flowed into redux via UPDATE_PROMOCODE (start action), and on a 412 response no re-hydration happened — the form re-rendered with raw IDs, and TicketTypesInput's getOptionLabel template literal `${e.name}` coerced (number).name into the literal string "undefined" for each chip. Move the ID extraction into normalizeEntity in the action layer so only the request body is dehydrated; redux and form state stay hydrated. Adds a regression test asserting onSubmit receives an entity whose allowed_ticket_types retains {id,name} objects. Refs ClickUp 86b9v01bt Jam: https://jam.dev/c/b1566c62-c802-4f43-b093-9c5c2f5a5fde Co-Authored-By: Claude Opus 4.7 (1M context) --- src/actions/promocode-actions.js | 6 ++++ .../promocode-form.integration.test.js | 34 +++++++++++++++++++ src/components/forms/promocode-form/index.js | 6 ---- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/actions/promocode-actions.js b/src/actions/promocode-actions.js index efd0f23a0..6cc3463c0 100644 --- a/src/actions/promocode-actions.js +++ b/src/actions/promocode-actions.js @@ -147,6 +147,12 @@ const normalizeEntity = (entity) => { normalizedEntity.tags = entity.tags.map((e) => e.tag); } + if (Array.isArray(entity.allowed_ticket_types)) { + normalizedEntity.allowed_ticket_types = entity.allowed_ticket_types.map( + (tt) => (typeof tt === "object" && tt !== null ? tt.id : tt) + ); + } + delete normalizedEntity.owner; delete normalizedEntity.owner_id; delete normalizedEntity.speaker; 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 6e88578eb..98c331f70 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 @@ -600,3 +600,37 @@ describe("regression — non-DomainAuthorized classes are unaffected by the layo } ); }); + +describe("regression — handleSubmit must not dehydrate allowed_ticket_types", () => { + // Reproduces the "Ticket Types reverts to undefined" bug (Jam + // b1566c62-c802-4f43-b093-9c5c2f5a5fde, ClickUp 86b9v01bt). Pre-fix, + // handleSubmit mutated state.entity.allowed_ticket_types from [{id, name}] + // to [id]; on a 412, the form re-rendered with raw IDs, and the + // openstack-uicore TicketTypesInput's getOptionLabel = `${e.name}` produced + // the literal string "undefined" for each chip. The action layer + // (normalizeEntity in promocode-actions.js) is responsible for the + // hydrated → API-shape conversion; the form must hand off untouched + // option objects. + it("passes onSubmit an entity whose allowed_ticket_types retain {id,name} objects", () => { + const onSubmit = jest.fn(); + const ticketType = { id: 197, name: "Early Access Members" }; + renderForm( + baseEntity({ + class_name: "DOMAIN_AUTHORIZED_PROMO_CODE", + allowed_email_domains: ["@valid.com"], + allowed_ticket_types: [ticketType] + }), + { onSubmit } + ); + + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + const submittedEntity = onSubmit.mock.calls[0][0]; + expect(submittedEntity.allowed_ticket_types).toEqual([ticketType]); + expect(submittedEntity.allowed_ticket_types[0]).toMatchObject({ + id: 197, + name: "Early Access Members" + }); + }); +}); diff --git a/src/components/forms/promocode-form/index.js b/src/components/forms/promocode-form/index.js index 5cfb908ea..ba292222a 100644 --- a/src/components/forms/promocode-form/index.js +++ b/src/components/forms/promocode-form/index.js @@ -141,12 +141,6 @@ class PromocodeForm extends React.Component { const { entity } = this.state; const typeScope = this.fragmentParser.getParam("type"); - if (entity.allowed_ticket_types.length > 0) { - entity.allowed_ticket_types = entity.allowed_ticket_types.map( - (tt) => tt.id - ); - } - if (this.validate()) { onSubmit(entity, typeScope === "sponsor"); }