From fc5ee78c89278ee37d95a813398afe35aa64e56f Mon Sep 17 00:00:00 2001
From: Andy Aragon
Date: Fri, 29 May 2026 15:39:40 -0700
Subject: [PATCH 1/5] feat(bin-designer): organize saved designs with tags,
filtering, and bulk actions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rebuilds the saved-designs manager so it scales with the power-user tail
(production design counts reach p95=15, max 69 per user) instead of the
cramped single-column list.
- Wider dialog (max-w-lg → max-w-4xl) with a denser auto-fill thumbnail grid
- Per-design tag chips on cards; "Edit tags" action opens a tag editor
(single design) backed by updateDesignTags
- Tag filter bar (chip toggles, AND-match, clear-filters) above the list
- Bulk-selection mode: multi-select to Delete (single confirm), Export, or
Tag a batch at once
New pieces, each unit-tested: useDesignSelection reducer, tagFilter
(collect/filter) utils, DesignTagChips, TagInput, TagEditDialog, TagFilterBar,
BulkActionBar. Tags reuse the synced field added in #1936. i18n added across
all 9 locales. Verified visually (normal / filtered / selection) via Playwright.
---
scripts/design-system-allowlist.txt | 3 +
.../DesignActions/DesignActions.test.tsx | 15 ++
.../DesignActions/DesignActions.tsx | 25 +++
.../DesignGridItem/DesignGridItem.test.tsx | 1 +
.../DesignGridItem/DesignGridItem.tsx | 53 +++++-
.../BulkActionBar/BulkActionBar.test.tsx | 48 +++++
.../BulkActionBar/BulkActionBar.tsx | 53 ++++++
.../DesignListDialog/BulkActionBar/index.ts | 1 +
.../DesignListDialog/DesignListDialog.tsx | 180 +++++++++++++++++-
.../TagEditDialog/TagEditDialog.test.tsx | 59 ++++++
.../TagEditDialog/TagEditDialog.tsx | 52 +++++
.../DesignListDialog/TagEditDialog/index.ts | 1 +
.../TagFilterBar/TagFilterBar.test.tsx | 42 ++++
.../TagFilterBar/TagFilterBar.tsx | 53 ++++++
.../DesignListDialog/TagFilterBar/index.ts | 1 +
.../TagInput/TagInput.test.tsx | 39 ++++
.../DesignListDialog/TagInput/TagInput.tsx | 96 ++++++++++
.../DesignListDialog/TagInput/index.ts | 1 +
.../useDesignSelection.test.ts | 54 ++++++
.../DesignListDialog/useDesignSelection.ts | 77 ++++++++
.../DesignListItem/DesignListItem.test.tsx | 1 +
.../DesignListItem/DesignListItem.tsx | 50 ++++-
.../DesignTagChips/DesignTagChips.test.tsx | 25 +++
.../DesignTagChips/DesignTagChips.tsx | 36 ++++
.../components/DesignTagChips/index.ts | 1 +
.../bin-designer/utils/tagFilter.test.ts | 58 ++++++
src/features/bin-designer/utils/tagFilter.ts | 29 +++
src/i18n/locales/de.json | 25 ++-
src/i18n/locales/en.json | 23 +++
src/i18n/locales/en.ts | 27 +++
src/i18n/locales/es.json | 25 ++-
src/i18n/locales/fr.json | 25 ++-
src/i18n/locales/ja.json | 25 ++-
src/i18n/locales/nb.json | 25 ++-
src/i18n/locales/nl.json | 25 ++-
src/i18n/locales/pt-BR.json | 25 ++-
src/i18n/locales/sv.json | 25 ++-
src/i18n/locales/uk.json | 25 ++-
38 files changed, 1308 insertions(+), 21 deletions(-)
create mode 100644 src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.test.tsx
create mode 100644 src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.tsx
create mode 100644 src/features/bin-designer/components/DesignListDialog/BulkActionBar/index.ts
create mode 100644 src/features/bin-designer/components/DesignListDialog/TagEditDialog/TagEditDialog.test.tsx
create mode 100644 src/features/bin-designer/components/DesignListDialog/TagEditDialog/TagEditDialog.tsx
create mode 100644 src/features/bin-designer/components/DesignListDialog/TagEditDialog/index.ts
create mode 100644 src/features/bin-designer/components/DesignListDialog/TagFilterBar/TagFilterBar.test.tsx
create mode 100644 src/features/bin-designer/components/DesignListDialog/TagFilterBar/TagFilterBar.tsx
create mode 100644 src/features/bin-designer/components/DesignListDialog/TagFilterBar/index.ts
create mode 100644 src/features/bin-designer/components/DesignListDialog/TagInput/TagInput.test.tsx
create mode 100644 src/features/bin-designer/components/DesignListDialog/TagInput/TagInput.tsx
create mode 100644 src/features/bin-designer/components/DesignListDialog/TagInput/index.ts
create mode 100644 src/features/bin-designer/components/DesignListDialog/useDesignSelection.test.ts
create mode 100644 src/features/bin-designer/components/DesignListDialog/useDesignSelection.ts
create mode 100644 src/features/bin-designer/components/DesignTagChips/DesignTagChips.test.tsx
create mode 100644 src/features/bin-designer/components/DesignTagChips/DesignTagChips.tsx
create mode 100644 src/features/bin-designer/components/DesignTagChips/index.ts
create mode 100644 src/features/bin-designer/utils/tagFilter.test.ts
create mode 100644 src/features/bin-designer/utils/tagFilter.ts
diff --git a/scripts/design-system-allowlist.txt b/scripts/design-system-allowlist.txt
index 9276db2cc..f105df12f 100644
--- a/scripts/design-system-allowlist.txt
+++ b/scripts/design-system-allowlist.txt
@@ -165,3 +165,6 @@ src/shared/components/Toast/Toast.tsx
src/shared/components/ToolSwitcher/ToolSwitcher.tsx
src/shared/components/TwoClickDeleteButton/TwoClickDeleteButton.tsx
src/shared/components/ViewModeToggle/ViewModeToggle.tsx
+src/features/bin-designer/components/DesignListDialog/TagInput/TagInput.tsx
+src/features/bin-designer/components/DesignListDialog/TagFilterBar/TagFilterBar.tsx
+src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.tsx
diff --git a/src/features/bin-designer/components/DesignActions/DesignActions.test.tsx b/src/features/bin-designer/components/DesignActions/DesignActions.test.tsx
index f108b34ce..4f224e466 100644
--- a/src/features/bin-designer/components/DesignActions/DesignActions.test.tsx
+++ b/src/features/bin-designer/components/DesignActions/DesignActions.test.tsx
@@ -20,6 +20,7 @@ describe('DesignActions', () => {
onLoad: vi.fn(),
onDownloadJSON: vi.fn(),
onRename: vi.fn(),
+ onEditTags: vi.fn(),
onDuplicate: vi.fn(),
onDelete: vi.fn(),
};
@@ -92,6 +93,20 @@ describe('DesignActions', () => {
expect(onRename).toHaveBeenCalled();
});
+ it('calls onEditTags when Edit tags is clicked', async () => {
+ const onEditTags = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /more actions/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('menuitem', { name: /edit tags/i })).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('menuitem', { name: /edit tags/i }));
+ expect(onEditTags).toHaveBeenCalled();
+ });
+
it('calls onDuplicate when Duplicate is clicked', async () => {
const onDuplicate = vi.fn();
render();
diff --git a/src/features/bin-designer/components/DesignActions/DesignActions.tsx b/src/features/bin-designer/components/DesignActions/DesignActions.tsx
index c3b161989..b1c687818 100644
--- a/src/features/bin-designer/components/DesignActions/DesignActions.tsx
+++ b/src/features/bin-designer/components/DesignActions/DesignActions.tsx
@@ -10,6 +10,7 @@ interface DesignActionsProps {
onLoad: () => void;
onDownloadJSON: () => void;
onRename: () => void;
+ onEditTags: () => void;
onDuplicate: () => void;
onDelete: () => void;
}
@@ -24,6 +25,7 @@ export function DesignActions({
onLoad,
onDownloadJSON,
onRename,
+ onEditTags,
onDuplicate,
onDelete,
}: DesignActionsProps) {
@@ -199,6 +201,29 @@ export function DesignActions({
{t('common.rename')}
+ {/* Edit tags */}
+
+
{/* Duplicate */}
+ {/* Tags */}
+ {design.tags && design.tags.length > 0 && (
+
+
+
+ )}
+
{/* Date and actions row */}
@@ -150,6 +196,7 @@ export function DesignGridItem({
onLoad={onSelect}
onDownloadJSON={onDownloadJSON}
onRename={startEditing}
+ onEditTags={onEditTags}
onDuplicate={onDuplicate}
onDelete={onDelete}
/>
diff --git a/src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.test.tsx b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.test.tsx
new file mode 100644
index 000000000..9e1d75aef
--- /dev/null
+++ b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.test.tsx
@@ -0,0 +1,48 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { BulkActionBar } from './BulkActionBar';
+
+function setup(count: number) {
+ const handlers = {
+ onSelectAll: vi.fn(),
+ onTag: vi.fn(),
+ onExport: vi.fn(),
+ onDelete: vi.fn(),
+ onCancel: vi.fn(),
+ };
+ render();
+ return handlers;
+}
+
+describe('BulkActionBar', () => {
+ it('shows the selected count', () => {
+ setup(3);
+ expect(screen.getByText('3 selected')).toBeInTheDocument();
+ });
+
+ it('disables bulk actions when nothing is selected', () => {
+ setup(0);
+ expect(screen.getByRole('button', { name: /^delete$/i })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /^export$/i })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /^tag$/i })).toBeDisabled();
+ });
+
+ it('fires actions when selected', () => {
+ const h = setup(2);
+ fireEvent.click(screen.getByRole('button', { name: /^delete$/i }));
+ fireEvent.click(screen.getByRole('button', { name: /^export$/i }));
+ fireEvent.click(screen.getByRole('button', { name: /^tag$/i }));
+ expect(h.onDelete).toHaveBeenCalled();
+ expect(h.onExport).toHaveBeenCalled();
+ expect(h.onTag).toHaveBeenCalled();
+ });
+
+ it('select-all and cancel are always available', () => {
+ const h = setup(0);
+ fireEvent.click(screen.getByText(/select all/i));
+ fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
+ expect(h.onSelectAll).toHaveBeenCalled();
+ expect(h.onCancel).toHaveBeenCalled();
+ });
+});
diff --git a/src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.tsx b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.tsx
new file mode 100644
index 000000000..057c39adf
--- /dev/null
+++ b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.tsx
@@ -0,0 +1,53 @@
+import { useTranslation } from '@/i18n';
+import { Button } from '@/design-system';
+
+interface BulkActionBarProps {
+ count: number;
+ onSelectAll: () => void;
+ onTag: () => void;
+ onExport: () => void;
+ onDelete: () => void;
+ onCancel: () => void;
+}
+
+/** Action bar shown in bulk-selection mode. Bulk actions disable when nothing is selected. */
+export function BulkActionBar({
+ count,
+ onSelectAll,
+ onTag,
+ onExport,
+ onDelete,
+ onCancel,
+}: BulkActionBarProps) {
+ const t = useTranslation();
+ const none = count === 0;
+
+ return (
+
+
+ {t('binDesigner.bulk.selected', { count })}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/bin-designer/components/DesignListDialog/BulkActionBar/index.ts b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/index.ts
new file mode 100644
index 000000000..d177e1ff9
--- /dev/null
+++ b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/index.ts
@@ -0,0 +1 @@
+export { BulkActionBar } from './BulkActionBar';
diff --git a/src/features/bin-designer/components/DesignListDialog/DesignListDialog.tsx b/src/features/bin-designer/components/DesignListDialog/DesignListDialog.tsx
index 54eaaa2a8..43b4368b1 100644
--- a/src/features/bin-designer/components/DesignListDialog/DesignListDialog.tsx
+++ b/src/features/bin-designer/components/DesignListDialog/DesignListDialog.tsx
@@ -13,7 +13,14 @@ import {
deleteDesign,
duplicateDesign,
saveDesign,
+ updateDesignTags,
} from '@/features/bin-designer/storage/DesignerStorage';
+import { collectTags, filterByTags } from '@/features/bin-designer/utils/tagFilter';
+import { normalizeTags } from '@/features/bin-designer/utils/tags';
+import { TagFilterBar } from './TagFilterBar';
+import { BulkActionBar } from './BulkActionBar';
+import { TagEditDialog } from './TagEditDialog';
+import { useDesignSelection } from './useDesignSelection';
import { removeRegistryEntry } from '../../store/customBinRegistry';
import { useDesignerStore } from '../../store';
import { useDesignerRouting } from '@/shared/hooks/useDesignerRouting';
@@ -61,6 +68,12 @@ export function DesignListDialog({ open, onClose }: DesignListDialogProps) {
const [sortBy, setSortBy] = useState
('recent');
const [focusedIndex, setFocusedIndex] = useState(0);
const [showImport, setShowImport] = useState(false);
+ const [activeTags, setActiveTags] = useState([]);
+ const [tagEdit, setTagEdit] = useState<{ mode: 'single' | 'bulk'; design?: SavedDesign } | null>(
+ null
+ );
+ const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
+ const selection = useDesignSelection();
const itemRefs = useRef