tag deletion
This commit is contained in:
+154
-29
@@ -842,18 +842,17 @@ const PreviewWorkspace = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
const TagsPanel = ({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) => {
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [draftLabel, setDraftLabel] = useState('');
|
const [draftLabel, setDraftLabel] = useState('');
|
||||||
const [draftColor, setDraftColor] = useState('');
|
const [draftColor, setDraftColor] = useState('');
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deletingId, setDeletingId] = useState(null);
|
||||||
|
|
||||||
const startEdit = useCallback((tag) => {
|
const startEdit = useCallback((tag) => {
|
||||||
setEditingId(tag.id);
|
setEditingId(tag.id);
|
||||||
setDraftLabel(tag.label || '');
|
setDraftLabel(tag.label || '');
|
||||||
setDraftColor(tag.color || '');
|
setDraftColor(tag.color || '');
|
||||||
setError(null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cancelEdit = useCallback(() => {
|
const cancelEdit = useCallback(() => {
|
||||||
@@ -861,7 +860,6 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
|||||||
setDraftLabel('');
|
setDraftLabel('');
|
||||||
setDraftColor('');
|
setDraftColor('');
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
setError(null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const colorPickerValue = useMemo(() => {
|
const colorPickerValue = useMemo(() => {
|
||||||
@@ -880,19 +878,18 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
|||||||
|
|
||||||
const trimmedLabel = draftLabel.trim();
|
const trimmedLabel = draftLabel.trim();
|
||||||
if (!trimmedLabel) {
|
if (!trimmedLabel) {
|
||||||
setError('Tag label cannot be empty.');
|
onNotify?.('Tag label cannot be empty.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedColor = draftColor.trim();
|
const trimmedColor = draftColor.trim();
|
||||||
const colorPattern = /^#([0-9a-fA-F]{6})$/;
|
const colorPattern = /^#([0-9a-fA-F]{6})$/;
|
||||||
if (trimmedColor && !colorPattern.test(trimmedColor)) {
|
if (trimmedColor && !colorPattern.test(trimmedColor)) {
|
||||||
setError('Colors must use the #RRGGBB format.');
|
onNotify?.('Colors must use the #RRGGBB format.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
await onUpdateTag(editingId, {
|
await onUpdateTag(editingId, {
|
||||||
label: trimmedLabel,
|
label: trimmedLabel,
|
||||||
@@ -901,11 +898,11 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
|||||||
cancelEdit();
|
cancelEdit();
|
||||||
} catch (updateError) {
|
} catch (updateError) {
|
||||||
const message = updateError?.message || 'Failed to update tag.';
|
const message = updateError?.message || 'Failed to update tag.';
|
||||||
setError(message);
|
onNotify?.(message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [editingId, draftLabel, draftColor, onUpdateTag, cancelEdit]);
|
}, [editingId, draftLabel, draftColor, onUpdateTag, cancelEdit, onNotify]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
@@ -920,6 +917,28 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
|||||||
[handleSave, cancelEdit],
|
[handleSave, cancelEdit],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (tag) => {
|
||||||
|
if (!tag?.id || typeof onDeleteTag !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingId(tag.id);
|
||||||
|
try {
|
||||||
|
await onDeleteTag(tag.id);
|
||||||
|
if (editingId === tag.id) {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
} catch (deleteError) {
|
||||||
|
const message = deleteError?.message || 'Failed to delete tag.';
|
||||||
|
onNotify?.(message, 'error');
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onDeleteTag, editingId, cancelEdit, onNotify],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="tags-panel column">
|
<section className="tags-panel column">
|
||||||
<div className="column-header">
|
<div className="column-header">
|
||||||
@@ -928,17 +947,17 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
|||||||
<div className="column-subtitle">{tags.length} total</div>
|
<div className="column-subtitle">{tags.length} total</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<button className="secondary" type="button" onClick={onRefresh} disabled={saving}>
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={saving || Boolean(deletingId)}
|
||||||
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="column-body tags-panel__body">
|
<div className="column-body tags-panel__body">
|
||||||
{error && (
|
|
||||||
<div className="tags-panel__error" role="alert">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{tags.length === 0 ? (
|
{tags.length === 0 ? (
|
||||||
<div className="empty-state">No tags created yet.</div>
|
<div className="empty-state">No tags created yet.</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -968,7 +987,7 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
|||||||
value={draftLabel}
|
value={draftLabel}
|
||||||
onChange={(event) => setDraftLabel(event.target.value)}
|
onChange={(event) => setDraftLabel(event.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
disabled={saving}
|
disabled={saving || deletingId === tag.id}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -988,7 +1007,7 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
|||||||
className="tags-table__color-picker"
|
className="tags-table__color-picker"
|
||||||
value={colorPickerValue}
|
value={colorPickerValue}
|
||||||
onChange={(event) => setDraftColor(event.target.value)}
|
onChange={(event) => setDraftColor(event.target.value)}
|
||||||
disabled={saving}
|
disabled={saving || deletingId === tag.id}
|
||||||
aria-label="Pick tag color"
|
aria-label="Pick tag color"
|
||||||
/>
|
/>
|
||||||
{draftColor && (
|
{draftColor && (
|
||||||
@@ -996,7 +1015,7 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="secondary"
|
className="secondary"
|
||||||
onClick={() => setDraftColor('')}
|
onClick={() => setDraftColor('')}
|
||||||
disabled={saving}
|
disabled={saving || deletingId === tag.id}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
@@ -1020,7 +1039,7 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="secondary"
|
className="secondary"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving || deletingId === tag.id}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
@@ -1028,19 +1047,38 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="secondary"
|
className="secondary"
|
||||||
onClick={cancelEdit}
|
onClick={cancelEdit}
|
||||||
disabled={saving}
|
disabled={saving || deletingId === tag.id}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => handleDelete(tag)}
|
||||||
|
disabled={deletingId === tag.id}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div className="tags-table__row-actions">
|
||||||
type="button"
|
<button
|
||||||
className="secondary"
|
type="button"
|
||||||
onClick={() => startEdit(tag)}
|
className="secondary"
|
||||||
>
|
onClick={() => startEdit(tag)}
|
||||||
Edit
|
disabled={deletingId === tag.id}
|
||||||
</button>
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => handleDelete(tag)}
|
||||||
|
disabled={deletingId === tag.id}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -2024,6 +2062,76 @@ const AppLayout = () => {
|
|||||||
[api, refreshTags, notifyApiError],
|
[api, refreshTags, notifyApiError],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleTagDelete = useCallback(
|
||||||
|
async (tagId) => {
|
||||||
|
if (!tagId) {
|
||||||
|
throw new Error('Missing tag identifier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/tags/${tagId}`);
|
||||||
|
setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
|
||||||
|
|
||||||
|
const stripTagFromDoc = (doc) => {
|
||||||
|
if (!doc || !Array.isArray(doc.tags)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
|
||||||
|
if (nextTags.length === doc.tags.length) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
return { ...doc, tags: nextTags };
|
||||||
|
};
|
||||||
|
|
||||||
|
setDocuments((prev) => prev.map((doc) => stripTagFromDoc(doc)));
|
||||||
|
setSearchResults((prev) =>
|
||||||
|
Array.isArray(prev) ? prev.map((doc) => stripTagFromDoc(doc)) : prev,
|
||||||
|
);
|
||||||
|
|
||||||
|
setFolderContents((prev) => {
|
||||||
|
if (!prev.size) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map();
|
||||||
|
prev.forEach((contents, key) => {
|
||||||
|
if (!Array.isArray(contents?.documents)) {
|
||||||
|
next.set(key, contents);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updatedDocs = contents.documents.map((doc) => stripTagFromDoc(doc));
|
||||||
|
const mutated = updatedDocs.some((doc, index) => doc !== contents.documents[index]);
|
||||||
|
if (mutated) {
|
||||||
|
changed = true;
|
||||||
|
next.set(key, { ...contents, documents: updatedDocs });
|
||||||
|
} else {
|
||||||
|
next.set(key, contents);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
await refreshTags();
|
||||||
|
setStatusMessage('Tag deleted.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to delete tag.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
refreshTags,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
setDocuments,
|
||||||
|
setSearchResults,
|
||||||
|
setFolderContents,
|
||||||
|
setActiveTagFilters,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const loadFolder = useCallback(
|
const loadFolder = useCallback(
|
||||||
async (folderId, { showLoading = true } = {}) => {
|
async (folderId, { showLoading = true } = {}) => {
|
||||||
const targetId = folderId || 'root';
|
const targetId = folderId || 'root';
|
||||||
@@ -4275,6 +4383,7 @@ const AppLayout = () => {
|
|||||||
token,
|
token,
|
||||||
appStatus,
|
appStatus,
|
||||||
status,
|
status,
|
||||||
|
setStatusMessage,
|
||||||
dropOverlayState,
|
dropOverlayState,
|
||||||
handleBulkReanalyze,
|
handleBulkReanalyze,
|
||||||
handleLogout,
|
handleLogout,
|
||||||
@@ -4283,6 +4392,7 @@ const AppLayout = () => {
|
|||||||
refreshTags,
|
refreshTags,
|
||||||
handleTagUpdate,
|
handleTagUpdate,
|
||||||
handleTagCreate,
|
handleTagCreate,
|
||||||
|
handleTagDelete,
|
||||||
handleDocumentTagAttach,
|
handleDocumentTagAttach,
|
||||||
previewActive,
|
previewActive,
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
@@ -4300,6 +4410,7 @@ const AppLayout = () => {
|
|||||||
token,
|
token,
|
||||||
appStatus,
|
appStatus,
|
||||||
status,
|
status,
|
||||||
|
setStatusMessage,
|
||||||
dropOverlayState,
|
dropOverlayState,
|
||||||
handleBulkReanalyze,
|
handleBulkReanalyze,
|
||||||
handleLogout,
|
handleLogout,
|
||||||
@@ -4308,6 +4419,7 @@ const AppLayout = () => {
|
|||||||
refreshTags,
|
refreshTags,
|
||||||
handleTagUpdate,
|
handleTagUpdate,
|
||||||
handleTagCreate,
|
handleTagCreate,
|
||||||
|
handleTagDelete,
|
||||||
handleDocumentTagAttach,
|
handleDocumentTagAttach,
|
||||||
previewActive,
|
previewActive,
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
@@ -4546,10 +4658,23 @@ const LoginRoute = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TagsRoute = () => {
|
const TagsRoute = () => {
|
||||||
const { sidebarProps, tags, refreshTags, handleTagUpdate } = useAppShell();
|
const {
|
||||||
|
sidebarProps,
|
||||||
|
tags,
|
||||||
|
refreshTags,
|
||||||
|
handleTagUpdate,
|
||||||
|
handleTagDelete,
|
||||||
|
setStatusMessage,
|
||||||
|
} = useAppShell();
|
||||||
return (
|
return (
|
||||||
<MainLayout sidebarProps={sidebarProps} className="tags-main">
|
<MainLayout sidebarProps={sidebarProps} className="tags-main">
|
||||||
<TagsPanel tags={tags} onRefresh={refreshTags} onUpdateTag={handleTagUpdate} />
|
<TagsPanel
|
||||||
|
tags={tags}
|
||||||
|
onRefresh={refreshTags}
|
||||||
|
onUpdateTag={handleTagUpdate}
|
||||||
|
onDeleteTag={handleTagDelete}
|
||||||
|
onNotify={setStatusMessage}
|
||||||
|
/>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
--overlay-backdrop: rgba(30, 33, 39, 0.45);
|
--overlay-backdrop: rgba(30, 33, 39, 0.45);
|
||||||
--overlay-shadow: rgba(30, 33, 39, 0.18);
|
--overlay-shadow: rgba(30, 33, 39, 0.18);
|
||||||
|
|
||||||
|
--app-bar-background: var(--surface-subtle);
|
||||||
|
|
||||||
/* Accent (primary action, selection, focus) */
|
/* Accent (primary action, selection, focus) */
|
||||||
--accent: #3f6ad8;
|
--accent: #3f6ad8;
|
||||||
--accent-hover: #365cb9;
|
--accent-hover: #365cb9;
|
||||||
@@ -209,7 +211,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.6rem 1.5rem 0.4rem;
|
padding: 0.6rem 1.5rem 0.4rem;
|
||||||
background: var(--surface);
|
background: var(--app-bar-background);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -422,6 +424,12 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tags-table__row-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-workspace__message {
|
.preview-workspace__message {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user