Port chapters page to React - #476
Conversation
Ports the legacy chapters admin page (frontend/ChapterList.vue, /list_chapters) to /v2/chapters using separate list/new/edit pages instead of a modal, since NO_MODAL is a hard requirement and the edit form's organizer sub-list only becomes available after a chapter is saved. Reuses the existing chapter/list, chapter/save, and chapter/delete Go endpoints; no backend changes. Adds a passthrough zod schema so fields the UI never displays (EmailToken, LastCheckinEmailSent) round-trip unchanged on save instead of being silently wiped, mirroring the Vue page's object-spread behavior. Updates shared/nav.json to point the Admin > Chapters link at /v2/chapters and appends the legacy chapters link to the Legacy menu. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Ports the legacy Vue-based chapters admin UI to the Next.js /v2 app, adding a new chapters list page plus dedicated create/edit pages while continuing to use the existing Go chapter/list, chapter/save, and chapter/delete endpoints.
Changes:
- Adds
/v2/chapters,/v2/chapters/new, and/v2/chapters/[id]pages and associated client components (list/table/form, utils, loading fallbacks). - Extends the typed
ApiClientwith admin-focused chapter list/save/delete helpers and Zod schemas for the full admin chapter payload. - Updates navigation so Admin → Chapters points to
/v2/chapters, while retaining the legacy/list_chapterslink under the Legacy menu.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| shared/nav.json | Routes Admin → Chapters to the new /v2/chapters page and keeps the legacy link in the Legacy menu. |
| frontend-v2/src/lib/api.ts | Adds chapter save/delete paths, admin schemas, and ApiClient methods for chapter admin CRUD. |
| frontend-v2/src/app/(authed)/chapters/page.tsx | Server component that hydrates the chapters admin list query for /chapters. |
| frontend-v2/src/app/(authed)/chapters/loading.tsx | Loading UI for the chapters list route. |
| frontend-v2/src/app/(authed)/chapters/chapters-page.tsx | Client-side chapters list page with filters, counts, CSV export link, and delete action. |
| frontend-v2/src/app/(authed)/chapters/chapter-table.tsx | TanStack table for chapters list with edit/email/delete row actions. |
| frontend-v2/src/app/(authed)/chapters/chapter-utils.ts | Date parsing/coloring helpers and Gmail compose link builder. |
| frontend-v2/src/app/(authed)/chapters/countries.ts | Country list extracted for country/flag selection parity. |
| frontend-v2/src/app/(authed)/chapters/chapter-form.tsx | Create/edit chapter form with organizers sub-list and save behavior. |
| frontend-v2/src/app/(authed)/chapters/form-loading-fallback.tsx | Suspense fallback for the edit form. |
| frontend-v2/src/app/(authed)/chapters/new/page.tsx | New-chapter page wrapper that gates access via chapter list fetch. |
| frontend-v2/src/app/(authed)/chapters/new/loading.tsx | Loading UI for the new-chapter route. |
| frontend-v2/src/app/(authed)/chapters/[id]/page.tsx | Edit-chapter page wrapper that validates the ID and hydrates chapter list data. |
| frontend-v2/src/app/(authed)/chapters/[id]/loading.tsx | Loading UI for the edit-chapter route. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| {isLoading ? ( | ||
| <div className="flex items-center gap-2 text-muted-foreground text-sm"> | ||
| <Loader2 className="h-4 w-4 animate-spin" /> | ||
| Loading chapters... | ||
| </div> | ||
| ) : ( | ||
| <ChapterTable | ||
| chapters={filteredChapters} | ||
| showFacebookColumns={showFacebookColumns} | ||
| onDelete={handleDelete} | ||
| /> | ||
| )} |
There was a problem hiding this comment.
[Claude] Fixed in 8949793 — the list page now renders an explicit error notice (error.message, falling back to a generic message) between the loading and success states, mirroring users-page.tsx/organizers-page.tsx, instead of falling through to an empty table.
| const emails = [ | ||
| chapter.Email, | ||
| ...chapter.Organizers.map((o) => o.Email), | ||
| ].filter(Boolean) | ||
| if (emails.length === 0) return null | ||
| return `https://mail.google.com/mail/?view=cm&fs=1&su=${chapter.Name}&to=${emails.join(',')}` | ||
| } |
There was a problem hiding this comment.
[Claude] Fixed in 8949793 — the compose link is now built with URLSearchParams (view, fs, su, to), so the chapter name and joined emails are properly encoded.
| const chapterId = parseInt(id) | ||
| if (Number.isNaN(chapterId)) { | ||
| notFound() | ||
| } |
There was a problem hiding this comment.
[Claude] Fixed in 8949793 — the id segment is now validated with a strict /^\d+$/ test before Number(id); anything else 404s via notFound().
…delete Save/delete now invalidate the [chapter/list] prefix so the chapter-picker and intl-organizers caches refresh, the row Delete button is disabled while a delete is pending, and two comments are trimmed per review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| const { id } = await params | ||
| const chapterId = parseInt(id) | ||
| if (Number.isNaN(chapterId)) { | ||
| notFound() | ||
| } |
There was a problem hiding this comment.
[Claude] I do not think nuqs applies here: it manages URL query-string state (filters, search params) synced to client components, whereas this is a dynamic route segment (params.id) resolved by the App Router on the server. There is no query state on this page to manage, so I kept the standard params handling (now with strict integer validation per the sibling comment).
|
|
||
| // Not used by the form itself; this call exists purely to gate the page | ||
| // behind the same admin/intl_coordinator check as the rest of /chapters. | ||
| await redirectForHttpError(() => apiClient.getChapterAdminList()) |
There was a problem hiding this comment.
is this hacky? what did vue page do?
There was a problem hiding this comment.
[Claude] Agreed it was hacky — replaced in 8949793. The Vue page was gated server-side by authIntlCoordinatorAccessMiddleware on the /list_chapters HTML route (server/src/main.go:306); the API calls are separately gated by apiIntlCoordinatorAuthMiddleware. The /new page now uses the established session gate instead of a throwaway fetch: getCachedSession() + forbidden() unless the user has admin or intl_coordinator — mirroring intlCoordinatorRoles in server/src/model/adb_auth.go:12 — same as config/page.tsx does for admin. The list and [id] pages keep their prefetch + redirectForHttpError since they genuinely need the data anyway.
| .trim() | ||
| .max(100) | ||
| .refine( | ||
| (v) => v === '' || z.string().email().safeParse(v).success, |
There was a problem hiding this comment.
doesn't zod have a .email() included?
There was a problem hiding this comment.
[Claude] Yes — replaced the manual refine in 8949793 with z.string().trim().max(100).pipe(z.email('Enter a valid email').or(z.literal(''))) (zod v4 top-level z.email()). Same semantics: trims, accepts empty (the field is optional), rejects invalid addresses.
|
|
||
| // Spread the original chapter first so fields the form never displays | ||
| // (e.g. EmailToken) round-trip unchanged instead of being wiped on save. | ||
| const payload: Partial<ChapterAdmin> = { |
There was a problem hiding this comment.
should we extract a function for constructing payload?
There was a problem hiding this comment.
[Claude] Done in 8949793 — extracted buildChapterPayload(chapter, values) into chapter-form-schema.ts next to the schemas, which also gives the round-trip comment (spread original chapter so undisplayed fields like EmailToken are not wiped) a natural home.
| } | ||
| } | ||
|
|
||
| function ChapterFormInner({ |
There was a problem hiding this comment.
this component is massive. pls consider splitting it up.
There was a problem hiding this comment.
[Claude] Done in 8949793 — split following the events form layout: chapter-form-schema.ts (zod schemas, toInitialValues, buildChapterPayload), useChapterForm.ts (mutation + form hook, exports ChapterFormApi like useEventForm), and chapter-form-sections.tsx (Basic Info / Location / Social Links / Dates / Organizers section components, with the repeated coordinate/social/date/organizer fields built declaratively from config arrays). chapter-form.tsx is now ~100 lines of header + submit + section composition. Behavior unchanged.
| function addMonths(date: Date, months: number): Date { | ||
| return new Date(date.getFullYear(), date.getMonth() + months, date.getDate()) | ||
| } | ||
|
|
||
| function addDays(date: Date, days: number): Date { | ||
| const result = new Date(date) | ||
| result.setDate(result.getDate() + days) | ||
| return result | ||
| } |
There was a problem hiding this comment.
if we have date-fns installed, pls use it whenever possible
There was a problem hiding this comment.
[Claude] Done in 8949793 — chapter-utils.ts now uses date-fns (parse/format for the Y-M-D round-trip, addMonths/subMonths/addDays/subDays/addHours/subHours for the threshold math, isAfter/isBefore/isValid for comparisons, differenceInCalendarDays for the tooltip) instead of the hand-rolled helpers.
|
|
||
| // Quadrimesters: Feb-May, Jun-Sep, Oct-Jan. Returns the first day of the | ||
| // quadrimester containing today. | ||
| function currentQuadrimesterStart(now: Date): Date { |
There was a problem hiding this comment.
what's this? i don't remember quadrimesters in vue, but we need behavior to match vue. was this supposed to be quarter?
There was a problem hiding this comment.
[Claude] Quadrimesters genuinely come from the Vue page, not from me — see frontend/ChapterList.vue lines 853-883: the comment "Quadrimesters: Feb–May, Jun–Sep, Oct–Jan" (853-854), currentQuadrimesterStart() (855-863), and colorLastAction() (864-883) with the same prev-quad / +1mo+2wk red threshold / quadEnd-1wk black threshold logic this file ports 1:1. It was added to Vue recently, which may be why it is unfamiliar: commit 359a1b9 "feat(chapters): last action coloring" (May 16, 2026) and follow-up efbc75c "fix(chapters): last action coloring" (May 18, 2026), both by Alexander Taylor. So it is intentionally 4-month blocks, not quarters, and React matches Vue exactly (verified again after the date-fns rewrite in 8949793).
- Show an explicit error notice when the chapter list query fails - Encode the Gmail compose link via URLSearchParams and open it with noopener,noreferrer - Fix the "SuriName" display typo (safe: the persisted value is the country code, not the name) - Reject non-numeric chapter IDs strictly instead of via parseInt - Gate /chapters/new with the session role check (admin or intl_coordinator, mirroring apiIntlCoordinatorAuthMiddleware) instead of a throwaway fetch - Use zod's built-in email validator - Split the chapter form into schema/hook/section modules with a buildChapterPayload helper - Use date-fns for the date helpers in chapter-utils Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ubmit validators.onSubmit already blocks invalid values before onSubmit runs, so parse only narrows the type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migrate ChapterTable to useTable with tableFeatures, reusing AUTO_SORT_FNS, and update ColumnDef generics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // Extracted from frontend/ChapterList.vue's allCountries list (display names | ||
| // only; the persisted value is `code`, so fixing Vue's "SuriName" typo is safe). |
There was a problem hiding this comment.
| // Extracted from frontend/ChapterList.vue's allCountries list (display names | |
| // only; the persisted value is `code`, so fixing Vue's "SuriName" typo is safe). |
There was a problem hiding this comment.
[Claude] Done in 5d80519 — trimmed to a one-liner: // Extracted from frontend/ChapterList.vue's allCountries list.
| // Stricter checks that only apply at submit time, mirroring the legacy Vue | ||
| // page's confirmEditChapterModal validation. |
There was a problem hiding this comment.
| // Stricter checks that only apply at submit time, mirroring the legacy Vue | |
| // page's confirmEditChapterModal validation. | |
| // Stricter checks that only apply at submit time. |
There was a problem hiding this comment.
the columns here stretch to fill the screen and leave lots of blank space between columns. perhaps the columns should have max widths.
There was a problem hiding this comment.
or maybe make it a non-full-screen page, but i forget maybe we're trying to make all the pages full screen so we can get rid of the background image for performance reasons
There was a problem hiding this comment.
[Claude] Fixed in 5d80519 — took your second option, and it turns out it was a porting mistake on my part rather than a styling choice.
Vue rendered this page in .body-wrapper-extra-wide: centered, width: 95%, max-width: 1450px (frontend/static/css/style.css:67-71) — not full-screen. I had ported it as <ContentWrapper size="full">, which is lg:max-w-none lg:mx-0 lg:rounded-none shadow-none mb-0 — hence the columns spreading across the whole viewport.
Two independent signals that full was wrong here:
ContentWrapper's own docs say "Be sure to keep in sync withisFullScreenPagein site-background-controller.tsx", and that list is only/activistsand/intl/organizers—/chapterswas never added. So the page was rendering edge-to-edge while still painting the site background, which is the combinationfullexists to avoid.- Vue's only other extra-wide pages are WorkingGroupList and FacebookEvents, neither of which is full-screen in v2 either.
So page.tsx and loading.tsx are now <ContentWrapper size="2xl"> (lg:max-w-screen-xl → 1536px, the closest Tailwind step to Vue's 1450px), which bounds the table without needing per-column max widths. To your aside: we are not making all pages full-screen — full is reserved for the two genuinely edge-to-edge grids above, and dropping the background image is a side effect of those, not the goal.
I left per-column max-w-* off deliberately: with the wrapper bounded, capping columns just moves the slack rather than removing it, and truncating Name/Mentor would lose text Vue showed in full. Happy to add caps on top if it still reads sparse to you at 1536px.
There was a problem hiding this comment.
was it intentional to drop the cards view for mobile that Vue had? now on mobile it's a horizontally scrolling table
There was a problem hiding this comment.
[Claude] Not intentional — restored in 5d80519. Buefy's b-table defaults mobile-cards to true, so Vue got the card layout for free and I lost it by hand-rolling the table.
chapter-table.tsx now renders the same two-branch layout the other v2 tables already use (hidden md:block table + md:hidden space-y-3 cards) — see users/user-table.tsx:171,217 and events/event-list-table.tsx:205,264. Both branches read from the same table.getRowModel().rows, so sorting and the mentor/name filters apply identically and the FB columns still toggle with showFacebookColumns.
Card layout: flag + name as the heading with the edit/email/delete icon buttons on the right, then a <dl> of Mentor / Last Contact / Last Action (keeping the colorLastAction pill and its tooltip) plus Last FB Event / FB Sync Status when FB columns are on. Empty state is "No chapters found." in both branches.
| // Spreads the original chapter first so fields the form never displays (e.g. | ||
| // EmailToken) round-trip unchanged instead of being wiped on save. |
There was a problem hiding this comment.
i did a lil more looking into EmailToken and it looks like it's not used anymore. maybe we could at least update the comment to mention this / add a TODO.
Claude said:
/international_actions — it's dead
grep-ing all of server/src for "international_actions" finds nothing. There's no HandleFunc/Handle registration for that path in main.go, and no path-prefix mounting would catch it either — the only prefixes registered are for static assets (/static, /dist, /v2, /_next, etc.).
Git history explains it: the whole feature was built in commit f387daa (2021), then fully removed in commit e26251e ("remove international action form", 2023-06-25) — but that commit only cleaned up main.go, international_mailer/international_mailer.go, model/forms.go, and frontend/adb.ts. It left behind:
frontend/FormInternationalActions.vue (the file you're looking at)
server/templates/form_international_actions.html
the form_international_actions DB table
the email_token / EmailToken plumbing in chapters.go
So POSTing to /international_actions today just 404s. The Vue component is orphaned dead code.
For reference, before removal the route/handler looked like:
router.HandleFunc("/international_actions/{id:[0-9]+}/{token:[a-zA-Z0-9]+}", main.InternationalActionsFormHandler)
router.HandleFunc("/international_actions", main.InternationalActionsFormHandler)
The GET side loaded a chapter by ID and checked chapFromDB.EmailToken != token; the POST side decoded InternationalActionFormData and called model.SubmitInternationalActionForm.
EmailToken — its purpose
It's a per-chapter secret capability token, generated once at chapter creation (generateToken() at main.go:67-74, assigned in ChapterSaveHandler at main.go:866 — this still runs today even though nothing consumes it anymore).
It was used to build no-login, unguessable URLs like https://adb.dxe.io/international_actions/{chapterID}/{emailToken}, emailed monthly to chapter organizers so they could submit a check-in update without an ADB account. The GET handler validated the token by equality check against the DB value; on match it rendered the check-in form — the exact form in FormInternationalActions.vue, which passes token straight through as part of its POST body (FormInternationalActions.vue:110).
It pairs with LastCheckinEmailSent (chapters.go:74) — that's why the two columns are still selected/updated together in GetAllChapters, GetAdminChapterById, UpdateChapter, and InsertChapter today. Both are leftovers of the same removed workflow; the mailer that sent those emails (international_mailer.go) was deleted in the same 2023 cleanup commit.
Bottom line: if you're trying to make this form work again, the backend route/handler needs to be re-added (git history has the old implementation to restore). If instead you're trying to finish the 2023 cleanup, the Vue component, the HTML template, the form_international_actions table, and the email_token/EmailToken field are all candidates for removal.
| // Spreads the original chapter first so fields the form never displays (e.g. | |
| // EmailToken) round-trip unchanged instead of being wiped on save. | |
| // Spreads the original chapter first so fields the form never displays (e.g. | |
| // EmailToken) round-trip unchanged instead of being wiped on save. | |
| // Note that EmailToken is no longer used and should be removed anyway. |
There was a problem hiding this comment.
[Claude] Done in 5d80519:
// Spreads the original chapter first so fields the form never displays
// round-trip unchanged instead of being wiped on save.
// TODO: the only such field is EmailToken, which is unused and should be removed.| } | ||
|
|
||
| // Full `fb_pages` row for the chapters admin page. `.passthrough()` preserves | ||
| // fields the UI never edits (e.g. EmailToken) so saves can't silently wipe them. |
There was a problem hiding this comment.
here too
| // fields the UI never edits (e.g. EmailToken) so saves can't silently wipe them. | |
| // fields the UI never edits (e.g. EmailToken) so saves can't silently wipe them. | |
| // Note that EmailToken is unused and should be removed anyway. |
| .nullable() | ||
| .transform((v) => v ?? []), | ||
| }) | ||
| .passthrough() |
There was a problem hiding this comment.
(passthrough called here)
| .passthrough() | |
| // todo: remove when EmailToken is removed | |
| .passthrough() |
- Trim comments per review; add EmailToken removal TODOs. - Use the extra-wide (2xl) content wrapper like Vue's body-wrapper-extra-wide instead of a full-screen page, which stretched the table columns across the viewport. - Restore the mobile card view the Vue b-table had. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Ports the legacy chapters admin page (
frontend/ChapterList.vue,/list_chapters, ~2250 lines) to the Next.js app at/v2/chapters. This is the largest remaining Vue page, managing DxE chapters worldwide (create/edit/delete/list, organizer sub-lists, mentor/name filtering, FB sync status).No backend changes — reuses the existing
chapter/list,chapter/save, andchapter/deleteGo endpoints exactly as the Vue page does.Edit-UX decision: separate pages vs. drawer
Chose separate pages (
/v2/chapters,/v2/chapters/new,/v2/chapters/[id]), matching theusersprecedent, over a right-side drawer.Field count: 12 top-level fields (Name, Mentor, Notes, Region, Country, Lat, Lng, Facebook/Twitter/Instagram URLs, public Email, Flag derived from Country) plus a repeating Organizers sub-list (7 fields each: Name, Email, Phone, Facebook, Instagram, Twitter, Website).
Pros of separate pages (chosen):
userslist +/new+/[id]pattern exactly, minimizing new conventions.Cons (accepted):
Endpoints used
GET chapter/list(existing, admin+intl_coordinator gated) — list + single-chapter lookup (there's no single-chapter GET endpoint, so the edit page fetches the full list and finds the chapter by ID, same as the Vue page keeping all chapters in memory).POST chapter/save— create (noChapterID) or update (withChapterID), same branch-by-presence logic as the Go handler already implements.POST chapter/delete— unchanged.Added typed
ApiClientmethods (getChapterAdminList,saveChapterAdmin,deleteChapterAdmin) andAPI_PATH.CHAPTER_SAVE/API_PATH.CHAPTER_DELETEinfrontend-v2/src/lib/api.ts, alongside a newChapterAdminSchema(zod,.passthrough()) distinct from the existing minimalChapter/ChapterOrganizerSchemaused by the chapter-picker dropdown and/v2/intl/organizers.Feature parity / round-tripped fields
The Go
ChapterWithTokenstruct has no JSON tags, sochapter/saveoverwrites every column in itsUPDATEstatement with whatever the client sends — includingemail_tokenandlast_checkin_email_sent, two fields the Vue page never displays but silently preserves via{...chapter}object-spread before posting. A naive React port that only sent the displayed/edited fields would wipe these columns on every save. Fixed by givingChapterAdminSchema.passthrough()and spreading the originally-fetched chapter into the save payload before applying form edits — the same effect as Vue's spread, without needing to modelsql.NullTime's JSON shape.All Vue fields are ported: Name (locked after creation, matching
:disabled="currentChapter.ChapterID"), Flag (still derived from Country selection, never directly editable), Mentor, Notes, Region, Country, Lat/Lng, Facebook/Twitter/Instagram URLs, public Email, Last Contact / Last Action (date picker + "Today"/"Clear" quick actions), and the Organizers sub-list (add/remove rows, all 7 fields). The 258-country dropdown (with flags) was extracted verbatim from the Vue file'sallCountriesarray intocountries.ts.List page ports: New chapter button, Mentor filter, name filter, Show FB columns toggle, Total/Active chapter counts, Export CSV link (unchanged legacy
/csv/international_organizersroute), and per-row Edit/Email-compose/Delete actions. The Last Action color-coding (quadrimester-based green/red/black) and FB sync status coloring were ported algorithmically intochapter-utils.ts.Intentional differences
0is accepted as valid (the Vue page's!latcheck incorrectly rejected the equator).Nav changes (
shared/nav.json)/v2/chapters(ChaptersList_beta)./list_chapterslink to the end of the Legacy menu (same roles).Test plan
cd frontend-v2 && pnpm installpnpm exec tsc --noEmit— passes (only the known/ignorable pre-existingbaseUrldeprecation warning)pnpm lint— 0 errors, 0 warningspnpm build— succeeds;/chapters,/chapters/new,/chapters/[id]all registeredpnpm test(vitest) — fails identically onorigin/mainwith zero changes applied (pre-existingERR_REQUIRE_ESMin a transitive jsdom dependency, unrelated to this change)🤖 Generated with Claude Code