Skip to content
Merged
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
1 change: 1 addition & 0 deletions frontend-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@next/eslint-plugin-next": "^16.1.1",
"@tailwindcss/forms": "^0.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.17.17",
"@types/react": "^19.0.8",
Expand Down
19 changes: 19 additions & 0 deletions frontend-v2/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend-v2/src/app/event/attendee-input-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export const AttendeeInputField = ({
name="attendee"
placeholder=""
className={cn(
'w-full transition-colors duration-300 border-2',
'w-full transition-colors duration-300',
isDuplicate || isError
? 'text-red-500 border-red-500 focus:border-red-500'
: isNewName
Expand Down
94 changes: 56 additions & 38 deletions frontend-v2/src/app/event/event-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ const EVENT_TYPES = [
const DEFAULT_FIELD_COUNT = 5
const MIN_EMPTY_FIELDS = 1

// Get today's date in YYYY-MM-DD format in the browser's local timezone
const getTodayDate = () => {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}

// Zod schema for form validation.
const attendeeSchema = z.object({
name: z.string().refine(
Expand Down Expand Up @@ -124,7 +133,6 @@ export const EventForm = ({ mode }: EventFormProps) => {
)
if (match) {
const newEventId = match[2]
// Update URL to include the new event ID.
const newPath = isConnection
? `/coaching/${newEventId}`
: `/event/${newEventId}`
Expand All @@ -138,24 +146,35 @@ export const EventForm = ({ mode }: EventFormProps) => {

// Reset the form's dirty state after successful save.
// This prevents "unsaved changes" warning after successful save.
// TanStack form has a bug requiring the `keepDefaultValues` option:
// https://github.com/TanStack/form/issues/1798.
form.reset(
{
eventName: variables.event_name,
eventType: variables.event_type,
eventDate: variables.event_date,
suppressSurvey: variables.suppress_survey,
attendees: (result.attendees ?? [])
.map((name) => ({ name }))
.concat(
Array(MIN_EMPTY_FIELDS)
.fill(null)
.map(() => ({ name: '' })),
),
},
{ keepDefaultValues: true },
)

// Use the attendee order from the current form state, not from the
// server response (result.attendees). The server returns attendees in
// arbitrary database order, but we want to preserve the user's input order.
// This matches the behavior of the legacy Vue version.
const savedAttendeeNames = form.state.values.attendees
.map((a) => a.name.trim())
.filter((n) => n !== '')

const newValues = {
eventName: variables.event_name,
eventType: variables.event_type,
eventDate: variables.event_date,
suppressSurvey: variables.suppress_survey,
attendees: savedAttendeeNames
.map((name) => ({ name }))
.concat(
Array(MIN_EMPTY_FIELDS)
.fill(null)
.map(() => ({ name: '' })),
),
}

// Use keepDefaultValues to work around TanStack Form bug:
// https://github.com/TanStack/form/issues/1798
form.reset(newValues, { keepDefaultValues: true })

// Manually update defaultValues since keepDefaultValues prevents it.
form.options.defaultValues = newValues

// Refresh activist list to include newly created activists.
// This ensures they appear in autocomplete suggestions.
Expand All @@ -175,10 +194,13 @@ export const EventForm = ({ mode }: EventFormProps) => {
if (eventId && !eventData) {
throw new Error('Expected event data to be prefetched')
}

// Default to today's date for new events to avoid Safari showing a confusing
// placeholder. Users can easily change it if needed.
return {
eventName: eventData?.event_name || '',
eventType: eventData?.event_type || (isConnection ? 'Connection' : ''),
eventDate: eventData?.event_date || '',
eventDate: eventData?.event_date || getTodayDate(),
// For new events, non-SF Bay chapters default to not sending surveys.
suppressSurvey:
eventData?.suppress_survey ?? user.ChapterID !== SF_BAY_CHAPTER_ID,
Expand Down Expand Up @@ -249,8 +271,8 @@ export const EventForm = ({ mode }: EventFormProps) => {
})

const checkForDuplicate = (value: string, currentIndex: number): boolean => {
const attendees = form.state.values.attendees
const matches = attendees.filter(
const currentAttendees = form.state.values.attendees
const matches = currentAttendees.filter(
(a, idx) => idx !== currentIndex && a.name === value,
)
return matches.length > 0
Expand All @@ -259,6 +281,7 @@ export const EventForm = ({ mode }: EventFormProps) => {
// Subscribe to form state to reactively show/hide the survey checkbox.
const eventType = useStore(form.store, (state) => state.values.eventType)
const eventName = useStore(form.store, (state) => state.values.eventName)
const isDirty = useStore(form.store, (state) => state.isDirty)

// Predicts whether the server will send a survey by default.
const shouldShowSuppressSurveyCheckbox = useMemo(() => {
Expand Down Expand Up @@ -290,16 +313,16 @@ export const EventForm = ({ mode }: EventFormProps) => {
})
}, [eventName, eventType, user.ChapterID])

// Subscribe to attendees changes to reactively update the count
const attendees = useStore(form.store, (state) => state.values.attendees)
const attendeeCount = useMemo(
() =>
form.state.values.attendees.filter((a) => a.name.trim() !== '').length,
[form.state.values.attendees],
() => attendees.filter((a) => a.name.trim() !== '').length,
[attendees],
)

const ensureMinimumEmptyFields = () => {
const emptyCount = form.state.values.attendees.filter(
(it) => !it.name.length,
).length
const currentAttendees = form.state.values.attendees
const emptyCount = currentAttendees.filter((it) => !it.name.length).length
if (emptyCount < MIN_EMPTY_FIELDS) {
form.pushFieldValue('attendees', { name: '' })
}
Expand All @@ -308,7 +331,7 @@ export const EventForm = ({ mode }: EventFormProps) => {
// Warn before leaving with unsaved changes.
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (form.state.isDirty) {
if (isDirty) {
// `preventDefault()` + setting `returnValue` triggers the
// browser's native unsaved changes warning dialog. Modern
// browsers ignore custom messages in returnValue for security,
Expand All @@ -320,15 +343,10 @@ export const EventForm = ({ mode }: EventFormProps) => {

window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [form.state.isDirty])
}, [isDirty])

const setDateToToday = () => {
// Get today's date in YYYY-MM-DD format in the browser's local timezone
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
form.setFieldValue('eventDate', `${year}-${month}-${day}`)
form.setFieldValue('eventDate', getTodayDate())
}

// Only show loading for activist list since event data is prefetched during SSR
Expand Down Expand Up @@ -389,7 +407,7 @@ export const EventForm = ({ mode }: EventFormProps) => {
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
'h-9 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
field.state.meta.errors[0] && 'border-red-500',
)}
>
Expand Down Expand Up @@ -509,7 +527,7 @@ export const EventForm = ({ mode }: EventFormProps) => {
</div>
<div className="flex items-center gap-4">
<div className="text-sm">
{form.state.isDirty && (
{isDirty && (
<span className="text-red-500 font-medium">Unsaved changes</span>
)}
</div>
Expand Down
14 changes: 12 additions & 2 deletions frontend-v2/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,18 @@ export default async function RootLayout({
<meta name="csrf-token" content={await fetchCsrfToken()} />
</head>
{/* Top padding is to make room for the fixed navbar. */}
<body className="antialiased pt-[3.25rem]">
<Providers>{children}</Providers>
<body className="antialiased">
{/*
Bottom padding uses inline style because Safari doesn't respect
pb-[3.25rem] Tailwind class when combined with body's background styles.
Inline styles work reliably across all browsers.
*/}
<div
className="pt-[3.25rem] min-h-screen"
style={{ paddingBottom: '3rem' }}
>
<Providers>{children}</Providers>
</div>
<Toaster position="bottom-right" />
</body>
</html>
Expand Down
2 changes: 1 addition & 1 deletion frontend-v2/src/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
Expand Down
1 change: 0 additions & 1 deletion frontend-v2/src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
@tailwind utilities;

body {
min-height: 100vh;
background:
linear-gradient(rgba(80, 90, 170, 0.85), rgba(80, 90, 170, 0.85)),
url('/v2/bg.jpg') no-repeat center center fixed;
Expand Down
6 changes: 5 additions & 1 deletion frontend-v2/tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,9 @@ export default {
},
},
},
plugins: [twAnimate, require('tailwindcss-animate')],
plugins: [
require('@tailwindcss/forms'),
twAnimate,
require('tailwindcss-animate'),
],
} satisfies Config