From b5d2b443e296e29ad93e80ba0148613d6069f3c3 Mon Sep 17 00:00:00 2001 From: Alexander Taylor Date: Sun, 3 May 2026 06:34:53 +0000 Subject: [PATCH 1/2] feat(activists): return chapter name with activist --- server/src/model/activist.go | 101 ++++++++++++++++++----------------- 1 file changed, 52 insertions(+), 49 deletions(-) diff --git a/server/src/model/activist.go b/server/src/model/activist.go index c3ec3699..d1b3c79a 100644 --- a/server/src/model/activist.go +++ b/server/src/model/activist.go @@ -53,59 +53,60 @@ FROM activists const selectActivistExtraBaseQuery string = ` SELECT - lower(email) as email, - email_updated, - facebook, + lower(a.email) as email, + a.email_updated, + a.facebook, a.id, a.chapter_id, - mpi, + IFNULL(fp.name, '') AS chapter_name, + a.mpi, a.notes, - vision_wall, - mpp_requirements, - voting_agreement, - street_address, - city, - state, - location, - address_updated, - location_updated, - lat, - lng, + a.vision_wall, + mpp_requirements.MPP_Requirements as mpp_requirements, + a.voting_agreement, + a.street_address, + a.city, + a.state, + a.location, + a.address_updated, + a.location_updated, + a.lat, + a.lng, a.name, - name_updated, - preferred_name, - phone, - phone_updated, - pronouns, - language, - accessibility, - dob, - - activist_level, - source, - hiatus, - - connector, - training0, - training1, - training4, - training5, - training6, - consent_quiz, - training_protest, - dev_application_date, - dev_application_type, - dev_quiz, - dev_interest, - - cm_first_email, - cm_approval_email, - prospect_organizer, - prospect_chapter_member, - referral_friends, - referral_apply, - referral_outlet, - interest_date, + a.name_updated, + a.preferred_name, + a.phone, + a.phone_updated, + a.pronouns, + a.language, + a.accessibility, + a.dob, + + a.activist_level, + a.source, + a.hiatus, + + a.connector, + a.training0, + a.training1, + a.training4, + a.training5, + a.training6, + a.consent_quiz, + a.training_protest, + a.dev_application_date, + a.dev_application_type, + a.dev_quiz, + a.dev_interest, + + a.cm_first_email, + a.cm_approval_email, + a.prospect_organizer, + a.prospect_chapter_member, + a.referral_friends, + a.referral_apply, + a.referral_outlet, + a.interest_date, a.assigned_to, DATE_FORMAT(a.followup_date, "%Y-%m-%d") as followup_date, @@ -235,6 +236,8 @@ LEFT JOIN ( group by ea.activist_id ) currentMonth on currentMonth.activist_id = activists.id ) mpp_requirements on mpp_requirements.activist_id_mpp = a.id + +LEFT JOIN fb_pages fp ON fp.chapter_id = a.chapter_id ` const insertActivistQuery string = `INSERT INTO activists SET ` + From 2f011c308c774a52fe3cbf35a844e25263f62bc0 Mon Sep 17 00:00:00 2001 From: Alexander Taylor Date: Wed, 29 Apr 2026 17:56:09 +0000 Subject: [PATCH 2/2] feat(activists): allow editing in new frontend --- .../activists/[id]/activist-detail.tsx | 297 ++++++++++--- .../(authed)/activists/[id]/section-form.tsx | 403 ++++++++++++++++++ .../(authed)/activists/column-definitions.ts | 75 +++- .../(authed)/activists/column-selector.tsx | 31 +- .../activists/field-description-popover.tsx | 34 ++ frontend-v2/src/lib/api.ts | 25 ++ frontend-v2/src/lib/api/activists.ts | 52 +++ 7 files changed, 832 insertions(+), 85 deletions(-) create mode 100644 frontend-v2/src/app/(authed)/activists/[id]/section-form.tsx create mode 100644 frontend-v2/src/app/(authed)/activists/field-description-popover.tsx diff --git a/frontend-v2/src/app/(authed)/activists/[id]/activist-detail.tsx b/frontend-v2/src/app/(authed)/activists/[id]/activist-detail.tsx index 1afacf86..e0e4dae2 100644 --- a/frontend-v2/src/app/(authed)/activists/[id]/activist-detail.tsx +++ b/frontend-v2/src/app/(authed)/activists/[id]/activist-detail.tsx @@ -1,21 +1,30 @@ 'use client' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' import Link from 'next/link' -import { ArrowLeft } from 'lucide-react' +import { ArrowLeft, Pencil } from 'lucide-react' import { API_PATH, apiClient, ActivistJSON, ActivistColumnName, } from '@/lib/api' +import { Button } from '@/components/ui/button' import { COLUMN_DEFINITIONS, + isEditableActivistField, + type ColumnCategory, type ColumnDefinition, } from '../column-definitions' import { getActivistDisplayName } from '../display-name' +import { FieldDescriptionPopover } from '../field-description-popover' import { formatValue } from '../format-value' import { LinkedValue } from '../linked-value' +import { ActivistSectionForm } from './section-form' + +const NOTES_SECTION_KEY = '__notes__' +type SectionKey = ColumnCategory | typeof NOTES_SECTION_KEY function useActivist(activistId: number) { return useQuery({ @@ -24,8 +33,90 @@ function useActivist(activistId: number) { }) } +const EDITABLE_FIELDS_BY_CATEGORY: Map = + (() => { + const map = new Map() + for (const def of COLUMN_DEFINITIONS) { + if (!isEditableActivistField(def.name)) continue + // Notes is rendered as its own section, not as part of "Other". + if (def.name === 'notes') continue + const list = map.get(def.category) ?? [] + list.push(def) + map.set(def.category, list) + } + return map + })() + +const NOTES_DEFINITION = COLUMN_DEFINITIONS.find((d) => d.name === 'notes')! +if (!NOTES_DEFINITION) { + throw new Error("Column definition for 'notes' is missing") +} + +const SECTION_ORDER: ColumnCategory[] = (() => { + const order: ColumnCategory[] = [] + const seen = new Set() + for (const def of COLUMN_DEFINITIONS) { + if (seen.has(def.category)) continue + seen.add(def.category) + order.push(def.category) + } + return order +})() + export function ActivistDetail({ activistId }: { activistId: number }) { const { data: activist, isError, isLoading } = useActivist(activistId) + const [editingSection, setEditingSection] = useState(null) + const [isFormDirty, setIsFormDirty] = useState(false) + + const confirmDiscard = useCallback(() => { + if (!isFormDirty) return true + return window.confirm( + 'You have unsaved changes. Discard them and leave this section?', + ) + }, [isFormDirty]) + + const handleEdit = useCallback( + (section: SectionKey) => { + if (editingSection !== null && editingSection !== section) { + if (!confirmDiscard()) return + } + setEditingSection(section) + setIsFormDirty(false) + }, + [editingSection, confirmDiscard], + ) + + const handleCancel = useCallback(() => { + setEditingSection(null) + setIsFormDirty(false) + }, []) + + const handleSaved = useCallback(() => { + setEditingSection(null) + setIsFormDirty(false) + }, []) + + // Warn on full page unload (close/refresh) while edits are unsaved. + useEffect(() => { + if (!isFormDirty) return + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault() + // Required for older browsers that read returnValue. + e.returnValue = '' + } + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [isFormDirty]) + + const groupedFields = useMemo(() => { + if (!activist) return new Map() + return buildReadOnlyFields(activist) + }, [activist]) + + const notesValue = useMemo(() => { + if (!activist) return '' + return formatValue(activist.notes, 'notes') + }, [activist]) if (isLoading) { return
Loading activist details...
@@ -36,30 +127,6 @@ export function ActivistDetail({ activistId }: { activistId: number }) { const displayName = getActivistDisplayName(activist) - // Group fields by category using column definitions - const groupedFields = new Map< - string, - { label: string; value: string; linkType?: ColumnDefinition['linkType'] }[] - >() - let notes = '' - - for (const def of COLUMN_DEFINITIONS) { - if (def.hideOnDetailPage) continue - - const rawValue = activist[def.name as keyof ActivistJSON] - const formatted = formatValue(rawValue, def.name as ActivistColumnName) - if (!formatted) continue - - if (def.name === 'notes') { - notes = formatted - continue - } - - const group = groupedFields.get(def.category) ?? [] - group.push({ label: def.label, value: formatted, linkType: def.linkType }) - groupedFields.set(def.category, group) - } - return ( <>
@@ -89,38 +156,156 @@ export function ActivistDetail({ activistId }: { activistId: number }) {
- {Array.from(groupedFields.entries()).map(([category, fields]) => ( -
-

- {category} -

-
- {fields.map(({ label, value, linkType }) => ( -
-
- {label} -
-
- {linkType ? ( - - ) : ( - value - )} -
-
- ))} -
-
- ))} - - {/* Notes value may be long, so don't subject it to two-column view. */} - {notes && ( -
-

Notes

-

{notes}

-
- )} + {SECTION_ORDER.map((category) => { + const fields = groupedFields.get(category) ?? [] + const editableFields = EDITABLE_FIELDS_BY_CATEGORY.get(category) + if (fields.length === 0 && !editableFields) return null + const isEditing = editingSection === category + return ( +
+ handleEdit(category)} + /> + {isEditing && editableFields ? ( + + ) : ( +
+ {fields.map( + ({ label, value, description, linkType, isEmpty }) => ( +
+
+ {label} + {description && ( + + )} +
+
+ {!isEmpty && linkType ? ( + + ) : ( + value + )} +
+
+ ), + )} +
+ )} +
+ ) + })} + + {/* Notes is its own section so its (potentially long) value can use the + full width in both read and edit modes. */} +
+ handleEdit(NOTES_SECTION_KEY)} + /> + {editingSection === NOTES_SECTION_KEY ? ( + + ) : notesValue ? ( +

{notesValue}

+ ) : ( +

No notes

+ )} +
) } + +interface DisplayField { + label: string + value: string + description?: string + linkType?: ColumnDefinition['linkType'] + isEmpty: boolean +} + +function buildReadOnlyFields( + activist: ActivistJSON, +): Map { + const grouped = new Map() + for (const def of COLUMN_DEFINITIONS) { + if (def.hideOnDetailPage) continue + if (def.name === 'notes') continue + + const rawValue = activist[def.name as keyof ActivistJSON] + // Empty string, 0, false + const isEmpty = !rawValue + const formatted = formatValue(rawValue, def.name as ActivistColumnName) + const isFormattedBlank = !formatted + + const group = grouped.get(def.category) ?? [] + group.push({ + label: def.label, + value: isFormattedBlank ? '—' : formatted, + description: def.description, + linkType: def.linkType, + isEmpty, + }) + grouped.set(def.category, group) + } + return grouped +} + +function SectionHeader({ + title, + showEdit, + onEdit, +}: { + title: string + showEdit: boolean + onEdit: () => void +}) { + return ( +
+

{title}

+ {showEdit && ( + + )} +
+ ) +} diff --git a/frontend-v2/src/app/(authed)/activists/[id]/section-form.tsx b/frontend-v2/src/app/(authed)/activists/[id]/section-form.tsx new file mode 100644 index 00000000..0035ba7d --- /dev/null +++ b/frontend-v2/src/app/(authed)/activists/[id]/section-form.tsx @@ -0,0 +1,403 @@ +'use client' + +import { useEffect, useMemo } from 'react' +import { useForm, useStore } from '@tanstack/react-form' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' +import { Loader2, Save, X } from 'lucide-react' +import { + API_PATH, + apiClient, + ActivistJSON, + ActivistPatchInput, +} from '@/lib/api' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + type ActivistEditInputType, + type ColumnDefinition, +} from '../column-definitions' +import { FieldDescriptionPopover } from '../field-description-popover' + +type FieldValue = string | boolean | number +type FormValues = Record + +// assigned_to uses 0 to mean "unassigned" — the column is non-nullable. +const UNASSIGNED_USER_ID = 0 + +// Radix Select disallows empty SelectItem values, so we use a sentinel for +// enum-select fields whose editOptions include the empty string. +const ENUM_EMPTY_SENTINEL = '__empty__' + +function LabelRow({ + htmlFor, + label, + description, +}: { + htmlFor: string + label: string + description?: string +}) { + return ( +
+ + {description && ( + + )} +
+ ) +} + +const inputTypeFor = (def: ColumnDefinition): ActivistEditInputType => + def.editInputType ?? 'text' + +const initialValueFor = ( + activist: ActivistJSON, + def: ColumnDefinition, +): FieldValue => { + const raw = (activist as Record)[def.name] + switch (inputTypeFor(def)) { + case 'checkbox': + return Boolean(raw) + case 'user-select': + return typeof raw === 'number' ? raw : UNASSIGNED_USER_ID + case 'date': + // needs YYYY-MM-DD; the API returns either that + // or a longer ISO timestamp. + return typeof raw === 'string' && raw.length >= 10 ? raw.slice(0, 10) : '' + default: + return typeof raw === 'string' ? raw : '' + } +} + +interface ActivistSectionFormProps { + activistId: number + activist: ActivistJSON + fields: ColumnDefinition[] + onSaved: () => void + onCancel: () => void + onDirtyChange: (dirty: boolean) => void +} + +export function ActivistSectionForm({ + activistId, + activist, + fields, + onSaved, + onCancel, + onDirtyChange, +}: ActivistSectionFormProps) { + const queryClient = useQueryClient() + + const hasUserSelect = fields.some((f) => inputTypeFor(f) === 'user-select') + const usersQuery = useQuery({ + queryKey: [API_PATH.USERS], + queryFn: ({ signal }) => apiClient.getUsers(signal), + enabled: hasUserSelect, + }) + + const initialValues = useMemo(() => { + const obj: FormValues = {} + for (const f of fields) obj[f.name] = initialValueFor(activist, f) + return obj + }, [activist, fields]) + + const mutation = useMutation({ + mutationFn: (patch: ActivistPatchInput) => + apiClient.patchActivist(activistId, patch), + onSuccess: (updated) => { + queryClient.setQueryData([API_PATH.ACTIVIST_GET, activistId], updated) + queryClient.invalidateQueries({ queryKey: [API_PATH.ACTIVISTS_SEARCH] }) + toast.success('Saved') + onSaved() + }, + onError: (err: unknown) => { + const msg = err instanceof Error ? err.message : 'Failed to save' + toast.error(msg) + }, + }) + + const form = useForm({ + defaultValues: initialValues, + onSubmit: async ({ value }) => { + const patch: Record = {} + for (const f of fields) { + if ( + form.state.fieldMeta[f.name]?.isDirty && + !Object.is(initialValues[f.name], value[f.name]) + ) { + patch[f.name] = value[f.name] + } + } + if (Object.keys(patch).length === 0) { + onSaved() + return + } + await mutation.mutateAsync(patch as ActivistPatchInput) + }, + }) + + const isDirty = useStore(form.store, (state) => state.isDirty) + useEffect(() => { + onDirtyChange(isDirty) + return () => onDirtyChange(false) + }, [isDirty, onDirtyChange]) + + const isSaving = mutation.isPending + + return ( +
{ + e.preventDefault() + form.handleSubmit() + }} + className="flex flex-col gap-4" + > +
+ {fields.map((def) => { + const inputId = `activist-field-${def.name}` + const inputType = inputTypeFor(def) + return ( + + {(field) => { + const error = field.state.meta.errors[0] + const errorMessage = + typeof error === 'string' + ? error + : (error as { message?: string } | undefined)?.message + + if (inputType === 'checkbox') { + return ( +
+
+ + {def.description && ( + + )} +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ ) + } + + if (inputType === 'enum-select') { + const stringValue = + typeof field.state.value === 'string' + ? field.state.value + : '' + return ( +
+ + + {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ ) + } + + if (inputType === 'user-select') { + const numericValue = + typeof field.state.value === 'number' + ? field.state.value + : UNASSIGNED_USER_ID + return ( +
+ + + {usersQuery.isError && ( +

+ Failed to load users +

+ )} + {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ ) + } + + if (inputType === 'textarea') { + return ( +
+ +