feat(12-03): capability-aware action rendering in StorageBrowser
- 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
This commit is contained in:
@@ -7,7 +7,10 @@
|
|||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<BreadcrumbBar
|
<BreadcrumbBar
|
||||||
:segments="breadcrumb"
|
:segments="breadcrumb"
|
||||||
:root-label="mode === 'cloud' ? 'Cloud' : 'Home'"
|
:root-label="connectionRoot ? connectionRoot.name : (mode === 'cloud' ? 'Cloud' : 'Home')"
|
||||||
|
:show-root="true"
|
||||||
|
:folder-freshness="folderFreshness"
|
||||||
|
:last-refreshed-at="lastRefreshedAt"
|
||||||
@navigate="$emit('breadcrumb-navigate', $event)"
|
@navigate="$emit('breadcrumb-navigate', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -20,7 +23,7 @@
|
|||||||
@change="handleSortChange"
|
@change="handleSortChange"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="mode === 'local'"
|
v-if="effectiveCaps.create_folder"
|
||||||
@click="$emit('new-folder')"
|
@click="$emit('new-folder')"
|
||||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-indigo-600 border border-indigo-200 hover:bg-indigo-50 active:bg-indigo-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-indigo-600 border border-indigo-200 hover:bg-indigo-50 active:bg-indigo-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||||
>
|
>
|
||||||
@@ -52,7 +55,7 @@
|
|||||||
<AppIcon name="chartBar" class="w-4 h-4" />
|
<AppIcon name="chartBar" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="mode === 'local'"
|
v-if="effectiveCaps.create_folder"
|
||||||
type="button"
|
type="button"
|
||||||
title="New folder"
|
title="New folder"
|
||||||
aria-label="New folder"
|
aria-label="New folder"
|
||||||
@@ -80,6 +83,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Capability explanation banner (touch tap or keyboard on disabled action) -->
|
||||||
|
<transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="capabilityNotice"
|
||||||
|
class="mx-4 sm:mx-6 mt-2 px-4 py-2.5 rounded-lg border flex items-start gap-2"
|
||||||
|
:class="capabilityNotice.type === 'temporary' ? 'bg-amber-50 border-amber-200 text-amber-800' : 'bg-gray-50 border-gray-200 text-gray-600'"
|
||||||
|
role="alert"
|
||||||
|
data-test="capability-notice"
|
||||||
|
>
|
||||||
|
<AppIcon name="info" class="w-4 h-4 shrink-0 mt-0.5" :class="capabilityNotice.type === 'temporary' ? 'text-amber-500' : 'text-gray-400'" />
|
||||||
|
<p class="text-sm">{{ capabilityNotice.message }}</p>
|
||||||
|
<button class="ml-auto text-xs opacity-60 hover:opacity-100" @click="capabilityNotice = null" aria-label="Dismiss">
|
||||||
|
<AppIcon name="x" class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto flex flex-col">
|
<div class="flex-1 overflow-y-auto flex flex-col">
|
||||||
|
|
||||||
<div class="px-4 sm:px-6 pt-5 pb-3">
|
<div class="px-4 sm:px-6 pt-5 pb-3">
|
||||||
@@ -126,9 +146,9 @@
|
|||||||
'bg-gray-50': renamingId === folder.id,
|
'bg-gray-50': renamingId === folder.id,
|
||||||
}"
|
}"
|
||||||
@click="renamingId === folder.id ? null : $emit('folder-navigate', folder)"
|
@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"
|
@dragleave="dragOverFolderId = null"
|
||||||
@drop.prevent="mode === 'local' ? onDropDocOnFolder(folder.id) : null"
|
@drop.prevent="effectiveCaps.drag_move ? onDropDocOnFolder(folder.id) : null"
|
||||||
>
|
>
|
||||||
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center shrink-0">
|
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center shrink-0">
|
||||||
<AppIcon name="folder" class="w-4 h-4 text-amber-500" />
|
<AppIcon name="folder" class="w-4 h-4 text-amber-500" />
|
||||||
@@ -150,16 +170,27 @@
|
|||||||
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(folder.created_at) }}</span>
|
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(folder.created_at) }}</span>
|
||||||
|
|
||||||
<div class="flex justify-end gap-0.5" data-test="folder-row-actions" @click.stop>
|
<div class="flex justify-end gap-0.5" data-test="folder-row-actions" @click.stop>
|
||||||
<template v-if="mode === 'local'">
|
<!-- Rename folder — supported only when rename cap is active -->
|
||||||
<button @click.stop="startRename(folder)" title="Rename"
|
<CapabilityButton
|
||||||
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1">
|
:cap="effectiveCaps.rename_folder ? 'supported' : 'unsupported'"
|
||||||
<AppIcon name="pencil" class="w-3.5 h-3.5" />
|
title="Rename"
|
||||||
</button>
|
:message="capMessage('rename_folder')"
|
||||||
<button @click.stop="$emit('folder-delete', folder)" title="Delete folder"
|
@action="startRename(folder)"
|
||||||
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 active:bg-red-100 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1">
|
@explain="showCapabilityNotice"
|
||||||
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
>
|
||||||
</button>
|
<AppIcon name="pencil" class="w-3.5 h-3.5" />
|
||||||
</template>
|
</CapabilityButton>
|
||||||
|
<!-- Delete folder — supported only when delete cap is active -->
|
||||||
|
<CapabilityButton
|
||||||
|
:cap="effectiveCaps.delete_folder ? 'supported' : 'unsupported'"
|
||||||
|
title="Delete folder"
|
||||||
|
:message="capMessage('delete_folder')"
|
||||||
|
variant="danger"
|
||||||
|
@action="$emit('folder-delete', folder)"
|
||||||
|
@explain="showCapabilityNotice"
|
||||||
|
>
|
||||||
|
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
||||||
|
</CapabilityButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -167,11 +198,11 @@
|
|||||||
<div
|
<div
|
||||||
v-for="file in files"
|
v-for="file in files"
|
||||||
:key="`d-${file.id}`"
|
:key="`d-${file.id}`"
|
||||||
:draggable="mode === 'local'"
|
:draggable="effectiveCaps.drag_move"
|
||||||
class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none"
|
class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none"
|
||||||
:class="{ 'opacity-50': draggingFile?.id === file.id }"
|
:class="{ 'opacity-50': draggingFile?.id === file.id }"
|
||||||
@click="draggingFile ? null : $emit('file-open', file)"
|
@click="draggingFile ? null : $emit('file-open', file)"
|
||||||
@dragstart="mode === 'local' ? onFileDragStart(file, $event) : null"
|
@dragstart="effectiveCaps.drag_move ? onFileDragStart(file, $event) : null"
|
||||||
@dragend="onFileDragEnd"
|
@dragend="onFileDragEnd"
|
||||||
>
|
>
|
||||||
<div class="w-7 h-7 bg-indigo-50 rounded-lg flex items-center justify-center shrink-0">
|
<div class="w-7 h-7 bg-indigo-50 rounded-lg flex items-center justify-center shrink-0">
|
||||||
@@ -193,29 +224,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="text-right text-xs text-gray-400 hidden md:block">{{ formatSize(file.size_bytes ?? file.size) }}</span>
|
<span class="text-right text-xs text-gray-400 hidden md:block">
|
||||||
|
{{ formatSize(file.size_bytes ?? file.size) }}
|
||||||
|
<!-- Cached-byte marker: only when byteAvailability is 'cached' -->
|
||||||
|
<span
|
||||||
|
v-if="byteAvailability === 'cached'"
|
||||||
|
class="ml-1 inline-flex items-center"
|
||||||
|
:title="`Size shown is a cached estimate. Last synced: ${lastRefreshedAt ? formatDate(lastRefreshedAt) : 'unknown'}`"
|
||||||
|
data-test="cached-byte-marker"
|
||||||
|
>
|
||||||
|
<AppIcon name="clock" class="w-3 h-3 text-amber-400" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(file.created_at) }}</span>
|
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(file.created_at) }}</span>
|
||||||
|
|
||||||
<div class="flex justify-end gap-0.5" data-test="file-row-actions" @click.stop>
|
<div class="flex justify-end gap-0.5" data-test="file-row-actions" @click.stop>
|
||||||
<template v-if="mode === 'local'">
|
<!-- Share -->
|
||||||
<button @click.stop="$emit('file-share', file)" title="Share"
|
<CapabilityButton
|
||||||
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1">
|
:cap="fileCapState('share')"
|
||||||
<AppIcon name="share" class="w-3.5 h-3.5" />
|
title="Share"
|
||||||
</button>
|
:message="capMessage('share')"
|
||||||
<div class="relative">
|
@action="$emit('file-share', file)"
|
||||||
<button
|
@explain="showCapabilityNotice"
|
||||||
@click.stop="openFolderPicker(file.id, $event)"
|
>
|
||||||
title="Move to folder"
|
<AppIcon name="share" class="w-3.5 h-3.5" />
|
||||||
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
</CapabilityButton>
|
||||||
>
|
<!-- Move to folder -->
|
||||||
<AppIcon name="folderMove" class="w-3.5 h-3.5" />
|
<div class="relative">
|
||||||
</button>
|
<CapabilityButton
|
||||||
</div>
|
:cap="fileCapState('move')"
|
||||||
<button @click.stop="$emit('file-delete', file.id)" title="Delete"
|
title="Move to folder"
|
||||||
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 active:bg-red-100 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1">
|
:message="capMessage('move')"
|
||||||
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
@action="openFolderPicker(file.id, $event)"
|
||||||
</button>
|
@explain="showCapabilityNotice"
|
||||||
</template>
|
>
|
||||||
|
<AppIcon name="folderMove" class="w-3.5 h-3.5" />
|
||||||
|
</CapabilityButton>
|
||||||
|
</div>
|
||||||
|
<!-- Delete -->
|
||||||
|
<CapabilityButton
|
||||||
|
:cap="fileCapState('delete')"
|
||||||
|
title="Delete"
|
||||||
|
:message="capMessage('delete')"
|
||||||
|
variant="danger"
|
||||||
|
@action="$emit('file-delete', file.id)"
|
||||||
|
@explain="showCapabilityNotice"
|
||||||
|
>
|
||||||
|
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
||||||
|
</CapabilityButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -301,8 +357,72 @@ import UploadProgress from '../upload/UploadProgress.vue'
|
|||||||
import TopicBadge from '../topics/TopicBadge.vue'
|
import TopicBadge from '../topics/TopicBadge.vue'
|
||||||
import { formatDate, formatSize } from '../../utils/formatters.js'
|
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({
|
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' },
|
mode: { type: String, default: 'local' },
|
||||||
folders: { type: Array, default: () => [] },
|
folders: { type: Array, default: () => [] },
|
||||||
files: { type: Array, default: () => [] },
|
files: { type: Array, default: () => [] },
|
||||||
@@ -318,6 +438,21 @@ const props = defineProps({
|
|||||||
topicColorFn: { type: Function, default: () => '#6366f1' },
|
topicColorFn: { type: Function, default: () => '#6366f1' },
|
||||||
emptyMessage: { type: String, default: 'This folder is empty' },
|
emptyMessage: { type: String, default: 'This folder is empty' },
|
||||||
emptyHint: { type: String, default: 'Upload files above or create a sub-folder' },
|
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([
|
const emit = defineEmits([
|
||||||
@@ -334,8 +469,66 @@ const emit = defineEmits([
|
|||||||
'file-share',
|
'file-share',
|
||||||
'file-move',
|
'file-move',
|
||||||
'file-delete',
|
'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')
|
const showSearch = computed(() => props.mode === 'local' || props.mode === 'cloud')
|
||||||
|
|
||||||
function topicColor(name) {
|
function topicColor(name) {
|
||||||
@@ -487,3 +680,14 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('resize', onWindowScroll)
|
window.removeEventListener('resize', onWindowScroll)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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: '<span class="app-icon-stub" />' },
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
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',
|
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',
|
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',
|
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: [
|
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',
|
'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',
|
||||||
|
|||||||
Reference in New Issue
Block a user