From 44244335a1cc346e5f0b41b3db65387a86130026 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Thu, 18 Jun 2026 23:29:10 +0200 Subject: [PATCH] feat(12-03): capability-aware action rendering in StorageBrowser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add capabilities, connectionRoot, folderFreshness, lastRefreshedAt, byteAvailability props - CapabilityButton inline component: aria-disabled, click/Enter/Space/touch suppression - Gray for unsupported, amber for temporarily_unavailable — no native disabled - Local defaults preserve all pre-Phase-12 drag/share/move/delete behavior - Cached-byte marker (clock icon) on size column when byteAvailability='cached' - No marker for cloud_only - AppIcon: add info, clock icons - 50 StorageBrowser tests pass --- .../src/components/storage/StorageBrowser.vue | 280 +++++++++++++++--- .../StorageBrowser.capabilities.test.js | 257 ++++++++++++++++ frontend/src/components/ui/AppIcon.vue | 2 + 3 files changed, 501 insertions(+), 38 deletions(-) create mode 100644 frontend/src/components/storage/__tests__/StorageBrowser.capabilities.test.js diff --git a/frontend/src/components/storage/StorageBrowser.vue b/frontend/src/components/storage/StorageBrowser.vue index 823c0d5..2c6f971 100644 --- a/frontend/src/components/storage/StorageBrowser.vue +++ b/frontend/src/components/storage/StorageBrowser.vue @@ -7,7 +7,10 @@
@@ -20,7 +23,7 @@ @change="handleSortChange" /> + + +
@@ -126,9 +146,9 @@ 'bg-gray-50': renamingId === folder.id, }" @click="renamingId === folder.id ? null : $emit('folder-navigate', folder)" - @dragover.prevent="mode === 'local' ? onFolderDragOver(folder.id, $event) : null" + @dragover.prevent="effectiveCaps.drag_move ? onFolderDragOver(folder.id, $event) : null" @dragleave="dragOverFolderId = null" - @drop.prevent="mode === 'local' ? onDropDocOnFolder(folder.id) : null" + @drop.prevent="effectiveCaps.drag_move ? onDropDocOnFolder(folder.id) : null" >
@@ -150,16 +170,27 @@
- + + + + + + + +
@@ -167,11 +198,11 @@
@@ -193,29 +224,54 @@
- +
- + + + + + +
+ + + +
+ + + +
@@ -301,8 +357,72 @@ import UploadProgress from '../upload/UploadProgress.vue' import TopicBadge from '../topics/TopicBadge.vue' import { formatDate, formatSize } from '../../utils/formatters.js' +// ── CapabilityButton inline component ────────────────────────────────────────── +/** + * Renders an action button that is aware of its capability state. + * cap: 'supported' | 'unsupported' | 'temporarily_unavailable' + * variant: 'default' | 'danger' + * + * Accessibility: aria-disabled is used (not native disabled) so the element + * remains focusable. Enter, Space, pointer click, and touch tap on a + * non-supported button suppress the action and surface the explanation. + */ +const CapabilityButton = { + name: 'CapabilityButton', + props: { + cap: { type: String, default: 'supported' }, + title: { type: String, default: '' }, + message: { type: String, default: '' }, + variant: { type: String, default: 'default' }, + }, + emits: ['action', 'explain'], + setup(props, { emit, slots }) { + function handleActivate(e) { + if (props.cap !== 'supported') { + e.stopPropagation() + emit('explain', { message: props.message, type: props.cap === 'temporarily_unavailable' ? 'temporary' : 'unsupported' }) + return + } + emit('action', e) + } + function handleKeydown(e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleActivate(e) + } + } + return () => { + const isDisabled = props.cap !== 'supported' + const colorClass = isDisabled + ? (props.cap === 'temporarily_unavailable' + ? 'text-amber-400 hover:text-amber-500' + : 'text-gray-300 hover:text-gray-400') + : (props.variant === 'danger' + ? 'text-gray-400 hover:text-red-500 hover:bg-red-50 active:bg-red-100 focus-visible:ring-red-500' + : 'text-gray-400 hover:text-gray-700 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring-indigo-500') + + const h = Vue.h + return h('button', { + type: 'button', + title: isDisabled ? (props.message || props.title) : props.title, + 'aria-label': props.title, + 'aria-disabled': isDisabled ? 'true' : undefined, + 'data-cap': props.cap, + class: `p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 ${colorClass}`, + onClick: handleActivate, + onKeydown: handleKeydown, + onTouchend: handleActivate, + }, slots.default?.()) + } + }, +} + +// Vue's h function reference for CapabilityButton +import { h as vueH } from 'vue' +const Vue = { h: vueH } + const props = defineProps({ - /** "local" or "cloud" — controls which actions are visible */ + /** "local" or "cloud" — used for search/sort visibility fallback only */ mode: { type: String, default: 'local' }, folders: { type: Array, default: () => [] }, files: { type: Array, default: () => [] }, @@ -318,6 +438,21 @@ const props = defineProps({ topicColorFn: { type: Function, default: () => '#6366f1' }, emptyMessage: { type: String, default: 'This folder is empty' }, emptyHint: { type: String, default: 'Upload files above or create a sub-folder' }, + /** + * Capability map for cloud connections. + * Keys: share, move, delete, rename_folder, delete_folder, create_folder, drag_move + * Values: true (supported) | false (unsupported) | 'temporarily_unavailable' + * Defaults to full local capability set when null (local mode). + */ + capabilities: { type: Object, default: null }, + /** Connection root metadata { id, name, provider } for breadcrumb */ + connectionRoot: { type: Object, default: null }, + /** 'refreshing' | 'fresh' | 'stale' | null */ + folderFreshness: { type: String, default: null }, + /** ISO timestamp of last successful refresh */ + lastRefreshedAt: { type: String, default: null }, + /** 'cached' | 'cloud_only' | null — affects size column markers */ + byteAvailability: { type: String, default: null }, }) const emit = defineEmits([ @@ -334,8 +469,66 @@ const emit = defineEmits([ 'file-share', 'file-move', 'file-delete', + 'capability-explain', ]) +/** + * Default capability set for local mode — full access to all actions. + * When capabilities prop is provided (cloud mode), uses that instead. + */ +const LOCAL_CAPS = { + share: true, + move: true, + delete: true, + rename_folder: true, + delete_folder: true, + create_folder: true, + drag_move: true, +} + +/** Effective capabilities: local defaults or cloud-provided */ +const effectiveCaps = computed(() => { + if (props.capabilities) return { ...LOCAL_CAPS, ...props.capabilities } + return LOCAL_CAPS +}) + +/** + * Determine capability state for a file action. + * Returns 'supported' | 'unsupported' | 'temporarily_unavailable' + */ +function fileCapState(action) { + const cap = effectiveCaps.value[action] + if (cap === true) return 'supported' + if (cap === 'temporarily_unavailable') return 'temporarily_unavailable' + return 'unsupported' +} + +/** Human-readable explanation for a capability message */ +function capMessage(capKey) { + const messages = { + share: 'Sharing is not available for this cloud connection.', + move: 'Moving files is not available for this cloud connection.', + delete: 'Deleting files is not available for this cloud connection.', + rename_folder: 'Renaming folders is not available for this cloud connection.', + delete_folder: 'Deleting folders is not available for this cloud connection.', + create_folder: 'Creating folders is not available for this cloud connection.', + drag_move: 'Drag-to-move is not available for this cloud connection.', + } + const cap = effectiveCaps.value[capKey] + if (cap === 'temporarily_unavailable') { + return `This action is temporarily unavailable. Please try again in a moment.` + } + return messages[capKey] ?? 'This action is not available.' +} + +/** Inline notice displayed on touch/keyboard activation of a disabled button */ +const capabilityNotice = ref(null) + +function showCapabilityNotice({ message, type }) { + capabilityNotice.value = { message, type } + emit('capability-explain', { message, type }) +} + const showSearch = computed(() => props.mode === 'local' || props.mode === 'cloud') function topicColor(name) { @@ -487,3 +680,14 @@ onUnmounted(() => { window.removeEventListener('resize', onWindowScroll) }) + + diff --git a/frontend/src/components/storage/__tests__/StorageBrowser.capabilities.test.js b/frontend/src/components/storage/__tests__/StorageBrowser.capabilities.test.js new file mode 100644 index 0000000..3091f02 --- /dev/null +++ b/frontend/src/components/storage/__tests__/StorageBrowser.capabilities.test.js @@ -0,0 +1,257 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { nextTick } from 'vue' +import StorageBrowser from '../StorageBrowser.vue' + +const globalStubs = { + BreadcrumbBar: true, + SearchBar: true, + SortControls: true, + DropZone: true, + UploadProgress: true, + TopicBadge: true, + AppIcon: { template: '' }, + EmptyState: true, +} + +const FOLDERS = [{ id: 'f1', name: 'Archive', created_at: '2025-01-01T00:00:00Z' }] +const FILES = [{ id: 'd1', original_name: 'report.pdf', size_bytes: 1024, created_at: '2025-01-01T00:00:00Z', topics: [] }] + +// Full cloud-unsupported capabilities +const CAPS_NONE = { + share: false, + move: false, + delete: false, + rename_folder: false, + delete_folder: false, + create_folder: false, + drag_move: false, +} + +// Temporarily unavailable +const CAPS_TEMP = { + share: 'temporarily_unavailable', + move: 'temporarily_unavailable', + delete: 'temporarily_unavailable', + rename_folder: false, + delete_folder: false, + create_folder: false, + drag_move: false, +} + +beforeEach(() => { + setActivePinia(createPinia()) +}) + +describe('StorageBrowser capability rendering', () => { + + // ── Local defaults — all pre-Phase-12 local actions preserved ───────────── + it('local mode: folders have rename and delete buttons in supported state', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'local', folders: FOLDERS, files: [], rootFolders: [] }, + global: { stubs: globalStubs }, + }) + const folderActions = w.find('[data-test="folder-row-actions"]') + const buttons = folderActions.findAll('button') + expect(buttons.length).toBeGreaterThanOrEqual(2) + // No aria-disabled on local + buttons.forEach(btn => { + expect(btn.attributes('aria-disabled')).toBeUndefined() + }) + }) + + it('local mode: file rows have share, move, delete buttons in supported state', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'local', folders: [], files: FILES, rootFolders: [] }, + global: { stubs: globalStubs }, + }) + const fileActions = w.find('[data-test="file-row-actions"]') + const buttons = fileActions.findAll('button') + expect(buttons.length).toBeGreaterThanOrEqual(3) + buttons.forEach(btn => { + expect(btn.attributes('aria-disabled')).toBeUndefined() + }) + }) + + it('local mode: supported file-delete button emits file-delete on click', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'local', folders: [], files: FILES, rootFolders: [] }, + global: { stubs: globalStubs }, + }) + const fileActions = w.find('[data-test="file-row-actions"]') + const deleteBtn = fileActions.findAll('button').find(b => b.attributes('title') === 'Delete') + await deleteBtn.trigger('click') + expect(w.emitted('file-delete')).toBeTruthy() + expect(w.emitted('file-delete')[0][0]).toBe('d1') + }) + + // ── Unsupported actions — aria-disabled, gray, action suppressed ───────── + it('cloud unsupported: file actions have aria-disabled="true"', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE }, + global: { stubs: globalStubs }, + }) + const fileActions = w.find('[data-test="file-row-actions"]') + const buttons = fileActions.findAll('button') + buttons.forEach(btn => { + expect(btn.attributes('aria-disabled')).toBe('true') + }) + }) + + it('cloud unsupported: clicking aria-disabled action does NOT emit the action', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE }, + global: { stubs: globalStubs }, + }) + const fileActions = w.find('[data-test="file-row-actions"]') + const deleteBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Delete') + await deleteBtn.trigger('click') + expect(w.emitted('file-delete')).toBeFalsy() + }) + + it('cloud unsupported: keyboard Enter on disabled action does NOT emit action', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE }, + global: { stubs: globalStubs }, + }) + const fileActions = w.find('[data-test="file-row-actions"]') + const shareBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Share') + await shareBtn.trigger('keydown', { key: 'Enter' }) + expect(w.emitted('file-share')).toBeFalsy() + }) + + it('cloud unsupported: keyboard Space on disabled action does NOT emit action', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE }, + global: { stubs: globalStubs }, + }) + const fileActions = w.find('[data-test="file-row-actions"]') + const shareBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Share') + await shareBtn.trigger('keydown', { key: ' ' }) + expect(w.emitted('file-share')).toBeFalsy() + }) + + it('cloud unsupported: clicking disabled action shows capability-notice', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE }, + global: { stubs: globalStubs }, + }) + const fileActions = w.find('[data-test="file-row-actions"]') + const deleteBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Delete') + await deleteBtn.trigger('click') + await nextTick() + expect(w.find('[data-test="capability-notice"]').exists()).toBe(true) + }) + + it('cloud unsupported: also emits capability-explain event', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE }, + global: { stubs: globalStubs }, + }) + const fileActions = w.find('[data-test="file-row-actions"]') + const deleteBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Delete') + await deleteBtn.trigger('click') + expect(w.emitted('capability-explain')).toBeTruthy() + }) + + // ── Temporarily unavailable — amber state ───────────────────────────────── + it('temporarily_unavailable: action buttons have data-cap="temporarily_unavailable"', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_TEMP }, + global: { stubs: globalStubs }, + }) + const fileActions = w.find('[data-test="file-row-actions"]') + const tempBtns = fileActions.findAll('button').filter(b => b.attributes('data-cap') === 'temporarily_unavailable') + expect(tempBtns.length).toBeGreaterThan(0) + }) + + it('temporarily_unavailable: clicking does NOT emit action', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_TEMP }, + global: { stubs: globalStubs }, + }) + const fileActions = w.find('[data-test="file-row-actions"]') + const shareBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Share') + await shareBtn.trigger('click') + expect(w.emitted('file-share')).toBeFalsy() + }) + + // ── No native disabled attr ──────────────────────────────────────────────── + it('no button uses native disabled attribute (all use aria-disabled)', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'cloud', folders: FOLDERS, files: FILES, rootFolders: [], capabilities: CAPS_NONE }, + global: { stubs: globalStubs }, + }) + const allButtons = w.findAll('button') + allButtons.forEach(btn => { + expect(btn.attributes('disabled')).toBeUndefined() + }) + }) + + // ── Cached byte marker ──────────────────────────────────────────────────── + it('shows cached-byte marker when byteAvailability is "cached"', async () => { + const w = mount(StorageBrowser, { + props: { + mode: 'cloud', folders: [], files: FILES, rootFolders: [], + capabilities: CAPS_NONE, byteAvailability: 'cached', + lastRefreshedAt: '2026-01-01T00:00:00Z', + }, + global: { stubs: globalStubs }, + }) + expect(w.find('[data-test="cached-byte-marker"]').exists()).toBe(true) + }) + + it('does NOT show cached-byte marker when byteAvailability is "cloud_only"', async () => { + const w = mount(StorageBrowser, { + props: { + mode: 'cloud', folders: [], files: FILES, rootFolders: [], + capabilities: CAPS_NONE, byteAvailability: 'cloud_only', + }, + global: { stubs: globalStubs }, + }) + expect(w.find('[data-test="cached-byte-marker"]').exists()).toBe(false) + }) + + it('does NOT show cached-byte marker in local mode', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'local', folders: [], files: FILES, rootFolders: [] }, + global: { stubs: globalStubs }, + }) + expect(w.find('[data-test="cached-byte-marker"]').exists()).toBe(false) + }) + + // ── Touch target min size ───────────────────────────────────────────────── + it('action buttons have min-w-[36px] min-h-[36px] touch target classes', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE }, + global: { stubs: globalStubs }, + }) + const fileActions = w.find('[data-test="file-row-actions"]') + const buttons = fileActions.findAll('button') + buttons.forEach(btn => { + const cls = btn.attributes('class') || '' + expect(cls).toContain('min-w-[36px]') + expect(cls).toContain('min-h-[36px]') + }) + }) + + // ── Drag/drop preserved for local ──────────────────────────────────────── + it('local mode: file rows have draggable="true"', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'local', folders: FOLDERS, files: FILES, rootFolders: [] }, + global: { stubs: globalStubs }, + }) + const fileRow = w.find('[draggable="true"]') + expect(fileRow.exists()).toBe(true) + }) + + it('cloud mode with drag_move=false: file rows are NOT draggable', async () => { + const w = mount(StorageBrowser, { + props: { mode: 'cloud', folders: FOLDERS, files: FILES, rootFolders: [], capabilities: CAPS_NONE }, + global: { stubs: globalStubs }, + }) + const fileRows = w.findAll('[draggable="true"]') + expect(fileRows.length).toBe(0) + }) +}) diff --git a/frontend/src/components/ui/AppIcon.vue b/frontend/src/components/ui/AppIcon.vue index d27db40..4fba158 100644 --- a/frontend/src/components/ui/AppIcon.vue +++ b/frontend/src/components/ui/AppIcon.vue @@ -60,6 +60,8 @@ const ICON_PATHS = { pencilEdit: 'M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z', lightBulb: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z', search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0', + info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', + clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z', dots: 'M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z', cog: [ 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',