From d6431e8fcabd532e01d7526dcafa01a4d68391e1 Mon Sep 17 00:00:00 2001 From: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:26:48 +0200 Subject: [PATCH] feat(admin): minimize a floating window instead of dismissing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A floating window that stays useful while the user works around it — the upload queue mid-transfer, a bulk edit mid-review, the viewer — offered two choices: close it and lose it, or let it cover the grid. A `minimizable` FloatingWindow now gets a collapse control beside Close that folds it to its title bar; its header actions fold with the body they act on. The upload queue and the bulk edit window opt in. The media viewer builds its own shell rather than FloatingWindow, so it wires the same control itself. Its height is a fixed length, so hiding the body alone left a full-size empty pane: `data-minimized` collapses the height too, while the top offset still clamps against the full height so a window folded near the bottom edge does not jump. Minimized state is per-session, and a folded window stays where it sits — the position is the user's own, and folding to a corner would discard it. The tests render the real windows and press the real control; each was checked against a window that hides nothing or keeps its height. Co-Authored-By: Claude Opus 5.5 --- docs/features/media.md | 2 +- src/__tests__/media/minimizeWindow.test.tsx | 144 ++++++++++++++++++ .../BulkEditWindow/BulkEditWindow.tsx | 1 + .../MediaViewerWindow.module.css | 9 ++ .../MediaViewerWindow/MediaViewerWindow.tsx | 27 +++- .../UploadQueueWindow/UploadQueueWindow.tsx | 1 + .../shared/FloatingWindow/FloatingWindow.tsx | 42 ++++- 7 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/media/minimizeWindow.test.tsx diff --git a/docs/features/media.md b/docs/features/media.md index 3c1483ee3..90e87c396 100644 --- a/docs/features/media.md +++ b/docs/features/media.md @@ -13,7 +13,7 @@ The workspace is canvas-style: it uses `AdminWorkspaceCanvasLayout`, the lighter - **State:** one hook — `useMediaWorkspace()` — orchestrates folders, assets, selection, filters, upload queue, and folder moves. The editor store doesn't grow new slices; the Media page is self-contained. - **Folders:** folders render as first-class grid/list items in the canvas. Opening a folder filters the canvas to its contents, and nested folders show a parent-folder entry to navigate back. - **Drag/drop:** assets can be dragged into folders from the canvas or folder tree. A drop replaces the asset's folder memberships with the target folder, matching desktop file-manager move semantics. -- **Floating windows:** Asset viewer, upload queue, bulk edit. Each uses the shared `@admin/shared/FloatingWindow` shell or its `useDraggablePanel` hook with a unique id (`mediaDetachedInspector`, `mediaUploadQueue`, `mediaBulkEdit`). Position survives reload via `workspaceLayoutStorage`. +- **Floating windows:** Asset viewer, upload queue, bulk edit. Each uses the shared `@admin/shared/FloatingWindow` shell or its `useDraggablePanel` hook with a unique id (`mediaDetachedInspector`, `mediaUploadQueue`, `mediaBulkEdit`). Position survives reload via `workspaceLayoutStorage`. All three can fold to their title bar — `minimizable` on `FloatingWindow`; the viewer builds its own shell and wires the same control, with `data-minimized` collapsing its fixed height. Minimized state is per-session, and a folded window stays where it sits: the position is the user's own. - **Auto-open behavior:** upload queue opens when uploads start; bulk-edit opens at 2+ selected; viewer opens on primary selection. - **Server side:** `media_assets`, `media_folders`, `media_asset_folders` tables. Handlers under `/admin/api/cms/media`, `/admin/api/cms/media/folders`, `/admin/api/cms/media/storage`. Repositories at `server/repositories/media*.ts`. - **Storage adapters:** built-in local-disk plus plugin-registered adapters. Non-public-url adapters route through `/_instatic/media//` for signed redirects. diff --git a/src/__tests__/media/minimizeWindow.test.tsx b/src/__tests__/media/minimizeWindow.test.tsx new file mode 100644 index 000000000..fff6970ce --- /dev/null +++ b/src/__tests__/media/minimizeWindow.test.tsx @@ -0,0 +1,144 @@ +/** + * Minimizing a floating window instead of dismissing it. + * + * The upload queue mid-transfer, a bulk edit mid-review, the viewer beside + * the grid: closing loses the panel, keeping it covers the grid. A + * minimizable window folds to its title bar and comes back where it was. + * + * These render the real windows and press the real control. + */ + +import { afterEach, describe, expect, it } from 'bun:test' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MediaViewerWindow } from '@admin/pages/media/components/MediaViewerWindow/MediaViewerWindow' +import { AdminSessionContext } from '@admin/sessionContext' +import { FloatingWindow } from '@admin/shared/FloatingWindow' + +afterEach(cleanup) + +function renderWindow(minimizable: boolean) { + render( + Clear finished} + onClose={() => {}} + > +

Three files uploading

+
, + ) +} + +describe('a minimizable FloatingWindow', () => { + it('folds to its title bar', () => { + renderWindow(true) + fireEvent.click(screen.getByRole('button', { name: 'Minimize Uploads' })) + + expect(screen.queryByText('Three files uploading')).toBeNull() + // Header actions act on the body, so they fold with it. + expect(screen.queryByRole('button', { name: 'Clear finished' })).toBeNull() + // The title bar stays: it is what the user drags and expands from. + expect(screen.getByText('Uploads')).toBeTruthy() + }) + + it('says which state it is in', () => { + renderWindow(true) + const control = screen.getByRole('button', { name: 'Minimize Uploads' }) + expect(control.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(control) + expect(screen.getByRole('button', { name: 'Expand Uploads' }).getAttribute('aria-expanded')).toBe('false') + }) + + it('comes back with its content when expanded', () => { + renderWindow(true) + fireEvent.click(screen.getByRole('button', { name: 'Minimize Uploads' })) + fireEvent.click(screen.getByRole('button', { name: 'Expand Uploads' })) + + expect(screen.getByText('Three files uploading')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Clear finished' })).toBeTruthy() + }) + + it('offers no control unless the window opts in', () => { + renderWindow(false) + expect(screen.queryByRole('button', { name: /minimize/i })).toBeNull() + expect(screen.getByText('Three files uploading')).toBeTruthy() + }) +}) + +describe('the media viewer window', () => { + // It builds its own shell rather than FloatingWindow, so it wires the + // control itself and needs its own check. + function renderViewer() { + const editor = { + asset: { + id: 'asset_1', + filename: 'logo.png', + mimeType: 'image/png', + sizeBytes: 1200, + publicPath: '/uploads/logo.png', + uploadedByUserId: null, + createdAt: '2026-01-01T00:00:00.000Z', + altText: '', + caption: '', + title: '', + tags: [], + width: null, + height: null, + durationMs: null, + dominantColor: null, + deletedAt: null, + replacedAt: null, + folderIds: [], + blurHash: null, + variants: [], + posterPath: null, + }, + tagPalette: [], + folderById: new Map(), + updateAsset: async () => undefined, + renameAsset: async () => undefined, + replaceAssetFile: async () => undefined, + restoreAsset: async () => undefined, + purgeAsset: async () => undefined, + } + const session = { + user: { id: 'u1', capabilities: ['media.write', 'media.delete'] }, + setUser: () => {}, + } + render( + + [0]['editor']} + open + onClose={() => {}} + /> + , + ) + } + + it('folds to its title bar and collapses its height with it', () => { + renderViewer() + expect(screen.getByRole('textbox', { name: 'Alt text' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Minimize logo.png' })) + + expect(screen.queryByRole('textbox', { name: 'Alt text' })).toBeNull() + // The window's height is a fixed length; hiding the body alone left a + // full-size empty pane under the title bar. The attribute is what the + // stylesheet collapses it on. + const viewer = screen.getByRole('dialog', { name: 'Viewer: logo.png' }) + expect(viewer.getAttribute('data-minimized')).toBe('true') + }) + + it('comes back when expanded', () => { + renderViewer() + fireEvent.click(screen.getByRole('button', { name: 'Minimize logo.png' })) + fireEvent.click(screen.getByRole('button', { name: 'Expand logo.png' })) + + expect(screen.getByRole('textbox', { name: 'Alt text' })).toBeTruthy() + expect(screen.getByRole('dialog', { name: 'Viewer: logo.png' }).hasAttribute('data-minimized')).toBe(false) + }) +}) diff --git a/src/admin/pages/media/components/BulkEditWindow/BulkEditWindow.tsx b/src/admin/pages/media/components/BulkEditWindow/BulkEditWindow.tsx index 496894e19..d5f98bcbf 100644 --- a/src/admin/pages/media/components/BulkEditWindow/BulkEditWindow.tsx +++ b/src/admin/pages/media/components/BulkEditWindow/BulkEditWindow.tsx @@ -186,6 +186,7 @@ export function BulkEditWindow({ workspace, open, onClose }: BulkEditWindowProps return ( event.stopPropagation()} > + {/* This window builds its own shell rather than `FloatingWindow`, so the + collapse control is wired here directly — same behaviour, same + per-session scope, same reason for staying put: the position is the + user's own and folding to a corner would discard it. */} + > + + -
+ {!minimized &&
@@ -338,7 +359,7 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) { )} -
+
} {canReplace && ( @@ -42,6 +57,7 @@ export function FloatingWindow({ bodyClassName, ariaLabel, testId, + minimizable = false, onClose, children, ref: forwardedRef, @@ -52,12 +68,14 @@ export function FloatingWindow({ ) useImperativeHandle(forwardedRef, () => panelRef.current as HTMLDivElement) + const [minimized, setMinimized] = useState(false) + if (!open) return null const style = { '--floating-window-w': cssLength(width), - '--floating-window-h': cssLength(height), - '--floating-window-max-h': cssLength(maxHeight), + '--floating-window-h': minimized ? 'auto' : cssLength(height), + '--floating-window-max-h': minimized ? 'none' : cssLength(maxHeight), ...panelPositionStyle, } as CSSProperties @@ -78,9 +96,23 @@ export function FloatingWindow({ onClose={onClose} dragHandleProps={headerDragProps} > - {headerActions} + {minimized ? null : headerActions} + {minimizable && ( + + )} -
{children}
+ {!minimized &&
{children}
} , document.body, )