feat(06-06): add whole-series edit confirmation prompt
- Create SeriesEditPrompt.tsx: bottom-sheet (phone) / dialog (desktop) matching DeleteConfirmationDialog pattern; focus trap, Escape=cancel, role=dialog/aria-modal - UI-SPEC Surface 6 copy: 'Edit recurring series' heading, 'This will update all occurrences of this event.' body, 'Update series' accent-filled CTA, 'Cancel' ghost - Import SeriesEditPrompt + add seriesEditPromptOpen state to EventForm - handleSubmit gates on occurrence.hasRrule: opens prompt for recurring edits; executeSubmit() fires the existing whole-series PATCH on confirmation - Save CTA label becomes 'Update series' for recurring edits (UI-SPEC primary CTAs) - Non-recurring and create-mode Save behavior unchanged (no prompt)
This commit is contained in:
@@ -45,6 +45,7 @@ import {
|
|||||||
computeNewTimedEnd,
|
computeNewTimedEnd,
|
||||||
computeNewAllDayEnd,
|
computeNewAllDayEnd,
|
||||||
} from '../lib/eventDateTime.js'
|
} from '../lib/eventDateTime.js'
|
||||||
|
import { SeriesEditPrompt } from './SeriesEditPrompt.js'
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -276,6 +277,8 @@ export function EventForm() {
|
|||||||
// ── Validation state ────────────────────────────────────────────────────────
|
// ── Validation state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const [errors, setErrors] = useState<{ title?: string; endTime?: string; recurrenceBound?: string }>({})
|
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 ───────────────────────────────────────────────────────────────
|
// ── Mutations ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -360,9 +363,13 @@ export function EventForm() {
|
|||||||
return Object.keys(newErrors).length === 0
|
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
|
// 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
|
// 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
|
// wall-clock string. The API container is UTC; a naive string was being read
|
||||||
@@ -403,6 +410,20 @@ export function EventForm() {
|
|||||||
mutation.mutate(payload)
|
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 ───────────────────────────────────────────────
|
// ── Keyboard: Escape to close ───────────────────────────────────────────────
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -466,11 +487,15 @@ export function EventForm() {
|
|||||||
|
|
||||||
const isPhone = isPhoneBreakpoint()
|
const isPhone = isPhoneBreakpoint()
|
||||||
const label = eventFormMode === 'edit' ? 'Edit Event' : 'New Event'
|
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
|
const saveLabel = mutation.isPending
|
||||||
? 'Saving…'
|
? 'Saving…'
|
||||||
: eventFormMode === 'edit'
|
: isEditRecurring
|
||||||
? 'Save Changes'
|
? 'Update series'
|
||||||
: 'Create Event'
|
: eventFormMode === 'edit'
|
||||||
|
? 'Save Changes'
|
||||||
|
: 'Create Event'
|
||||||
|
|
||||||
const dialogStyle: React.CSSProperties = isPhone
|
const dialogStyle: React.CSSProperties = isPhone
|
||||||
? {
|
? {
|
||||||
@@ -1014,6 +1039,16 @@ export function EventForm() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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