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
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
115 lines
4.0 KiB
TypeScript
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>
|
|
);
|
|
}
|