Milestone v1.0: FamilySync MVP #1
@@ -45,6 +45,7 @@ import {
|
||||
computeNewTimedEnd,
|
||||
computeNewAllDayEnd,
|
||||
} from '../lib/eventDateTime.js'
|
||||
import { SeriesEditPrompt } from './SeriesEditPrompt.js'
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -276,6 +277,8 @@ export function EventForm() {
|
||||
// ── Validation state ────────────────────────────────────────────────────────
|
||||
|
||||
const [errors, setErrors] = useState<{ title?: string; endTime?: string; recurrenceBound?: string }>({})
|
||||
// D-08/D-09: series-edit confirmation prompt state
|
||||
const [seriesEditPromptOpen, setSeriesEditPromptOpen] = useState(false)
|
||||
|
||||
// ── Mutations ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -360,9 +363,13 @@ export function EventForm() {
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) return
|
||||
|
||||
/**
|
||||
* Build the payload and fire the mutation. Called directly for non-recurring edits
|
||||
* and create mode; called from the SeriesEditPrompt onConfirm for recurring edits
|
||||
* (D-08/D-09). The whole-series PATCH reuses the existing /api/events/:uid/edit route
|
||||
* which PUTs the master VEVENT — no RECURRENCE-ID, per D-08.
|
||||
*/
|
||||
const executeSubmit = () => {
|
||||
// BUG A fix: serialize timed events to an unambiguous UTC instant here in
|
||||
// the browser (operator's zone is known) instead of sending a naive local
|
||||
// wall-clock string. The API container is UTC; a naive string was being read
|
||||
@@ -403,6 +410,20 @@ export function EventForm() {
|
||||
mutation.mutate(payload)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) return
|
||||
|
||||
const isEdit = eventFormMode === 'edit' && !!eventFormUid
|
||||
|
||||
// D-08/D-09: gate recurring-series edits behind the confirmation prompt
|
||||
if (isEdit && occurrence?.hasRrule === true) {
|
||||
setSeriesEditPromptOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
executeSubmit()
|
||||
}
|
||||
|
||||
// ── Keyboard: Escape to close ───────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
@@ -466,11 +487,15 @@ export function EventForm() {
|
||||
|
||||
const isPhone = isPhoneBreakpoint()
|
||||
const label = eventFormMode === 'edit' ? 'Edit Event' : 'New Event'
|
||||
// D-08/D-09: CTA label is "Update series" for recurring edits (UI-SPEC "EventForm primary CTAs")
|
||||
const isEditRecurring = eventFormMode === 'edit' && occurrence?.hasRrule === true
|
||||
const saveLabel = mutation.isPending
|
||||
? 'Saving…'
|
||||
: eventFormMode === 'edit'
|
||||
? 'Save Changes'
|
||||
: 'Create Event'
|
||||
: isEditRecurring
|
||||
? 'Update series'
|
||||
: eventFormMode === 'edit'
|
||||
? 'Save Changes'
|
||||
: 'Create Event'
|
||||
|
||||
const dialogStyle: React.CSSProperties = isPhone
|
||||
? {
|
||||
@@ -1014,6 +1039,16 @@ export function EventForm() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* D-08/D-09: Series-edit confirmation prompt — shown when editing a recurring occurrence */}
|
||||
<SeriesEditPrompt
|
||||
open={seriesEditPromptOpen}
|
||||
onConfirm={() => {
|
||||
setSeriesEditPromptOpen(false)
|
||||
executeSubmit()
|
||||
}}
|
||||
onCancel={() => setSeriesEditPromptOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* SeriesEditPrompt — whole-series edit confirmation sheet/dialog (Plan 06-06).
|
||||
*
|
||||
* D-08/D-09: Editing a recurring occurrence must confirm before submitting the
|
||||
* whole-series PATCH (which PUTs the master VEVENT back to Fastmail without a
|
||||
* RECURRENCE-ID, updating all occurrences). This prompt gates that submit.
|
||||
*
|
||||
* Trigger: EventForm calls setOpen(true) when in edit mode with occurrence.hasRrule===true.
|
||||
* Confirm: "Update series" — runs the existing whole-series PATCH; no new privilege.
|
||||
* Cancel: Escape or "Cancel" button — returns to the form without submitting.
|
||||
*
|
||||
* Layout (Surface 6 — UI-SPEC):
|
||||
* - Phone (≤767px): bottom sheet (full-screen overlay, slide from below)
|
||||
* - Tablet/desktop (≥768px): centered dialog (max-width 480px)
|
||||
*
|
||||
* Accessibility:
|
||||
* - role="dialog", aria-modal="true", aria-labelledby → heading
|
||||
* - Focus trap: Tab/Shift+Tab cycle between Cancel and Update series
|
||||
* - Escape key fires Cancel
|
||||
*
|
||||
* Security: T-03-15 — all text rendered as plain-text JSX children.
|
||||
* NEVER use dangerouslySetInnerHTML here.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SeriesEditPromptProps {
|
||||
open: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function SeriesEditPrompt({ open, onConfirm, onCancel }: SeriesEditPromptProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const headingId = 'series-edit-prompt-heading'
|
||||
|
||||
// Focus trap — focus the dialog when it opens
|
||||
useEffect(() => {
|
||||
if (open && dialogRef.current) {
|
||||
dialogRef.current.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Escape key listener — cancel without submitting
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [open, onCancel])
|
||||
|
||||
// Focus trap: Tab / Shift+Tab cycles within dialog
|
||||
const handleDialogKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key !== 'Tab' || !dialogRef.current) return
|
||||
|
||||
const focusable = Array.from(
|
||||
dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1')
|
||||
|
||||
if (focusable.length === 0) return
|
||||
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const isPhone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
|
||||
|
||||
const dialogStyle: React.CSSProperties = isPhone
|
||||
? {
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--color-surface-raised)',
|
||||
borderRadius: 'var(--space-3) var(--space-3) 0 0',
|
||||
boxShadow: '0 -4px 24px rgba(0,0,0,0.12)',
|
||||
padding: 'var(--space-6)',
|
||||
zIndex: 400,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}
|
||||
: {
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'var(--color-surface-raised)',
|
||||
borderRadius: 'var(--space-2)',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.16)',
|
||||
padding: 'var(--space-6)',
|
||||
width: '100%',
|
||||
maxWidth: '480px',
|
||||
zIndex: 400,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onCancel}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'var(--color-overlay)',
|
||||
zIndex: 399,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={headingId}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
style={dialogStyle}
|
||||
>
|
||||
{/* Heading */}
|
||||
<h2
|
||||
id={headingId}
|
||||
style={{
|
||||
margin: '0 0 var(--space-3) 0',
|
||||
fontSize: 'var(--text-heading-size)',
|
||||
fontWeight: 'var(--text-heading-weight)',
|
||||
lineHeight: 'var(--text-heading-line-height)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
{/* Plain text — XSS guard (T-03-15) */}
|
||||
Edit recurring series
|
||||
</h2>
|
||||
|
||||
{/* Body */}
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 var(--space-6) 0',
|
||||
fontSize: 'var(--text-body-size)',
|
||||
fontWeight: 'var(--text-body-weight)',
|
||||
lineHeight: 'var(--text-body-line-height)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{/* Plain text — XSS guard (T-03-15) */}
|
||||
This will update all occurrences of this event.
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 'var(--space-3)',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Cancel — ghost style */}
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
minHeight: '44px',
|
||||
padding: '0 var(--space-4)',
|
||||
borderRadius: 'var(--space-1)',
|
||||
fontSize: 'var(--text-label-size)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
{/* Plain text — XSS guard (T-03-15) */}
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{/* Update series — accent-filled (NOT destructive — this is an edit) */}
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
style={{
|
||||
background: 'var(--color-member-0)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
minHeight: '44px',
|
||||
padding: '0 var(--space-4)',
|
||||
borderRadius: 'var(--space-1)',
|
||||
fontSize: 'var(--text-label-size)',
|
||||
fontWeight: 600,
|
||||
color: '#ffffff',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
{/* Plain text — XSS guard (T-03-15) */}
|
||||
Update series
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user