mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
feat(dayplan): a day can be deleted from the reorder dialog, after a warning that says what goes with it
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier. The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle. On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import Modal from './Modal';
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
const onClose = vi.fn();
|
||||
@@ -91,6 +92,44 @@ describe('ConfirmDialog', () => {
|
||||
process.off('unhandledRejection', unhandled);
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-010: extra content renders under the message, and the card widens for it', () => {
|
||||
render(
|
||||
<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="Delete it?">
|
||||
<ul aria-label="consequences"><li>Stay at Harbour Hotel</li></ul>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
const list = screen.getByRole('list', { name: 'consequences' });
|
||||
expect(list).toHaveTextContent('Stay at Harbour Hotel');
|
||||
expect(screen.getByText('Delete it?').compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(document.querySelector('.max-w-md')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-011: Escape takes back the question only, not the dialog it was asked from', () => {
|
||||
const closeModal = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<Modal isOpen={true} onClose={closeModal} title="Reorder days">rows</Modal>
|
||||
<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="Delete it?" />
|
||||
</>
|
||||
);
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
expect(closeModal).not.toHaveBeenCalled();
|
||||
// Other keys pass through untouched.
|
||||
fireEvent.keyDown(document.body, { key: 'Enter' });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-012: the danger look comes from the danger tokens, the plain one from the accent', () => {
|
||||
const { unmount } = render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} />);
|
||||
expect(screen.getByRole('button', { name: /delete/i })).toHaveClass('bg-danger');
|
||||
expect(document.querySelector('.bg-danger-soft')).not.toBeNull();
|
||||
unmount();
|
||||
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} danger={false} confirmLabel="Keep" />);
|
||||
expect(screen.getByRole('button', { name: 'Keep' })).toHaveClass('bg-accent', 'text-accent-text');
|
||||
expect(document.querySelector('.bg-danger-soft')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-008: clicking backdrop calls onClose', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="msg" />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useCallback } from 'react'
|
||||
import React, { useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -12,6 +12,8 @@ interface ConfirmDialogProps {
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
danger?: boolean
|
||||
/** Extra content under the message, such as a list of what the action takes with it. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
// Callers commonly pass an async handler that reports its own failure and then
|
||||
@@ -33,18 +35,23 @@ export default function ConfirmDialog({
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
danger = true,
|
||||
children,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Captured and stopped here: the question often opens over another dialog
|
||||
// (a Modal) that closes on Escape too, and one key press should only take
|
||||
// back the question, not the dialog it was asked from.
|
||||
const handleEsc = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key !== 'Escape') return
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleEsc)
|
||||
if (!isOpen) return
|
||||
document.addEventListener('keydown', handleEsc, true)
|
||||
return () => document.removeEventListener('keydown', handleEsc, true)
|
||||
}, [isOpen, handleEsc])
|
||||
|
||||
if (!isOpen) return null
|
||||
@@ -60,22 +67,23 @@ export default function ConfirmDialog({
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm p-6 bg-surface-card"
|
||||
className={`trek-modal-enter rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{danger && (
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-danger-soft flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-danger" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-content">
|
||||
{title || t('common.confirm')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-content-secondary">
|
||||
{message}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,8 +96,8 @@ export default function ConfirmDialog({
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => { runConfirm(onConfirm); onClose() }}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white ${
|
||||
danger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-opacity hover:opacity-90 ${
|
||||
danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel || t('common.delete')}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// FE-COMP-DAYIMPACT-001 to FE-COMP-DAYIMPACT-003
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { BedDouble, MapPin } from 'lucide-react'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import DayImpactList from './DayImpactList'
|
||||
import MDayImpactList from '../../mobile/components/MDayImpactList'
|
||||
import type { ImpactLine } from '../../utils/dayImpactLines'
|
||||
|
||||
const lines: ImpactLine[] = [
|
||||
{ key: 'stay-9', icon: BedDouble, tone: 'danger', text: 'Stay at Harbour Hotel', hint: 'Cancelled with its booking.' },
|
||||
{ key: 'places', icon: MapPin, tone: 'neutral', text: 'Planned places: 3' },
|
||||
]
|
||||
|
||||
describe('DayImpactList', () => {
|
||||
it('FE-COMP-DAYIMPACT-001: one row per line, text and hint, named for assistive tech', () => {
|
||||
render(<DayImpactList lines={lines} label="Delete Tue, Oct 13?" />)
|
||||
const list = screen.getByRole('list', { name: 'Delete Tue, Oct 13?' })
|
||||
const rows = screen.getAllByRole('listitem')
|
||||
expect(list).toContainElement(rows[0])
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toHaveTextContent('Stay at Harbour Hotel')
|
||||
expect(rows[0]).toHaveTextContent('Cancelled with its booking.')
|
||||
expect(rows[1]).toHaveTextContent('Planned places: 3')
|
||||
})
|
||||
|
||||
it('FE-COMP-DAYIMPACT-002: the tone reaches the row: money in the danger tokens, the rest in the content tokens', () => {
|
||||
render(<DayImpactList lines={lines} />)
|
||||
const [stay, places] = screen.getAllByRole('listitem')
|
||||
expect(stay).toHaveAttribute('data-tone', 'danger')
|
||||
expect(screen.getByText('Stay at Harbour Hotel')).toHaveClass('text-danger')
|
||||
expect(stay.querySelector('span')).toHaveClass('bg-danger-soft')
|
||||
expect(screen.getByText('Planned places: 3')).toHaveClass('text-content')
|
||||
expect(places.querySelectorAll('p')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-DAYIMPACT-003: renders nothing without lines, and the phone skin draws the same rows in its own tokens', () => {
|
||||
const { container } = render(<DayImpactList lines={[]} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
|
||||
render(<MDayImpactList lines={lines} label="phone" />)
|
||||
const phone = screen.getByRole('list', { name: 'phone' })
|
||||
expect(phone.className).toContain('var(--m-inner)')
|
||||
const rows = phone.querySelectorAll('li')
|
||||
expect(rows).toHaveLength(2)
|
||||
// Hairlines between rows, not above the first.
|
||||
expect(rows[0].className).not.toContain('border-t')
|
||||
expect(rows[1].className).toContain('border-t')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ImpactLine, ImpactTone } from '../../utils/dayImpactLines'
|
||||
|
||||
/** The class names one shell draws the list with. */
|
||||
export interface ImpactListSkin {
|
||||
list: string
|
||||
row: string
|
||||
/** Between two rows; the desktop card divides them itself. */
|
||||
divider: string
|
||||
chip: string
|
||||
iconSize: number
|
||||
text: string
|
||||
hint: string
|
||||
chipTone: Record<ImpactTone, string>
|
||||
textTone: Record<ImpactTone, string>
|
||||
}
|
||||
|
||||
const DESKTOP: ImpactListSkin = {
|
||||
list: 'mt-4 overflow-hidden rounded-xl border border-edge-faint bg-surface-secondary divide-y divide-edge-faint',
|
||||
row: 'flex items-start gap-3 px-3 py-2.5',
|
||||
divider: '',
|
||||
chip: 'mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg',
|
||||
iconSize: 15,
|
||||
text: 'text-body font-medium leading-snug',
|
||||
hint: 'mt-0.5 text-caption text-content-muted',
|
||||
chipTone: {
|
||||
neutral: 'bg-surface text-content-muted',
|
||||
muted: 'bg-surface text-content-faint',
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
danger: 'bg-danger-soft text-danger',
|
||||
},
|
||||
textTone: { neutral: 'text-content', muted: 'text-content-muted', warning: 'text-content', danger: 'text-danger' },
|
||||
}
|
||||
|
||||
interface DayImpactListProps {
|
||||
lines: ImpactLine[]
|
||||
/** Names the list for assistive tech; the dialog title usually says it already. */
|
||||
label?: string
|
||||
/** The phone passes its own; the default is the desktop card. */
|
||||
skin?: ImpactListSkin
|
||||
}
|
||||
|
||||
/**
|
||||
* What goes with the days being removed, one row per kind of content: an icon
|
||||
* chip in the row's tone, the count, and a caption saying what happens to it.
|
||||
* The rows come ready made from utils/dayImpactLines, and the look comes from a
|
||||
* skin, so the desktop dialog and the phone sheet share this markup and differ
|
||||
* only in their tokens.
|
||||
*/
|
||||
export default function DayImpactList({ lines, label, skin = DESKTOP }: DayImpactListProps) {
|
||||
if (lines.length === 0) return null
|
||||
return (
|
||||
<ul aria-label={label} className={skin.list}>
|
||||
{lines.map(({ key, icon: Icon, text, hint, tone }, i) => (
|
||||
<li key={key} data-tone={tone} className={`${skin.row} ${i > 0 ? skin.divider : ''}`.trim()}>
|
||||
<span className={`${skin.chip} ${skin.chipTone[tone]}`}>
|
||||
<Icon size={skin.iconSize} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`${skin.text} ${skin.textTone[tone]}`}>{text}</p>
|
||||
{hint && <p className={skin.hint}>{hint}</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user