From 608641c23b258c714b84aabe63aa9400c145447a Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 23:03:30 +0200 Subject: [PATCH] fix(T3): inline tag create + explicit 404 on interest detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F16 — InlineTagEditor: inline "Create new tag" affordance The popover now has a search input at the top. Typing a name that doesn't match any existing tag surfaces a "Create new tag: " action that POSTs /api/v1/tags then attaches the new id to the entity. Reps no longer need to context-switch to Admin → Tags to create the first chip. Enter on the input also triggers create-and-attach. F17 — Interest detail page: explicit not-found state Pre-fix, navigating to /port-X/interests/ 404'd at the API but the UI silently rendered the list shell with empty tabs. Cross- port URL pastes now show an EmptyState with title "Interest not found" + a "Back to interests" CTA. 403 (no access in this port) gets its own copy. TanStack Query is told not to retry 404/403s so the empty state appears immediately. 1373/1373 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/interests/interest-detail.tsx | 39 ++++++++++- src/components/shared/inline-tag-editor.tsx | 74 ++++++++++++++++++-- 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/components/interests/interest-detail.tsx b/src/components/interests/interest-detail.tsx index 5ab55eee..a5e3e720 100644 --- a/src/components/interests/interest-detail.tsx +++ b/src/components/interests/interest-detail.tsx @@ -3,8 +3,10 @@ import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useParams } from 'next/navigation'; +import { SearchX } from 'lucide-react'; import { DetailLayout } from '@/components/shared/detail-layout'; +import { EmptyState } from '@/components/shared/empty-state'; import { InterestDetailHeader } from '@/components/interests/interest-detail-header'; import { getInterestTabs } from '@/components/interests/interest-tabs'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; @@ -82,10 +84,17 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; - const { data, isLoading } = useQuery({ + const { data, isLoading, error } = useQuery({ queryKey: ['interests', interestId], queryFn: () => apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data), + // F17: don't retry 404s — they're intentional (wrong port, archived, + // deleted). Let the error state render the EmptyState below. + retry: (failureCount, err) => { + const status = (err as { status?: number } | null | undefined)?.status; + if (status === 404 || status === 403) return false; + return failureCount < 2; + }, }); useRealtimeInvalidation({ @@ -119,6 +128,34 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp : null, ); + // F17: explicit "not found" state when the API 404'd or 403'd. + // Pre-fix the page silently rendered the layout shell with empty tabs, + // leaving users unsure whether the interest existed or just hadn't + // loaded. Cross-port URL pastes now land here with a clear message. + if (error && !isLoading) { + const status = (error as { status?: number } | null | undefined)?.status; + return ( + { + window.location.assign(`/${portSlug}/interests`); + }, + }} + /> + ); + } + const tabs = data ? getInterestTabs({ interestId, diff --git a/src/components/shared/inline-tag-editor.tsx b/src/components/shared/inline-tag-editor.tsx index f2475a8c..3d610a3b 100644 --- a/src/components/shared/inline-tag-editor.tsx +++ b/src/components/shared/inline-tag-editor.tsx @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, X, Check } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; @@ -43,6 +44,7 @@ export function InlineTagEditor({ }: InlineTagEditorProps) { const qc = useQueryClient(); const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); // Always fetch so we can hide the editor entirely when no tags are // configured AND the entity has no tags already applied — keeps the @@ -60,6 +62,14 @@ export function InlineTagEditor({ onError: (err) => toastError(err), }); + // F16: inline "Create new tag: X" — was missing entirely. Reps had to + // context-switch to Admin → Tags to create the tag, then come back. + const createTag = useMutation({ + mutationFn: (name: string) => + apiFetch<{ data: Tag }>('/api/v1/tags', { method: 'POST', body: { name } }), + onError: (err) => toastError(err), + }); + function toggleTag(tagId: string) { const has = currentTags.some((t) => t.id === tagId); const nextIds = has @@ -72,6 +82,26 @@ export function InlineTagEditor({ setTags.mutate(currentTags.filter((t) => t.id !== tagId).map((t) => t.id)); } + async function handleCreateAndAttach(name: string) { + const trimmed = name.trim(); + if (!trimmed) return; + const res = await createTag.mutateAsync(trimmed); + const newTag = res.data; + // Optimistically extend `allTags` so the new chip appears immediately. + qc.invalidateQueries({ queryKey: ['tags'] }); + setTags.mutate([...currentTags.map((t) => t.id), newTag.id]); + setSearch(''); + } + + const lowerSearch = search.trim().toLowerCase(); + const filtered = lowerSearch + ? (allTags?.data ?? []).filter((t) => t.name.toLowerCase().includes(lowerSearch)) + : (allTags?.data ?? []); + // Suggest "Create new tag: X" when the user typed something but no + // exact-case-insensitive match exists. + const exactMatch = (allTags?.data ?? []).some((t) => t.name.toLowerCase() === lowerSearch); + const canCreate = !!lowerSearch && !exactMatch; + // Hide the whole editor when the port has no tags configured AND this // entity has none applied. Once an admin adds the first tag in // Admin → Tags, the editor reappears on next mount/refetch. @@ -116,14 +146,26 @@ export function InlineTagEditor({ +
+ setSearch(e.target.value)} + onKeyDown={(e) => { + // Enter on a non-empty no-match → create and attach. + if (e.key === 'Enter' && canCreate && !createTag.isPending) { + e.preventDefault(); + void handleCreateAndAttach(search); + } + }} + placeholder="Search or create…" + className="h-7 text-xs" + autoFocus + /> +
{!allTags &&
Loading…
} - {allTags?.data.length === 0 && ( -
- No tags defined yet. Create some in Admin → Tags. -
- )} - {allTags?.data.map((t) => { + {filtered.map((t) => { const checked = currentTags.some((c) => c.id === t.id); return ( + )} + {filtered.length === 0 && !canCreate && !!allTags && ( +
+ {allTags.data.length === 0 + ? 'No tags defined yet. Type a name to create one.' + : 'No matching tags.'} +
+ )}