dorfteich/apps/web/src/labels/LabelPicker.tsx
Claude Opus 4.8 03e72242d3
All checks were successful
CD / Build and push images (push) Successful in 2m53s
CI / Lint, typecheck, test (push) Successful in 2m8s
CI / Auth e2e pack (push) Successful in 2m31s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add label UI: tree management, page assignment, and sidebar filter (#44)
Build the M4 label experience on top of the #43 label API.

- shared: `flattenLabelTree` (tree → depth-first list) for chip lookup,
  filtering, and the picker; `PageListItemView` adds each page's `labelIds`
  to the sidebar list response.
- api: `GET /ponds/:id/pages` now includes `labelIds` per page (one grouped
  query), so the sidebar can render chips and filter without extra calls.
- web:
  - Pond settings page (`/p/:pondSlug/settings`) with a `LabelManager`
    tree: inline create, rename, recolour (`<input type=color>`), move via a
    parent picker that excludes the label's own subtree, and delete that
    confirms then force-detaches assigned pages. Every control is a native
    button/input/select — the tree is fully keyboard-operable.
  - `LabelPicker` panel on the page editor: searchable, hierarchy-indented
    multi-select that assigns/unassigns immediately and refreshes the page's
    labels and the sidebar.
  - Sidebar: colored label chips on page entries (readable text via a
    luminance-based contrast helper) and a descendant-inclusive label filter
    (selecting a parent matches pages tagged with its children, via the
    shared `collectSubtreeIds`). Owner link to pond settings.
  - i18n `labels` namespace (de + en).
- e2e `labels.spec.ts` (new CI pack): full lifecycle from the settings UI
  and picker-assign + parent-filter-includes-child. Selectors are
  language-independent because the UI language follows the user's locale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 11:41:42 +02:00

115 lines
4.0 KiB
TypeScript

import type { LabelView } from '@dorfteich/shared';
import { labelDepth } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { apiDelete, apiGet, apiPost } from '../lib/api';
import { usePondLabels } from './use-pond-labels';
/**
* Page label picker (issue #44): a searchable, hierarchy-aware multi-select of
* the pond's labels. Toggling a label assigns/unassigns it immediately and
* refreshes both the page's labels and the sidebar (chips + filter). Shown only
* to users who may edit the page; the api enforces the permission.
*/
export function LabelPicker({
pageId,
pondId,
pondSlug,
onClose,
}: {
pageId: string;
pondId: string;
pondSlug: string;
onClose: () => void;
}): React.JSX.Element {
const { t } = useTranslation('labels');
const queryClient = useQueryClient();
const { flat, isLoading } = usePondLabels(pondId);
const [search, setSearch] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const assigned = useQuery({
queryKey: ['page-labels', pageId],
queryFn: () => apiGet<LabelView[]>(`/pages/${pageId}/labels`),
});
const assignedIds = new Set((assigned.data ?? []).map((l) => l.id));
async function toggle(label: LabelView, checked: boolean): Promise<void> {
setBusy(label.id);
try {
if (checked) {
await apiPost(`/pages/${pageId}/labels`, { labelId: label.id });
} else {
await apiDelete(`/pages/${pageId}/labels/${label.id}`);
}
// Refresh the page's own labels and the sidebar list (chips + filter).
await queryClient.invalidateQueries({ queryKey: ['page-labels', pageId] });
await queryClient.invalidateQueries({ queryKey: ['pages', pondId] });
} finally {
setBusy(null);
}
}
const term = search.trim().toLowerCase();
const shown = term ? flat.filter((l) => l.name.toLowerCase().includes(term)) : flat;
return (
<aside className="label-picker" aria-label={t('picker.title')}>
<header className="label-picker__header">
<h2>{t('picker.title')}</h2>
<button type="button" className="button" onClick={onClose}>
{t('picker.close')}
</button>
</header>
{isLoading ? null : flat.length === 0 ? (
<p className="label-picker__empty">
{t('picker.empty')} <Link to={`/p/${pondSlug}/settings`}>{t('picker.manageHint')}</Link>
</p>
) : (
<>
<input
type="search"
className="label-picker__search"
value={search}
placeholder={t('picker.searchPlaceholder')}
aria-label={t('picker.searchPlaceholder')}
onChange={(event) => setSearch(event.target.value)}
/>
{shown.length === 0 ? (
<p className="label-picker__empty">{t('picker.noMatches')}</p>
) : (
<ul className="label-picker__list">
{shown.map((label) => {
const indent = term ? 0 : labelDepth(flat, label.id) - 1;
const checked = assignedIds.has(label.id);
return (
<li key={label.id} style={{ paddingInlineStart: `${indent * 1.25}rem` }}>
<label className="label-picker__option">
<input
type="checkbox"
checked={checked}
disabled={busy === label.id}
onChange={(event) => void toggle(label, event.target.checked)}
/>
<span
className="label-chip__swatch"
style={{ backgroundColor: label.color }}
aria-hidden
/>
<span>{label.name}</span>
</label>
</li>
);
})}
</ul>
)}
</>
)}
</aside>
);
}