Files
homebox/frontend/components/Item/View/table/data-table-dropdown.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

274 lines
9.2 KiB
Vue

<script setup lang="ts">
import { MoreHorizontal } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import type { ItemSummary } from "~/lib/api/types/data-contracts";
import type { Column, Row, Table } from "@tanstack/vue-table";
import { useI18n } from "vue-i18n";
import { toast } from "~/components/ui/sonner";
import { useDialog } from "@/components/ui/dialog-provider";
import { DialogID } from "~/components/ui/dialog-provider/utils";
const { t } = useI18n();
const api = useUserApi();
const confirm = useConfirm();
const preferences = useViewPreferences();
const { openDialog } = useDialog();
const props = defineProps<{
item?: ItemSummary;
multi?: {
items: Row<ItemSummary>[];
columns: Column<ItemSummary>[];
};
view: "table" | "card";
table: Table<ItemSummary>;
}>();
const emit = defineEmits<{
(e: "expand"): void;
(e: "refresh"): void;
}>();
const resetSelection = () => {
props.table.resetRowSelection();
props.table.resetExpanded();
emit("refresh");
};
const openMultiTab = async (items: string[]) => {
if (!preferences.value.shownMultiTabWarning) {
// TODO: add warning with link to docs and just improve this
const { isCanceled } = await confirm.open({
message: t("components.item.view.table.dropdown.open_multi_tab_warning"),
href: "https://homebox.software/en/user-guide/tips-tricks#open-multiple-items-in-new-tabs",
});
if (isCanceled) {
return;
}
preferences.value.shownMultiTabWarning = true;
}
items.forEach(item => window.open(`/item/${item}`, "_blank"));
};
const escapeCsvField = (value: unknown): string => {
let str = String(value ?? "");
// Mitigate formula injection
if (/^[=+\-@]/.test(str)) {
str = "'" + str;
}
// Escape double quotes
str = str.replace(/"/g, '""');
// Wrap in double quotes
return `"${str}"`;
};
const downloadCsv = (items: Row<ItemSummary>[], columns: Column<ItemSummary>[]) => {
// get enabled columns
const enabledColumns = columns.filter(c => c.id !== undefined && c.getIsVisible() && c.getCanHide()).map(c => c.id);
// create CSV header (escaped)
const header = enabledColumns.map(escapeCsvField).join(",");
// map each item to a row matching enabled columns order, escaping each field
const rows = items.map(item =>
enabledColumns.map(col => escapeCsvField(item.original[col as keyof ItemSummary])).join(",")
);
const csv = [header, ...rows].join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "items.csv";
a.click();
a.remove();
URL.revokeObjectURL(url);
};
const downloadJson = (items: Row<ItemSummary>[], columns: Column<ItemSummary>[]) => {
// get enabled columns
const enabledColumns = columns.filter(c => c.id !== undefined && c.getIsVisible() && c.getCanHide()).map(c => c.id);
// map each item to an object with only enabled columns
const data = items.map(item => {
const obj: Record<string, unknown> = {};
enabledColumns.forEach(col => {
obj[col] = item.original[col as keyof ItemSummary] ?? null;
});
return obj;
});
const exportObj = {
headers: enabledColumns,
data,
};
const json = JSON.stringify(exportObj, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "items.json";
a.click();
a.remove();
URL.revokeObjectURL(url);
};
const deleteItems = async (ids: string[]) => {
const { isCanceled } = await confirm.open(t("components.item.view.table.dropdown.delete_confirmation"));
if (isCanceled) {
return;
}
await Promise.allSettled(
ids.map(id =>
api.items.delete(id).catch(err => {
toast.error(t("components.item.view.table.dropdown.error_deleting"));
console.error(err);
})
)
);
resetSelection();
};
const duplicateItems = async (ids: string[]) => {
await Promise.allSettled(
ids.map(id =>
api.items
.duplicate(id, {
copyMaintenance: preferences.value.duplicateSettings.copyMaintenance,
copyAttachments: preferences.value.duplicateSettings.copyAttachments,
copyCustomFields: preferences.value.duplicateSettings.copyCustomFields,
copyPrefix: preferences.value.duplicateSettings.copyPrefixOverride ?? t("items.duplicate.prefix"),
})
.catch(err => {
toast.error(t("components.item.view.table.dropdown.error_duplicating"));
console.error(err);
})
)
);
resetSelection();
};
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
:variant="view === 'table' ? 'ghost' : 'outline'"
class="size-8 p-0 hover:bg-primary hover:text-primary-foreground"
>
<span class="sr-only">{{ t("components.item.view.table.dropdown.open_menu") }}</span>
<MoreHorizontal class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{{ t("components.item.view.table.dropdown.actions") }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem v-if="item" as-child>
<NuxtLink :to="`/item/${item.id}`" class="hover:underline">
{{ t("components.item.view.table.dropdown.view_item") }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem v-if="multi" @click="openMultiTab(multi.items.map(row => row.original.id))">
{{ t("components.item.view.table.dropdown.view_items") }}
</DropdownMenuItem>
<DropdownMenuItem v-if="view === 'table'" @click="$emit('expand')">
{{ t("components.item.view.table.dropdown.toggle_expand") }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<!-- change location -->
<DropdownMenuItem
@click="
openDialog(DialogID.ItemChangeDetails, {
params: { items: multi ? multi.items.map(row => row.original) : [item!], changeLocation: true },
onClose: result => {
if (result) {
toast.success(t('components.item.view.table.dropdown.change_location_success'));
resetSelection();
}
},
})
"
>
{{ t("components.item.view.table.dropdown.change_location") }}
</DropdownMenuItem>
<!-- change labels -->
<DropdownMenuItem
@click="
openDialog(DialogID.ItemChangeDetails, {
params: {
items: multi ? multi.items.map(row => row.original) : [item!],
addLabels: true,
removeLabels: true,
},
onClose: result => {
if (result) {
toast.success(t('components.item.view.table.dropdown.change_labels_success'));
resetSelection();
}
},
})
"
>
{{ t("components.item.view.table.dropdown.change_labels") }}
</DropdownMenuItem>
<!-- maintenance -->
<DropdownMenuItem
@click="
openDialog(DialogID.EditMaintenance, {
params: { type: 'create', itemId: multi ? multi.items.map(row => row.original.id) : item!.id },
onClose: result => {
if (result) {
toast.success(t('components.item.view.table.dropdown.create_maintenance_success'));
}
},
})
"
>
{{
multi
? t("components.item.view.table.dropdown.create_maintenance_selected")
: t("components.item.view.table.dropdown.create_maintenance_item")
}}
</DropdownMenuItem>
<!-- duplicate -->
<DropdownMenuItem @click="duplicateItems(multi ? multi.items.map(row => row.original.id) : [item!.id])">
{{
multi
? t("components.item.view.table.dropdown.duplicate_selected")
: t("components.item.view.table.dropdown.duplicate_item")
}}
</DropdownMenuItem>
<!-- delete -->
<DropdownMenuItem @click="deleteItems(multi ? multi.items.map(row => row.original.id) : [item!.id])">
{{
multi
? t("components.item.view.table.dropdown.delete_selected")
: t("components.item.view.table.dropdown.delete_item")
}}
</DropdownMenuItem>
<!-- download -->
<DropdownMenuSeparator v-if="multi && view === 'table'" />
<DropdownMenuItem v-if="multi && view === 'table'" @click="downloadCsv(multi.items, multi.columns)">
{{ t("components.item.view.table.dropdown.download_csv") }}
</DropdownMenuItem>
<DropdownMenuItem v-if="multi && view === 'table'" @click="downloadJson(multi.items, multi.columns)">
{{ t("components.item.view.table.dropdown.download_json") }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>