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
17 changes: 12 additions & 5 deletions frontend/src/pages/Fields/components/FieldCard.module.css
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
/*
* .card augments the design-system <Card> primitive that wraps this tile.
*
* Card already supplies background, border and border-radius from tokens
* (radius="lg", subtle variant, bordered). The rules below add only the
* tile-specific affordances Card does not provide:
* - cursor + relative positioning for the hover overlay
* - overflow:hidden so the bleed-to-edge thumb clips against the radius
* - the lift + shadow hover transition
* - the descendant rule that fades the hover CTA in on hover
*/
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
cursor: pointer;
position: relative;
transition: border-color 150ms ease, transform 150ms ease, box-shadow 150ms ease;
transition: transform 150ms ease, box-shadow 150ms ease;
}

.card:hover {
border-color: var(--border-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
Expand Down
31 changes: 29 additions & 2 deletions frontend/src/pages/Fields/components/FieldCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,32 @@ import { ArrowRight } from 'lucide-react';
import type { FieldDto } from '../../../types/field';
import { useTranslation } from '../../../i18n';
import { getCropTagStyle } from '../../../utils/cropTagColors';
import { Card } from '../../../design-system';
import s from './FieldCard.module.css';

interface Props {
field: FieldDto;
}

/**
* FieldCard — tile rendered inside the fields grid (FieldsList).
*
* Phase 2a: outer container migrated from `<div className={s.card}>`
* to the design-system `<Card>` primitive. Card supplies the
* background, border and radius from tokens (`radius="lg"` mirrors
* the legacy `var(--radius-lg)`); Card's auto padding/gap are
* disabled (`p="0"`, `gap="0"`) because this tile composes its own
* internal layout — a thumb that bleeds to the edges plus an info
* block with its own padding. The local CSS module retains only the
* tile-specific affordances (cursor, hover lift + shadow, the
* absolutely-positioned hover overlay, and the inner thumb/info/
* badge/pill rules).
*
* Behavior preserved verbatim from the legacy implementation,
* including the existing `onClick` on a non-button element (an
* accessibility debt that is intentionally out of scope for this
* pure-surface migration).
*/
export default function FieldCard({ field }: Props) {
const navigate = useNavigate();
const { t } = useTranslation();
Expand All @@ -24,7 +44,14 @@ export default function FieldCard({ field }: Props) {
};

return (
<div className={s.card} onClick={() => navigate(`/fields/${field.id}`)}>
<Card
as="div"
radius="lg"
p="0"
gap="0"
Comment on lines +47 to +51
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep FieldCard hover shadow from being overridden

Switching this container to <Card> causes Surface to inject an inline boxShadow (elevation defaults to 0), and inline styles win over .card:hover { box-shadow: ... } in FieldCard.module.css. In practice, hovering a field tile now only translates it; the shadow lift no longer appears. This regression is introduced by the new Card wrapper in FieldCard and affects every hover interaction in the fields grid.

Useful? React with 👍 / 👎.

className={s.card}
onClick={() => navigate(`/fields/${field.id}`)}
>
{/* Polygon preview placeholder */}
<div className={s.thumb}>
<svg viewBox="0 0 80 60" className={s.thumbSvg}>
Expand Down Expand Up @@ -65,6 +92,6 @@ export default function FieldCard({ field }: Props) {
{t.fields.details} <ArrowRight size={12} />
</button>
</div>
</div>
</Card>
);
}
98 changes: 98 additions & 0 deletions frontend/src/pages/Fields/components/__tests__/FieldCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import FieldCard from '../FieldCard';
import type { FieldDto } from '../../../../types/field';

/* ── 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: {
crops: {
Wheat: 'Пшениця',
Corn: 'Кукурудза',
},
fields: {
details: 'Деталі',
notSeeded: 'Не засіяно',
ownershipOwnLand: 'Власність',
ownershipLease: 'Оренда',
ownershipShareLease: 'Пай',
},
},
lang: 'uk',
setLang: vi.fn(),
}),
}));

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

const baseField: FieldDto = {
id: 'field-42',
name: 'Південне поле',
areaHectares: 12.34,
cadastralNumber: '7120884600:01:001:0042',
currentCrop: 'Wheat',
ownershipType: 0,
};

const renderCard = (overrides: Partial<FieldDto> = {}) =>
render(
<MemoryRouter>
<FieldCard field={{ ...baseField, ...overrides }} />
</MemoryRouter>,
);

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

/* ── Tests ─────────────────────────────────────────────────────────── */

describe('FieldCard — primary render', () => {
it('renders the field name, area badge, cadastral and crop pill', () => {
renderCard();

expect(screen.getByText('Південне поле')).toBeInTheDocument();
expect(screen.getByText('12.3 га')).toBeInTheDocument();
expect(screen.getByText('7120884600:01:001:0042')).toBeInTheDocument();
expect(screen.getByText('Пшениця')).toBeInTheDocument();
expect(screen.getByText('Власність')).toBeInTheDocument();
});

it('omits the cadastral number when none is supplied', () => {
renderCard({ cadastralNumber: undefined });
expect(
screen.queryByText('7120884600:01:001:0042'),
).not.toBeInTheDocument();
});
});

describe('FieldCard — empty crop fallback', () => {
it('renders the "not seeded" label instead of a crop pill when currentCrop is unset', () => {
renderCard({ currentCrop: undefined });

expect(screen.getByText('Не засіяно')).toBeInTheDocument();
expect(screen.queryByText('Пшениця')).not.toBeInTheDocument();
});
});

describe('FieldCard — navigation', () => {
it('navigates to /fields/{id} when the card surface is clicked', () => {
renderCard();

fireEvent.click(screen.getByText('Південне поле'));

expect(navigateMock).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith('/fields/field-42');
});
});
Loading