Skip to content

Commit

Permalink
179 select table columns (#187)
Browse files Browse the repository at this point in the history
Introduce column selection and filtering logic (#179)
  • Loading branch information
katharinawuensche authored Nov 18, 2024
2 parents 341870c + f075b83 commit 2b3d28d
Show file tree
Hide file tree
Showing 17 changed files with 648 additions and 58 deletions.
82 changes: 82 additions & 0 deletions components/data-table/data-table-active-filters.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { ColumnFilter, Table } from "@tanstack/vue-table";
import { Filter, X } from "lucide-vue-next";
const props = defineProps<{
table: Table<never>;
}>();
const activeFilterColumns = computed(() =>
props.table
.getState()
.columnFilters.filter((c: ColumnFilter) => (c.value as Array<string>).length > 0),
);
function removeFilters(colId: string) {
props.table.getColumn(colId)?.setFilterValue([]);
}
function removeAllFilters() {
props.table.resetColumnFilters();
}
function removeValFromColumnFilter(col: ColumnFilter, val: string) {
const newFilter = (col.value as Array<string>).toSpliced(
(col.value as Array<string>).indexOf(val),
1,
);
props.table.getColumn(col.id)?.setFilterValue(newFilter);
}
</script>

<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
class="ml-auto hidden h-8 lg:flex"
:disabled="activeFilterColumns.length === 0"
size="sm"
variant="outline"
>
<Filter class="mr-2 size-4" />
Active Filters
<Badge class="ml-2" variant="outline">{{ activeFilterColumns.length }}</Badge>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="max-h-[350px] w-72 max-w-none overflow-y-auto">
<DropdownMenuLabel class="flex items-center justify-between"
><span>Remove filters</span
><Button
class="ml-2 flex h-8 gap-1"
:disabled="activeFilterColumns.length === 0"
size="sm"
variant="outline"
@click="removeAllFilters()"
>
<X class="size-4 align-middle hover:scale-125"></X><span>Remove all</span></Button
></DropdownMenuLabel
>
<DropdownMenuSeparator />
<div
v-for="col in activeFilterColumns"
:key="col.id"
class="flex justify-between p-2 text-sm"
>
<span
>{{ props.table.getColumn(col.id)?.columnDef.header }}
<Badge
v-for="val in col.value"
:key="val"
class="mx-0.5 cursor-pointer"
title="Remove"
variant="secondary"
@click="removeValFromColumnFilter(col, val)"
>{{ val }}</Badge
> </span
><button @click="removeFilters(col.id)">
<X class="size-4 hover:scale-125"></X><span class="sr-only">Remove filter</span>
</button>
</div>
<span v-if="activeFilterColumns.length === 0" class="m-2 text-sm text-neutral-600"
>No filters active.</span
>
</DropdownMenuContent>
</DropdownMenu>
</template>
43 changes: 43 additions & 0 deletions components/data-table/data-table-faceted-filter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { Column } from "@tanstack/vue-table";
import { Filter } from "lucide-vue-next";
const props = defineProps<{
column: Column<never, unknown>;
}>();
const facets = computed(() =>
[...props.column.getFacetedUniqueValues()]?.sort((a, b) => b[1] - a[1]),
);
const selectedValues = computed(() => new Set(props.column?.getFilterValue() as Array<string>));
</script>

<template>
<DropdownMenu>
<DropdownMenuTrigger class="group p-1 align-middle"
><Filter
class="size-4 group-hover:scale-125"
:class="selectedValues.size > 0 && 'fill-white'"
></Filter
></DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuCheckboxItem
v-for="facet in facets"
:key="facet[0]"
:checked="selectedValues.has(facet[0])"
@update:checked="
(checked) => {
if (!checked) {
selectedValues.delete(facet[0]);
} else {
selectedValues.add(facet[0]);
}
column?.setFilterValue([...selectedValues]);
}
"
>
{{ facet[0] }}
<Badge class="ml-2" variant="outline">{{ facet[1] }}</Badge>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
131 changes: 131 additions & 0 deletions components/data-table/data-table-filter-columns.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<script setup lang="ts">
import type { Column, Table } from "@tanstack/vue-table";
import { ChevronDown, LayoutList, ListChecks, ListTodo, TableProperties } from "lucide-vue-next";
import type { Component } from "vue";
const props = defineProps<{
table: Table<never>;
}>();
const columns = computed(() => props.table.getAllColumns().filter((column) => column.getCanHide()));
// Source: https://stackoverflow.com/a/64489760
function titleCase(s: string) {
return s
.replace(/^[-_]*(.)/, (_, c) => c.toUpperCase()) // Initial char (after -/_)
.replace(/[-_]+(.)/g, (_, c) => " " + c.toUpperCase()); // First char after each -/_
}
type visibilityState = "ALL_VISIBLE" | "SOME_VISIBLE" | "NONE_VISIBLE";
function getVisibilityState(category: Column<never>) {
const currentState: visibilityState = category.columns.every((c) => c.getIsVisible())
? "ALL_VISIBLE"
: category.columns.some((c) => c.getIsVisible())
? "SOME_VISIBLE"
: "NONE_VISIBLE";
return currentState;
}
function toggleCategory(category: Column<never>) {
let targetVisibility = true;
switch (getVisibilityState(category)) {
case "ALL_VISIBLE":
targetVisibility = false;
break;
default:
targetVisibility = true;
}
category.columns.forEach((c) => {
if (c.getIsVisible() !== targetVisibility) {
c.setFilterValue([]);
c.toggleVisibility(targetVisibility);
}
});
}
function getAllColumnsVisibilityState() {
if (props.table.getIsAllColumnsVisible()) return "ALL_VISIBLE";
const visibleColumns = props.table.getVisibleLeafColumns();
const hidableColumns = props.table.getAllLeafColumns().filter((c) => !c.getCanHide());
return visibleColumns.length > hidableColumns.length ? "SOME_VISIBLE" : "NONE_VISIBLE";
}
function toggleAllCategories() {
let targetVisibility = true;
switch (getAllColumnsVisibilityState()) {
case "ALL_VISIBLE":
targetVisibility = false;
break;
default:
targetVisibility = true;
}
if (!targetVisibility) props.table.resetColumnFilters(true);
props.table.toggleAllColumnsVisible(targetVisibility);
}
const isCollapsibleOpen = ref(columns.value.map(() => false));
const visibilityToIcon: Record<visibilityState, Component> = {
ALL_VISIBLE: ListChecks,
SOME_VISIBLE: ListTodo,
NONE_VISIBLE: LayoutList,
};
</script>

<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button class="ml-auto hidden h-8 lg:flex" size="sm" variant="outline">
<TableProperties class="mr-2 size-4" />
Features
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="max-h-[350px] w-72 max-w-none overflow-y-auto">
<DropdownMenuLabel class="flex items-center justify-between"
><span>Select features</span
><Button class="ml-2 h-8" size="sm" variant="outline" @click.stop="toggleAllCategories()"
><component
:is="visibilityToIcon[getAllColumnsVisibilityState()]"
class="mr-2 size-4 align-middle"
></component
><span class="align-middle">{{
getAllColumnsVisibilityState() === "ALL_VISIBLE" ? "Deselect all" : "Select all"
}}</span></Button
></DropdownMenuLabel
>
<DropdownMenuSeparator />
<div v-for="(group, idx) in columns" :key="group.id">
<Collapsible v-slot="{ open }" v-model:open="isCollapsibleOpen[idx]">
<CollapsibleTrigger class="flex w-full items-center gap-1 p-2 text-sm">
<Button class="mr-2 p-2" variant="outline" @click.stop="toggleCategory(group)"
><component
:is="visibilityToIcon[getVisibilityState(group)]"
class="size-4"
></component
></Button>
<span>{{ titleCase(group.id) }}</span>
<ChevronDown class="size-4" :class="open ? 'rotate-180' : ''"></ChevronDown>
</CollapsibleTrigger>

<CollapsibleContent class="">
<DropdownMenuCheckboxItem
v-for="column in group.columns"
:key="column.id"
:checked="column.getIsVisible()"
@select.prevent
@update:checked="
(value) => {
column.toggleVisibility(!!value);
column.setFilterValue([]);
}
"
>
{{ column.columnDef.header }}
</DropdownMenuCheckboxItem>
</CollapsibleContent>
<DropdownMenuSeparator />
</Collapsible>
</div>
</DropdownMenuContent>
</DropdownMenu>
</template>
69 changes: 54 additions & 15 deletions components/data-table/data-table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,50 @@
import {
type ColumnDef,
type ColumnFiltersState,
type FilterFn,
FlexRender,
getCoreRowModel,
getFacetedRowModel,
getFilteredRowModel,
getPaginationRowModel,
useVueTable,
type VisibilityState,
} from "@tanstack/vue-table";
const emit = defineEmits(["table-ready", "columnFiltersChange", "globalFilterChange"]);
import customFacetedUniqueValues from "@/utils/customFacetedUniqueValues";
const emit = defineEmits([
"table-ready",
"columnFiltersChange",
"globalFilterChange",
"columnVisibilityChange",
]);
interface Props {
items: Array<never>;
columns: Array<ColumnDef<never>>;
minHeaderDepth?: number;
enableFilterOnColumns?: boolean;
initialColumnVisibility?: Record<string, boolean>;
globalFilterFn?: FilterFn<never>;
}
const props = defineProps<Props>();
const { items, columns } = toRefs(props);
const { items, columns, initialColumnVisibility } = toRefs(props);
const columnFilters = ref<ColumnFiltersState>([]);
const globalFilter = ref("");
const columnVisibility = ref<VisibilityState>({
label: true,
person: false,
age: false,
sex: false,
type: true,
region: true,
settlement: true,
date: true,
respPerson: true,
...initialColumnVisibility.value,
});
const table = useVueTable({
get data() {
return items.value;
Expand All @@ -29,17 +54,8 @@ const table = useVueTable({
return columns.value;
},
initialState: {
columnVisibility: {
label: true,
person: false,
age: false,
sex: false,
type: true,
region: true,
settlement: true,
date: true,
respPerson: true,
},
columnVisibility: columnVisibility.value,
globalFilter: globalFilter.value,
},
state: {
get columnFilters() {
Expand All @@ -48,6 +64,9 @@ const table = useVueTable({
get globalFilter() {
return globalFilter.value;
},
get columnVisibility() {
return columnVisibility.value;
},
},
onColumnFiltersChange: (updaterOrValue) => {
columnFilters.value =
Expand All @@ -59,9 +78,19 @@ const table = useVueTable({
typeof updaterOrValue === "function" ? updaterOrValue(globalFilter.value) : updaterOrValue;
emit("globalFilterChange", globalFilter.value);
},
onColumnVisibilityChange: (updaterOrValue) => {
columnVisibility.value =
typeof updaterOrValue === "function"
? updaterOrValue(columnVisibility.value)
: updaterOrValue;
emit("columnVisibilityChange", table);
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: customFacetedUniqueValues,
globalFilterFn: props.globalFilterFn,
});
onMounted(() => {
Expand All @@ -72,9 +101,19 @@ onMounted(() => {
<template>
<Table>
<TableHeader class="bg-primary font-bold text-on-primary">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableRow
v-for="headerGroup in table
.getHeaderGroups()
.filter((header) => header.depth >= (props.minHeaderDepth ?? 0))"
:key="headerGroup.id"
class="hover:bg-primary"
>
<TableHead v-for="header in headerGroup.headers" :key="header.id">
{{ header.column.columnDef.header }}
<DataTableFacetedFilter
v-if="enableFilterOnColumns && header.column.getCanFilter()"
:column="header.column"
></DataTableFacetedFilter>
</TableHead>
</TableRow>
</TableHeader>
Expand Down
2 changes: 1 addition & 1 deletion components/geo-map.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const addNearbyDataPopup = function (marker: LeafletMarker) {
if (nearbyMarkerData.length > 1) {
const markers = nearbyMarkerData.sort((a, b) => {
return a.properties!.label.localeCompare(b.properties!.label);
return a.properties!.label?.localeCompare(b.properties!.label);
});
// @todo determine whether grouping is needed based on the number of
Expand Down
Loading

0 comments on commit 2b3d28d

Please sign in to comment.