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
23 changes: 23 additions & 0 deletions frontend/src/pages/DashboardV2/components/UpcomingPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,33 @@
align-items: center;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
/*
* Phase 2d migrated this from an inline `style={{cursor: 'pointer'}}`
* on the JSX so the focus-visible rule below can live alongside it
* without a runtime style override fight.
*/
cursor: pointer;
}

.item:last-child { border-bottom: 0; }

/*
* Phase 2d keyboard-focus affordance — mirrors the FieldCard / Phase 2c
* OperationsTimeline pattern:
* - .item:focus resets the platform-default outline so mouse-driven
* focus stays ring-free (the row has no hover background to begin
* with — there is nothing to "preserve parity" with, so the ring
* stands alone for keyboard users only).
* - .item:focus-visible draws a token-driven brand-coloured ring.
* 2px outset is enough to clear the row's bottom-border divider.
*/
.item:focus { outline: none; }

.item:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}

.dateChip {
font-family: var(--fm);
font-size: 11px;
Expand Down
68 changes: 62 additions & 6 deletions frontend/src/pages/DashboardV2/components/UpcomingPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { KeyboardEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { Cloud, Sun } from 'lucide-react';
import dayjs from 'dayjs';
Expand All @@ -22,6 +23,31 @@ interface Props {
* with a small weather strip at the bottom. Row 5, right column in the
* v2 IA. Filters operations to: not completed AND plannedDate within
* [today, today + windowDays].
*
* Phase 2d: pays down the same accessibility debt fixed by Phase 2b
* (FieldCard) and Phase 2c (dashboard OperationsTimeline). Each row was
* a <li> with onClick + an inline `cursor: pointer` and no keyboard
* affordance — unreachable by keyboard, unnamed for assistive tech.
*
* Pattern (identical to 2b/2c):
* - role="button" + tabIndex={0} + onKeyDown (Enter, Space) on each
* row, with preventDefault on Space to suppress page scroll
* - aria-label summarising the row (operation type + field + date,
* plus the optional area suffix when present) so screen readers
* announce a meaningful destination instead of an unnamed button
* - the decorative date chip and area pill are marked aria-hidden
* because their content is duplicated in aria-label
* - inline `cursor: pointer` migrated into the CSS module so the
* `:focus-visible` rule can live alongside it without a runtime
* style override fight
* - a token-driven :focus-visible ring lives in the CSS module
*
* No nested <button> existed in the row, so the FieldCard "decorative
* button → span" step from Phase 2b does not apply here.
*
* Visual layout, the navigation target, the weather strip, the
* filter / sort / windowDays logic and all i18n strings are preserved
* verbatim. Card / Surface API is unchanged.
*/
export default function UpcomingPanel({ operations, windowDays = 7, weather }: Props) {
const { t } = useTranslation();
Expand Down Expand Up @@ -49,24 +75,54 @@ export default function UpcomingPanel({ operations, windowDays = 7, weather }: P
{upcoming.map((op) => {
const d = dayjs(op.plannedDate);
const isToday = d.isSame(today, 'day');
const dateStr = d.format('DD MMM');
const opLabel =
t.operationTypes[op.operationType as keyof typeof t.operationTypes] ?? op.operationType;
const haUnit = dash.haUnit ?? 'га';
const showArea =
typeof op.areaProcessed === 'number' && op.areaProcessed > 0;

// Concise screen-reader summary mirroring the row's visible
// content — operation type, field name, the same date the
// chip shows, and (when present) the area suffix already
// visible on the right. Keeps AT output in sync with the
// visual without inventing new i18n keys.
const ariaLabel = showArea
? `${opLabel}, ${op.fieldName}, ${dateStr}, ${op.areaProcessed} ${haUnit}`
: `${opLabel}, ${op.fieldName}, ${dateStr}`;

const goToOperation = () => navigate(`/operations/${op.id}`);
const handleKeyDown = (e: KeyboardEvent<HTMLLIElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goToOperation();
}
};

return (
<li
key={op.id}
className={s.item}
onClick={() => navigate(`/operations/${op.id}`)}
style={{ cursor: 'pointer' }}
role="button"
tabIndex={0}
aria-label={ariaLabel}
onClick={goToOperation}
onKeyDown={handleKeyDown}
>
<span className={`${s.dateChip} ${isToday ? s.dateChipToday : ''}`}>
{d.format('DD MMM')}
<span
className={`${s.dateChip} ${isToday ? s.dateChipToday : ''}`}
aria-hidden="true"
>
{dateStr}
</span>
<div className={s.body}>
<p className={s.opType}>{opLabel}</p>
<p className={s.fieldName}>{op.fieldName}</p>
</div>
{typeof op.areaProcessed === 'number' && op.areaProcessed > 0 && (
<span className={s.area}>{op.areaProcessed} {dash.haUnit ?? 'га'}</span>
{showArea && (
<span className={s.area} aria-hidden="true">
{op.areaProcessed} {haUnit}
</span>
)}
</li>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import dayjs from 'dayjs';
import UpcomingPanel from '../UpcomingPanel';
import type { AgroOperationDto } from '../../../../types/operation';

/* ── Mocks ─────────────────────────────────────────────────────────── */

const navigateMock = vi.fn();
vi.mock('react-router-dom', async () => {
const actual =
await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => navigateMock,
};
});

vi.mock('../../../../i18n', () => ({
useTranslation: () => ({
t: {
operationTypes: {
Sowing: 'Сівба',
Fertilizing: 'Внесення добрив',
Harvesting: 'Збір врожаю',
},
dashboard: {
noUpcoming: 'Немає запланованих',
haUnit: 'га',
},
},
lang: 'uk',
setLang: vi.fn(),
}),
}));

/* ── Helpers ───────────────────────────────────────────────────────── */

const today = dayjs().startOf('day');
const isoTomorrow = today.add(1, 'day').format('YYYY-MM-DD');
const isoIn3Days = today.add(3, 'day').format('YYYY-MM-DD');
const isoYesterday = today.subtract(1, 'day').format('YYYY-MM-DD');
const isoIn30Days = today.add(30, 'day').format('YYYY-MM-DD');

const makeOp = (overrides: Partial<AgroOperationDto> = {}): AgroOperationDto =>
({
id: 'op-1',
operationType: 'Sowing',
fieldId: 'f-1',
fieldName: 'Південне поле',
plannedDate: isoTomorrow,
isCompleted: false,
...overrides,
} as AgroOperationDto);

const renderPanel = (
ops: AgroOperationDto[],
weather?: { tempC: number; condition: 'clear' | 'cloudy'; location: string },
) =>
render(
<MemoryRouter>
<UpcomingPanel operations={ops} weather={weather} />
</MemoryRouter>,
);

beforeEach(() => navigateMock.mockReset());

/* ── Empty state ───────────────────────────────────────────────────── */

describe('UpcomingPanel — empty state', () => {
it('renders the empty placeholder when no operations supplied', () => {
renderPanel([]);
expect(screen.getByText('Немає запланованих')).toBeInTheDocument();
expect(screen.queryAllByRole('button')).toHaveLength(0);
});

it('renders the empty placeholder when all operations are already completed', () => {
renderPanel([makeOp({ isCompleted: true })]);
expect(screen.getByText('Немає запланованих')).toBeInTheDocument();
expect(screen.queryAllByRole('button')).toHaveLength(0);
});

it('renders the empty placeholder when operations fall outside the 7-day window', () => {
renderPanel([
makeOp({ id: 'past', plannedDate: isoYesterday }),
makeOp({ id: 'far', plannedDate: isoIn30Days }),
]);
expect(screen.getByText('Немає запланованих')).toBeInTheDocument();
expect(screen.queryAllByRole('button')).toHaveLength(0);
});
});

/* ── Render ────────────────────────────────────────────────────────── */

describe('UpcomingPanel — primary render', () => {
it('renders one row per upcoming operation with type, field name and date chip', () => {
renderPanel([
makeOp(),
makeOp({
id: 'op-2',
operationType: 'Harvesting',
fieldName: 'Західне поле',
plannedDate: isoIn3Days,
}),
]);

expect(screen.getByText('Сівба')).toBeInTheDocument();
expect(screen.getByText('Південне поле')).toBeInTheDocument();
expect(screen.getByText('Збір врожаю')).toBeInTheDocument();
expect(screen.getByText('Західне поле')).toBeInTheDocument();
// Both date chips rendered (DD MMM format from dayjs).
expect(screen.getByText(dayjs(isoTomorrow).format('DD MMM'))).toBeInTheDocument();
expect(screen.getByText(dayjs(isoIn3Days).format('DD MMM'))).toBeInTheDocument();
});

it('renders the area suffix when areaProcessed > 0', () => {
renderPanel([makeOp({ areaProcessed: 12.5 } as Partial<AgroOperationDto>)]);
expect(screen.getByText('12.5 га')).toBeInTheDocument();
});

it('omits the area suffix when areaProcessed is missing or zero', () => {
renderPanel([makeOp({ areaProcessed: 0 } as Partial<AgroOperationDto>)]);
// No element should match the "<num> га" pattern.
expect(screen.queryByText(/^\d.*га$/)).not.toBeInTheDocument();
});

it('renders the optional weather strip when weather prop is supplied', () => {
renderPanel([makeOp()], { tempC: 18, condition: 'clear', location: 'Київ' });
expect(screen.getByText('+18°C')).toBeInTheDocument();
expect(screen.getByText('· Київ')).toBeInTheDocument();
});

it('exposes exactly one button per upcoming row (no nested interactives)', () => {
renderPanel([makeOp(), makeOp({ id: 'op-2', plannedDate: isoIn3Days })]);
expect(screen.getAllByRole('button')).toHaveLength(2);
});
});

/* ── A11y (Phase 2d) ───────────────────────────────────────────────── */

describe('UpcomingPanel — accessibility', () => {
it('gives each row an accessible name summarising the operation', () => {
renderPanel([makeOp()]);
const row = screen.getByRole('button', { name: /Сівба/ });
expect(row).toBeInTheDocument();
expect(row.getAttribute('aria-label')).toBe(
`Сівба, Південне поле, ${dayjs(isoTomorrow).format('DD MMM')}`,
);
});

it('appends the area suffix into the accessible name when present', () => {
renderPanel([makeOp({ areaProcessed: 7 } as Partial<AgroOperationDto>)]);
const row = screen.getByRole('button', { name: /Сівба/ });
expect(row.getAttribute('aria-label')).toBe(
`Сівба, Південне поле, ${dayjs(isoTomorrow).format('DD MMM')}, 7 га`,
);
});

it('makes each row keyboard-reachable (tabIndex=0)', () => {
renderPanel([makeOp()]);
const row = screen.getByRole('button', { name: /Сівба/ });
expect(row).toHaveAttribute('tabindex', '0');
});

it('does not expose decorative date chip or area pill as interactive elements', () => {
renderPanel([makeOp({ areaProcessed: 7 } as Partial<AgroOperationDto>)]);
// Only the row itself should be a button; the date chip and the
// area pill are presentational <span>s with aria-hidden.
expect(screen.getAllByRole('button')).toHaveLength(1);
expect(
screen.queryByRole('button', { name: dayjs(isoTomorrow).format('DD MMM') }),
).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '7 га' })).not.toBeInTheDocument();
});
});

/* ── Activation (mouse + keyboard) ─────────────────────────────────── */

describe('UpcomingPanel — activation', () => {
it('navigates to /operations/{id} when a row is clicked', () => {
renderPanel([makeOp({ id: 'op-77' })]);
fireEvent.click(screen.getByRole('button', { name: /Сівба/ }));
expect(navigateMock).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith('/operations/op-77');
});

it('navigates when Enter is pressed while the row has focus', () => {
renderPanel([makeOp({ id: 'op-77' })]);
fireEvent.keyDown(screen.getByRole('button', { name: /Сівба/ }), { key: 'Enter' });
expect(navigateMock).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith('/operations/op-77');
});

it('navigates when Space is pressed while the row has focus', () => {
renderPanel([makeOp({ id: 'op-77' })]);
fireEvent.keyDown(screen.getByRole('button', { name: /Сівба/ }), { key: ' ' });
expect(navigateMock).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith('/operations/op-77');
});

it('Space activation calls preventDefault to suppress page scroll', () => {
renderPanel([makeOp({ id: 'op-77' })]);
const row = screen.getByRole('button', { name: /Сівба/ });
const evt = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
row.dispatchEvent(evt);
expect(evt.defaultPrevented).toBe(true);
expect(navigateMock).toHaveBeenCalledWith('/operations/op-77');
});

it('does not navigate on unrelated keys (Tab, Escape, Shift, ArrowDown)', () => {
renderPanel([makeOp()]);
const row = screen.getByRole('button', { name: /Сівба/ });

fireEvent.keyDown(row, { key: 'Tab' });
fireEvent.keyDown(row, { key: 'Escape' });
fireEvent.keyDown(row, { key: 'Shift' });
fireEvent.keyDown(row, { key: 'ArrowDown' });

expect(navigateMock).not.toHaveBeenCalled();
});

it('routes to the correct id when multiple rows are present', () => {
renderPanel([
makeOp({ id: 'op-A', operationType: 'Sowing' }),
makeOp({ id: 'op-B', operationType: 'Harvesting', plannedDate: isoIn3Days }),
]);

fireEvent.click(screen.getByRole('button', { name: /Збір врожаю/ }));

expect(navigateMock).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith('/operations/op-B');
});
});
Loading