Phase 3: Unify controls across shot/asset/task toolbars and detail panels
Extracts duplication that built up as shot/asset/task features reached parity: CheckableCommandItem/ColumnToggleList replace 14+ hand-rolled checkbox-list blocks, shared toolbar pieces (debounced search, detail-panel toggle, clear-filters, segmented view/context toggle) replace copy-pasted markup in the three table toolbars, icon-only buttons standardize on the icon-sm size, TaskBulkActionsMenu's Assign To submenu matches Set Status, and a shared DetailPanelOverlay/Header/Loading/Error shell backs all three detail panels (adding a previously-missing error state to the task panel).
This commit is contained in:
@@ -15,35 +15,11 @@
|
||||
</Button>
|
||||
|
||||
<!-- Context Filter Toggle -->
|
||||
<div class="flex items-center border rounded-md h-8 p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{'bg-muted':contextFilter === 'all'}"
|
||||
@click="$emit('update:context-filter', 'all')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{'bg-muted':contextFilter === 'shots'}"
|
||||
@click="$emit('update:context-filter', 'shots')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<Film class="h-4 w-4 mr-1" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{'bg-muted': contextFilter === 'assets'}"
|
||||
@click="$emit('update:context-filter', 'assets')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<Package class="h-4 w-4 mr-1" />
|
||||
</Button>
|
||||
</div>
|
||||
<SegmentedToggle
|
||||
:options="contextFilterOptions"
|
||||
:model-value="contextFilter"
|
||||
@update:model-value="$emit('update:context-filter', $event as 'all' | 'shots' | 'assets')"
|
||||
/>
|
||||
<!-- Status Filter -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
@@ -65,24 +41,15 @@
|
||||
<CommandList>
|
||||
<CommandEmpty>No status found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
<CheckableCommandItem
|
||||
v-for="status in statusOptions"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
@select="toggleStatusFilter(status.value)"
|
||||
:model-value="statusFilter.includes(status.value)"
|
||||
@update:model-value="toggleStatusFilter(status.value)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
statusFilter.includes(status.value)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ status.label }}</span>
|
||||
</CommandItem>
|
||||
</CheckableCommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -110,24 +77,15 @@
|
||||
<CommandList>
|
||||
<CommandEmpty>No type found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
<CheckableCommandItem
|
||||
v-for="type in taskTypes"
|
||||
:key="type"
|
||||
:value="type"
|
||||
@select="toggleTypeFilter(type)"
|
||||
:model-value="typeFilter.includes(type)"
|
||||
@update:model-value="toggleTypeFilter(type)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
typeFilter.includes(type)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span class="capitalize">{{ type.replace(/_/g, ' ') }}</span>
|
||||
</CommandItem>
|
||||
</CheckableCommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -155,40 +113,22 @@
|
||||
<CommandList>
|
||||
<CommandEmpty>No episode found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
<CheckableCommandItem
|
||||
value="all"
|
||||
@select="$emit('update:episode-filter', null)"
|
||||
:model-value="episodeFilter === null"
|
||||
@update:model-value="$emit('update:episode-filter', null)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
episodeFilter === null
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>All Episodes</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
</CheckableCommandItem>
|
||||
<CheckableCommandItem
|
||||
v-for="episode in episodes"
|
||||
:key="episode.id"
|
||||
:value="episode.id.toString()"
|
||||
@select="$emit('update:episode-filter', episode.id)"
|
||||
:model-value="episodeFilter === episode.id"
|
||||
@update:model-value="$emit('update:episode-filter', episode.id)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
episodeFilter === episode.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ episode.name }}</span>
|
||||
</CommandItem>
|
||||
</CheckableCommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -216,24 +156,15 @@
|
||||
<CommandList>
|
||||
<CommandEmpty>No assignee found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
<CheckableCommandItem
|
||||
v-for="assignee in assignees"
|
||||
:key="assignee.id"
|
||||
:value="assignee.id.toString()"
|
||||
@select="toggleAssigneeFilter(assignee.id)"
|
||||
:model-value="assigneeFilter.includes(assignee.id)"
|
||||
@update:model-value="toggleAssigneeFilter(assignee.id)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
assigneeFilter.includes(assignee.id)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ assignee.name }}</span>
|
||||
</CommandItem>
|
||||
</CheckableCommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -241,64 +172,19 @@
|
||||
</Popover>
|
||||
|
||||
<!-- Column Visibility -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="ml-auto h-8 border-dashed">
|
||||
<Settings2 class="mr-2 h-4 w-4" />
|
||||
View
|
||||
<Badge
|
||||
v-if="hiddenColumnsCount > 0"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
{{ hiddenColumnsCount }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="end">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search columns..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No columns found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="column in allColumns"
|
||||
:key="column.id"
|
||||
:value="column.id"
|
||||
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
columnVisibility[column.id] !== false
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ column.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div class="ml-auto">
|
||||
<ColumnToggleList
|
||||
:columns="allColumns"
|
||||
:model-value="columnVisibility"
|
||||
@update:model-value="$emit('update:column-visibility', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel Enable/Disable Toggle Button -->
|
||||
<Button
|
||||
@click="$emit('toggle-detail-panel')"
|
||||
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
:class="[
|
||||
'h-8 w-8 p-0',
|
||||
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
|
||||
]"
|
||||
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
|
||||
>
|
||||
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
|
||||
<PanelRightOpen v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
<DetailPanelToggleButton
|
||||
:enabled="isDetailPanelEnabled"
|
||||
@toggle="$emit('toggle-detail-panel')"
|
||||
/>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative flex-1">
|
||||
@@ -312,23 +198,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<Button
|
||||
v-if="hasFilters"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Reset
|
||||
<X class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Search, ListFilter, Tag, Film, Package, User, Settings2, Check, X, PanelRightClose, PanelRightOpen } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { Search, ListFilter, Tag, Film, Package, User } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -342,9 +219,14 @@ import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CheckableCommandItem,
|
||||
} from '@/components/ui/command'
|
||||
import ColumnToggleList from '@/components/shared/ColumnToggleList.vue'
|
||||
import SegmentedToggle from '@/components/shared/SegmentedToggle.vue'
|
||||
import DetailPanelToggleButton from '@/components/shared/DetailPanelToggleButton.vue'
|
||||
import ClearFiltersButton from '@/components/shared/ClearFiltersButton.vue'
|
||||
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
|
||||
import type { VisibilityState } from '@tanstack/vue-table'
|
||||
import type { Episode } from '@/services/episode'
|
||||
|
||||
@@ -387,6 +269,12 @@ const statusOptions = [
|
||||
{ value: 'retake', label: 'Retake' },
|
||||
]
|
||||
|
||||
const contextFilterOptions = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'shots', icon: Film },
|
||||
{ value: 'assets', icon: Package },
|
||||
]
|
||||
|
||||
// Column definitions
|
||||
const allColumns = [
|
||||
{ id: 'name', label: 'Task Name' },
|
||||
@@ -414,19 +302,8 @@ const hasFilters = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const hiddenColumnsCount = computed(() => {
|
||||
return allColumns.filter(col => props.columnVisibility[col.id] === false).length
|
||||
})
|
||||
|
||||
// Debounced search
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const debouncedSearch = (value: string | number) => {
|
||||
const searchValue = typeof value === 'string' ? value : String(value)
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
emit('update:search', searchValue)
|
||||
}, 300)
|
||||
}
|
||||
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
|
||||
|
||||
// Methods
|
||||
const toggleStatusFilter = (status: string) => {
|
||||
@@ -450,11 +327,6 @@ const toggleAssigneeFilter = (assigneeId: number) => {
|
||||
emit('update:assignee-filter', newFilter)
|
||||
}
|
||||
|
||||
const toggleColumn = (columnId: string, value: any) => {
|
||||
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
|
||||
emit('update:column-visibility', newVisibility)
|
||||
}
|
||||
|
||||
const toggleMyTasksFilter = () => {
|
||||
const newValue = !props.myTasksFilter
|
||||
emit('update:my-tasks-filter', newValue)
|
||||
|
||||
Reference in New Issue
Block a user