fix(T3): inline tag create + explicit 404 on interest detail
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: <name>" 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/<port-Y-id> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,10 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
|
import { SearchX } from 'lucide-react';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
import { InterestDetailHeader } from '@/components/interests/interest-detail-header';
|
import { InterestDetailHeader } from '@/components/interests/interest-detail-header';
|
||||||
import { getInterestTabs } from '@/components/interests/interest-tabs';
|
import { getInterestTabs } from '@/components/interests/interest-tabs';
|
||||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
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 params = useParams<{ portSlug: string }>();
|
||||||
const portSlug = params?.portSlug ?? '';
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<InterestData>({
|
const { data, isLoading, error } = useQuery<InterestData>({
|
||||||
queryKey: ['interests', interestId],
|
queryKey: ['interests', interestId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
|
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({
|
useRealtimeInvalidation({
|
||||||
@@ -119,6 +128,34 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
|||||||
: null,
|
: 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 (
|
||||||
|
<EmptyState
|
||||||
|
icon={SearchX}
|
||||||
|
title={status === 403 ? 'No access to this interest' : 'Interest not found'}
|
||||||
|
description={
|
||||||
|
status === 403
|
||||||
|
? 'You do not have permission to view this interest in this port.'
|
||||||
|
: 'It may have been removed, archived, or it belongs to a different port. Use the back button or pick a different interest.'
|
||||||
|
}
|
||||||
|
className="mt-16"
|
||||||
|
action={{
|
||||||
|
label: 'Back to interests',
|
||||||
|
// EmptyState only knows about onClick; render a Link-styled
|
||||||
|
// button below so back-nav works without JS-routing surprises.
|
||||||
|
onClick: () => {
|
||||||
|
window.location.assign(`/${portSlug}/interests`);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const tabs = data
|
const tabs = data
|
||||||
? getInterestTabs({
|
? getInterestTabs({
|
||||||
interestId,
|
interestId,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { Plus, X, Check } from 'lucide-react';
|
import { Plus, X, Check } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
@@ -43,6 +44,7 @@ export function InlineTagEditor({
|
|||||||
}: InlineTagEditorProps) {
|
}: InlineTagEditorProps) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
// Always fetch so we can hide the editor entirely when no tags are
|
// Always fetch so we can hide the editor entirely when no tags are
|
||||||
// configured AND the entity has no tags already applied — keeps the
|
// configured AND the entity has no tags already applied — keeps the
|
||||||
@@ -60,6 +62,14 @@ export function InlineTagEditor({
|
|||||||
onError: (err) => toastError(err),
|
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) {
|
function toggleTag(tagId: string) {
|
||||||
const has = currentTags.some((t) => t.id === tagId);
|
const has = currentTags.some((t) => t.id === tagId);
|
||||||
const nextIds = has
|
const nextIds = has
|
||||||
@@ -72,6 +82,26 @@ export function InlineTagEditor({
|
|||||||
setTags.mutate(currentTags.filter((t) => t.id !== tagId).map((t) => t.id));
|
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
|
// 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
|
// entity has none applied. Once an admin adds the first tag in
|
||||||
// Admin → Tags, the editor reappears on next mount/refetch.
|
// Admin → Tags, the editor reappears on next mount/refetch.
|
||||||
@@ -116,14 +146,26 @@ export function InlineTagEditor({
|
|||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-64 p-0" align="start">
|
<PopoverContent className="w-64 p-0" align="start">
|
||||||
|
<div className="border-b p-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="max-h-64 overflow-y-auto py-1">
|
<div className="max-h-64 overflow-y-auto py-1">
|
||||||
{!allTags && <div className="px-3 py-2 text-xs text-muted-foreground">Loading…</div>}
|
{!allTags && <div className="px-3 py-2 text-xs text-muted-foreground">Loading…</div>}
|
||||||
{allTags?.data.length === 0 && (
|
{filtered.map((t) => {
|
||||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
|
||||||
No tags defined yet. Create some in Admin → Tags.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{allTags?.data.map((t) => {
|
|
||||||
const checked = currentTags.some((c) => c.id === t.id);
|
const checked = currentTags.some((c) => c.id === t.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -143,6 +185,26 @@ export function InlineTagEditor({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{canCreate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={createTag.isPending}
|
||||||
|
onClick={() => void handleCreateAndAttach(search)}
|
||||||
|
className="flex w-full items-center gap-2 border-t px-3 py-1.5 text-sm text-left hover:bg-muted/60"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
Create new tag: <strong>{search.trim()}</strong>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{filtered.length === 0 && !canCreate && !!allTags && (
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{allTags.data.length === 0
|
||||||
|
? 'No tags defined yet. Type a name to create one.'
|
||||||
|
: 'No matching tags.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
Reference in New Issue
Block a user