Skip to content

Port chapters page to React - #476

Open
jakehobbs wants to merge 7 commits into
mainfrom
jake/react-chapters
Open

Port chapters page to React#476
jakehobbs wants to merge 7 commits into
mainfrom
jake/react-chapters

Conversation

@jakehobbs

Copy link
Copy Markdown
Member

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, and chapter/delete Go 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 the users precedent, 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):

  • The Vue page itself disables adding organizers until the chapter is saved ("Please save the new chapter before adding organizers.") — a full page gives that follow-up step room to breathe, and after creating a chapter we navigate straight to its edit page so organizers can be added immediately.
  • Deep-linkable URLs per chapter (useful since chapters are referenced from Users, International Organizers, and activist records).
  • Matches the established users list + /new + /[id] pattern exactly, minimizing new conventions.
  • More horizontal room for the organizer sub-list than a narrow drawer would allow.

Cons (accepted):

  • An extra navigation hop compared to editing inline in the list (mitigated by TanStack Query cache reuse between list/edit — no extra fetch when going back).
  • Loses the Vue modal's "quick edit without leaving the list" feel.

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 (no ChapterID) or update (with ChapterID), same branch-by-presence logic as the Go handler already implements.
  • POST chapter/delete — unchanged.

Added typed ApiClient methods (getChapterAdminList, saveChapterAdmin, deleteChapterAdmin) and API_PATH.CHAPTER_SAVE / API_PATH.CHAPTER_DELETE in frontend-v2/src/lib/api.ts, alongside a new ChapterAdminSchema (zod, .passthrough()) distinct from the existing minimal Chapter/ChapterOrganizerSchema used by the chapter-picker dropdown and /v2/intl/organizers.

Feature parity / round-tripped fields

The Go ChapterWithToken struct has no JSON tags, so chapter/save overwrites every column in its UPDATE statement with whatever the client sends — including email_token and last_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 giving ChapterAdminSchema .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 model sql.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's allCountries array into countries.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_organizers route), 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 into chapter-utils.ts.

Intentional differences

  • Last Contact/Last Action edits happen inline in the full edit form rather than via separate one-off modals that auto-save on click.
  • The organizer detail fields (Facebook/Instagram/Twitter/Website) are always visible in a grid per organizer instead of behind an expand/collapse row.
  • After creating a new chapter, the user is redirected to its edit page (not back to the list) so organizers can be added right away.
  • Lat/Lng of exactly 0 is accepted as valid (the Vue page's !lat check incorrectly rejected the equator).

Nav changes (shared/nav.json)

  • Admin > Chapters now points to /v2/chapters (ChaptersList_beta).
  • Appended the old /list_chapters link to the end of the Legacy menu (same roles).

Test plan

  • cd frontend-v2 && pnpm install
  • pnpm exec tsc --noEmit — passes (only the known/ignorable pre-existing baseUrl deprecation warning)
  • pnpm lint — 0 errors, 0 warnings
  • pnpm build — succeeds; /chapters, /chapters/new, /chapters/[id] all registered
  • pnpm test (vitest) — fails identically on origin/main with zero changes applied (pre-existing ERR_REQUIRE_ESM in a transitive jsdom dependency, unrelated to this change)
  • Manual click-through against a dev DB (not run in this environment)

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings July 19, 2026 21:04
@jakehobbs
jakehobbs requested a review from alexsapps as a code owner July 19, 2026 21:04
@jakehobbs jakehobbs added the react-port Vue 2 → React (frontend-v2) port work label Jul 19, 2026
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 1c7cb9d6-bbf1-48cd-b738-98d894fbaad4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jake/react-chapters

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ApiClient with 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_chapters link 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.

Comment on lines +181 to +192
{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}
/>
)}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +117 to +123
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(',')}`
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread frontend-v2/src/app/(authed)/chapters/chapter-table.tsx Outdated
Comment thread frontend-v2/src/app/(authed)/chapters/countries.ts Outdated
Comment on lines +21 to +24
const chapterId = parseInt(id)
if (Number.isNaN(chapterId)) {
notFound()
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>
Comment on lines +20 to +24
const { id } = await params
const chapterId = parseInt(id)
if (Number.isNaN(chapterId)) {
notFound()
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we use nuqs?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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())

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this hacky? what did vue page do?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't zod have a .email() included?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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> = {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we extract a function for constructing payload?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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({

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this component is massive. pls consider splitting it up.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +46 to +54
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
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we have date-fns installed, pls use it whenever possible

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Done in 8949793chapter-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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's this? i don't remember quadrimesters in vue, but we need behavior to match vue. was this supposed to be quarter?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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).

jakehobbs and others added 4 commits July 19, 2026 15:10
- 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>
Comment on lines +7 to +8
// Extracted from frontend/ChapterList.vue's allCountries list (display names
// only; the persisted value is `code`, so fixing Vue's "SuriName" typo is safe).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Extracted from frontend/ChapterList.vue's allCountries list (display names
// only; the persisted value is `code`, so fixing Vue's "SuriName" typo is safe).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Done in 5d80519 — trimmed to a one-liner: // Extracted from frontend/ChapterList.vue's allCountries list.

Comment on lines +39 to +40
// Stricter checks that only apply at submit time, mirroring the legacy Vue
// page's confirmEditChapterModal validation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Stricter checks that only apply at submit time, mirroring the legacy Vue
// page's confirmEditChapterModal validation.
// Stricter checks that only apply at submit time.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Applied in 5d80519.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the columns here stretch to fill the screen and leave lots of blank space between columns. perhaps the columns should have max widths.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 with isFullScreenPage in site-background-controller.tsx", and that list is only /activists and /intl/organizers/chapters was never added. So the page was rendering edge-to-edge while still painting the site background, which is the combination full exists 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was it intentional to drop the cards view for mobile that Vue had? now on mobile it's a horizontally scrolling table

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +81 to +82
// Spreads the original chapter first so fields the form never displays (e.g.
// EmailToken) round-trip unchanged instead of being wiped on save.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here too

Suggested change
// 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Applied in 5d80519.

.nullable()
.transform((v) => v ?? []),
})
.passthrough()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(passthrough called here)

Suggested change
.passthrough()
// todo: remove when EmailToken is removed
.passthrough()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Applied in 5d80519.

- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

react-port Vue 2 → React (frontend-v2) port work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants