Files
homebox/frontend/components/Item/View/table/data-table.vue
Tonya 6cd9e2779f Use Tanstack table for Selectable Table, quick actions (#998)
* feat: implement example of data table

* feat: load item data into table

* chore: begin switching dialogs

* feat: implement old dialog for controlling headers and page size

* feat: get table into relatively usable state

* feat: enhance dropdown actions for multi-selection and CSV download

* feat: enhance table cell and dropdown button styles for better usability

* feat: json download for table

* feat: add expanded row component for item details in data table

* chore: add translation support

* feat: restore table on home page

* fix: oops need ids

* feat: move card view to use tanstack to allow for pagination

* feat: switch the items search to use ItemViewSelectable

* fix: update pagination handling and improve button click logic

* feat: improve selectable table

* feat: add indeterminate to checkbox

* feat: overhaul maintenance dialog to use new system and add maintenance options to table

* feat: add label ids and location id to item patch api

* feat: change location and labels in table view

* feat: add quick actions preference and enable toggle in table settings

* fix: lint

* fix: remove sized 1 pages

* fix: attempt to fix type error

* fix: various issues

* fix: remove

* fix: refactor item fetching logic to use useAsyncData for improved reactivity and improve use confirm

* fix: sort backend issues

* fix: enhance CSV export functionality by escaping fields to prevent formula injection

* fix: put aria sort on th not button

* chore: update api types
2025-09-24 02:37:38 +01:00

253 lines
8.2 KiB
Vue

<script setup lang="ts" generic="TData, TValue">
import BaseCard from "@/components/Base/Card.vue";
import type { ColumnDef, SortingState, VisibilityState, ExpandedState } from "@tanstack/vue-table";
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getExpandedRowModel,
useVueTable,
} from "@tanstack/vue-table";
import { camelToSnakeCase, valueUpdater } from "@/lib/utils";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import Button from "~/components/ui/button/Button.vue";
import { DialogID } from "~/components/ui/dialog-provider/utils";
import MdiArrowDown from "~icons/mdi/arrow-down";
import MdiArrowUp from "~icons/mdi/arrow-up";
import Checkbox from "~/components/ui/checkbox/Checkbox.vue";
import Label from "~/components/ui/label/Label.vue";
import type { ItemSummary } from "~/lib/api/types/data-contracts";
import TableView from "./table-view.vue";
import CardView from "./card-view.vue";
import DataTableControls from "./data-table-controls.vue";
import type { Pagination } from "../pagination";
import Switch from "~/components/ui/switch/Switch.vue";
const props = defineProps<{
columns: ColumnDef<ItemSummary, TValue>[];
data: ItemSummary[];
disableControls?: boolean;
view: "table" | "card";
locationFlatTree?: FlatTreeItem[];
externalPagination?: Pagination;
}>();
defineEmits<{
(e: "refresh"): void;
}>();
const preferences = useViewPreferences();
const defaultPageSize = preferences.value.itemsPerTablePage;
const tableHeadersData = preferences.value.tableHeaders;
const defaultVisible = ["name", "quantity", "insured", "purchasePrice"];
const tableHeaders = computed(
() =>
tableHeadersData ??
props.columns
.filter(c => c.enableHiding !== false)
.map(c => ({
value: c.id!,
enabled: defaultVisible.includes(c.id ?? ""),
}))
);
const sorting = ref<SortingState>([]);
const columnOrder = ref<string[]>([
"select",
...(tableHeaders.value ? tableHeaders.value.map(h => h.value) : []),
"actions",
]);
const columnVisibility = ref<VisibilityState>(
tableHeaders.value?.reduce((acc, h) => ({ ...acc, [h.value]: h.enabled }), {})
);
const rowSelection = ref({});
const expanded = ref<ExpandedState>({});
const pagination = ref({
pageIndex: 0,
pageSize: defaultPageSize || 12,
});
watch(
() => pagination.value.pageSize,
newSize => {
preferences.value.itemsPerTablePage = newSize;
}
);
const table = useVueTable<ItemSummary>({
manualPagination: !!props.externalPagination,
get data() {
return props.data;
},
get columns() {
return props.columns;
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility),
onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded),
onColumnOrderChange: updaterOrValue => valueUpdater(updaterOrValue, columnOrder),
onPaginationChange: updaterOrValue => valueUpdater(updaterOrValue, pagination),
state: {
get sorting() {
return sorting.value;
},
get columnVisibility() {
return columnVisibility.value;
},
get rowSelection() {
return rowSelection.value;
},
get expanded() {
return expanded.value;
},
get columnOrder() {
return columnOrder.value;
},
get pagination() {
return pagination.value;
},
},
});
const persistHeaders = () => {
const headers = table
.getAllColumns()
.filter(column => column.getCanHide())
.map(h => ({
value: h.id as keyof ItemSummary,
enabled: h.getIsVisible(),
}));
preferences.value.tableHeaders = headers;
};
const moveHeader = (from: number, to: number) => {
// Only allow moving between the first and last index (excluding 'select' and 'actions')
const start = 1; // index of 'select'
const end = columnOrder.value.length - 2; // index before 'actions'
if (from < start || from > end || to < start || to > end || from === to) return;
const order = [...columnOrder.value];
const [moved] = order.splice(from, 1);
order.splice(to, 0, moved!);
columnOrder.value = order;
persistHeaders();
};
const toggleHeader = (id: string) => {
const header = table
.getAllColumns()
.filter(column => column.getCanHide())
.find(h => h.id === id);
if (header) {
header.toggleVisibility();
}
persistHeaders();
};
</script>
<template>
<div>
<Dialog :dialog-id="DialogID.ItemTableSettings">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ $t("components.item.view.table.table_settings") }}</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4">
<div v-if="props.view === 'table'" class="flex flex-col gap-2">
<div>{{ $t("components.item.view.table.headers") }}</div>
<div class="flex flex-col">
<div
v-for="(colId, i) in columnOrder.slice(1, columnOrder.length - 1)"
:key="colId"
class="flex flex-row items-center gap-1"
>
<Button size="icon" class="size-6" variant="ghost" :disabled="i === 0" @click="moveHeader(i + 1, i)">
<MdiArrowUp />
</Button>
<Button
size="icon"
class="size-6"
variant="ghost"
:disabled="i === columnOrder.length - 3"
@click="moveHeader(i + 1, i + 2)"
>
<MdiArrowDown />
</Button>
<Checkbox
:id="colId"
:model-value="table.getColumn(colId)?.getIsVisible()"
@update:model-value="toggleHeader(colId)"
/>
<label class="text-sm" :for="colId"> {{ $t(`items.${camelToSnakeCase(colId)}`) }} </label>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<Label> {{ $t("components.item.view.table.rows_per_page") }} </Label>
<Select :model-value="pagination.pageSize" @update:model-value="val => table.setPageSize(Number(val))">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="12">12</SelectItem>
<SelectItem :value="24">24</SelectItem>
<SelectItem :value="48">48</SelectItem>
<SelectItem :value="96">96</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-2">
<Label class="text-sm"> {{ $t("components.item.view.table.quick_actions") }} </Label>
<Switch v-model="preferences.quickActions.enabled" />
</div>
</div>
</DialogContent>
</Dialog>
<BaseCard v-if="props.view === 'table'">
<div>
<TableView :table="table" :columns="columns" />
</div>
<div v-if="!props.disableControls" class="border-t p-3">
<DataTableControls
:table="table"
:pagination="pagination"
:data-length="data.length"
:external-pagination="externalPagination"
/>
</div>
</BaseCard>
<div v-else>
<CardView :table="table" :location-flat-tree="locationFlatTree" @refresh="$emit('refresh')" />
<div v-if="!props.disableControls" class="pt-2">
<DataTableControls
:table="table"
:pagination="pagination"
:data-length="data.length"
:external-pagination="externalPagination"
/>
</div>
</div>
</div>
</template>