Skip to content

Commit

Permalink
Adapt to feature taxonomy (#198)
Browse files Browse the repository at this point in the history
Features are grouped by subcategories and categories. This hierarchy is
now represented in the feature selection dropdown menus.
Closes #195
  • Loading branch information
simar0at authored Dec 9, 2024
2 parents 234b7a9 + 2991b89 commit b0a7741
Show file tree
Hide file tree
Showing 3 changed files with 208 additions and 123 deletions.
65 changes: 43 additions & 22 deletions components/data-table/data-table-filter-columns.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ function toggleAllCategories() {
}
const isCollapsibleOpen = ref(columns.value.map(() => false));
const isSubCollapsibleOpen = ref(
Object.fromEntries(
columns.value!.map((category) => [category.id, category.columns.map(() => false)]),
),
);
const visibilityToIcon: Record<visibilityState, Component> = {
ALL_VISIBLE: ListChecks,
Expand Down Expand Up @@ -94,34 +99,50 @@ const visibilityToIcon: Record<visibilityState, Component> = {
></DropdownMenuLabel
>
<DropdownMenuSeparator />
<div v-for="(group, idx) in columns" :key="group.id">
<div v-for="(category, idx) in columns" :key="category.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>
<CollapsibleTrigger class="flex w-full items-center justify-between gap-1 p-2 text-sm">
<span>{{ titleCase(category.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([]);
}
"
<Collapsible
v-for="(subcategory, subcatIdx) in category.columns"
:key="subcategory.id"
v-slot="{ open: subcatOpen }"
v-model:open="isSubCollapsibleOpen[category.id]![subcatIdx]"
>
{{ column.columnDef.header }}
</DropdownMenuCheckboxItem>
<CollapsibleTrigger class="flex w-full items-center gap-1 p-2 text-left text-sm">
<Button class="mr-2 p-2" variant="outline" @click.stop="toggleCategory(subcategory)"
><component
:is="visibilityToIcon[getVisibilityState(subcategory)]"
class="size-4"
></component
></Button>
<span>{{ titleCase(subcategory.id) }}</span>
<ChevronDown
class="size-4 shrink-0 grow-0"
:class="subcatOpen ? 'rotate-180' : ''"
></ChevronDown>
</CollapsibleTrigger>
<CollapsibleContent class="">
<DropdownMenuCheckboxItem
v-for="column in subcategory.columns"
:key="column.id"
:checked="column.getIsVisible()"
@select.prevent
@update:checked="
(value) => {
column.toggleVisibility(!!value);
column.setFilterValue([]);
}
"
>
{{ column.columnDef.header }}
</DropdownMenuCheckboxItem>
</CollapsibleContent>
</Collapsible>
</CollapsibleContent>
<DropdownMenuSeparator />
</Collapsible>
Expand Down
93 changes: 63 additions & 30 deletions components/geojson-map-toolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,21 @@ function titleCase(s: string) {
.replace(/^[-_]*(.)/, (_, c) => c.toUpperCase()) // Initial char (after -/_)
.replace(/[-_]+(.)/g, (_, c) => " " + c.toUpperCase()); // First char after each -/_
}
const isCollapsibleOpen = ref(categories.value!.map(() => false));
const isMenuOpen = ref(categories.value!.map(() => false));
const isCollapsibleOpen = ref(
Object.fromEntries(
categories.value!.map((category) => [category.id, category.columns.map(() => false)]),
),
);
const { colors, addColor, setColor } = useColorsStore();
</script>

<template>
<div class="grid items-center border-b border-border bg-surface px-8 py-3 text-on-surface">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm font-medium text-on-surface/75">
<div v-for="(group, idx) in categories" :key="group.id">
<DropdownMenu v-slot="{ open }" v-model:open="isCollapsibleOpen[idx]">
<div v-for="(group, catIdx) in categories" :key="group.id">
<DropdownMenu v-slot="{ open }" v-model:open="isMenuOpen[catIdx]">
<DropdownMenuTrigger class="flex w-full items-center gap-1 p-2 text-sm">
<span>{{ titleCase(group.id) }}</span>
<Badge
Expand All @@ -47,36 +52,64 @@ const { colors, addColor, setColor } = useColorsStore();
</DropdownMenuTrigger>

<DropdownMenuContent class="">
<DropdownMenuCheckboxItem
v-for="column in group.columns"
:key="column.id"
:checked="column.getIsVisible()"
@select.prevent
@update:checked="
(value) => {
column.toggleVisibility(!!value);
column.setFilterValue([]);
if (!colors.has(column.id)) addColor(column.id);
}
"
<Collapsible
v-for="(subcategory, subcatIdx) in group.columns"
:key="subcategory.id"
v-slot="{ open: subcatOpen }"
v-model:open="isCollapsibleOpen[group.id]![subcatIdx]"
>
<span class="flex-1">{{ column.columnDef.header }}</span>
<label v-if="column.getIsVisible()" class="grow-0 basis-0 p-0">
<input
class="size-5"
type="color"
:value="colors.get(column.id)?.colorCode || '#cccccc'"
@click.capture.stop
@input="
(event) => {
//@ts-expect-error target.value not recognized
setColor({ id: column.id, colorCode: event.target!.value });
<CollapsibleTrigger
class="flex w-full items-center justify-between gap-1 p-2 text-left text-sm"
>
<span>{{ titleCase(subcategory.id) }}</span>
<Badge
v-if="
subcategory.columns.length > 0 &&
subcategory.getLeafColumns().filter((c) => c.getIsVisible()).length
"
variant="outline"
>{{ subcategory.getLeafColumns().filter((c) => c.getIsVisible()).length }}</Badge
>

<ChevronDown
class="size-4 shrink-0 grow-0"
:class="subcatOpen ? 'rotate-180' : ''"
></ChevronDown>
</CollapsibleTrigger>

<CollapsibleContent class="">
<DropdownMenuCheckboxItem
v-for="column in subcategory.columns"
:key="column.id"
:checked="column.getIsVisible()"
@select.prevent
@update:checked="
(value) => {
column.toggleVisibility(!!value);
column.setFilterValue([]);
if (!colors.has(column.id)) addColor(column.id);
}
"
/>
<span class="sr-only">Select color</span>
</label>
</DropdownMenuCheckboxItem>
>
<span class="flex-1">{{ column.columnDef.header }}</span>
<label v-if="column.getIsVisible()" class="grow-0 basis-0 p-0">
<input
class="size-5"
type="color"
:value="colors.get(column.id)?.colorCode || '#cccccc'"
@click.capture.stop
@input="
(event) => {
//@ts-expect-error target.value not recognized
setColor({ id: column.id, colorCode: event.target!.value });
}
"
/>
<span class="sr-only">Select color</span>
</label>
</DropdownMenuCheckboxItem>
</CollapsibleContent>
</Collapsible>
</DropdownMenuContent>
</DropdownMenu>
</div>
Expand Down
173 changes: 102 additions & 71 deletions components/geojson-table-window-content.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type CellContext,
type ColumnDef,
createColumnHelper,
type GroupColumnDef,
type Row,
type Table,
} from "@tanstack/vue-table";
Expand All @@ -16,80 +17,110 @@ const url = "https://raw.githubusercontent.com/wibarab/wibarab-data/main/wibarab
const { isPending } = GeojsonStore.fetchGeojson(url);
const { fetchedData, tables } = storeToRefs(GeojsonStore);
const { data: projectData } = useProjectInfo();
const columnHelper = createColumnHelper();
const columns = computed(() => {
const columnHeadings = fetchedData.value.get(url)?.properties.column_headings;
const categories = [...new Set(columnHeadings?.flatMap((heading) => heading.category))];
const groupedColumns = categories
.map((categoryName: string | undefined) => {
switch (typeof categoryName) {
case "string":
return columnHelper.group({
header: categoryName,
//@ts-expect-error type mismatch in accessorFn
columns: columnHeadings
?.filter((heading) => heading.category === categoryName)
.map((heading) => {
return {
id: Object.keys(heading).find((key) => /ft_*/.test(key)) ?? "",
header: heading[Object.keys(heading).find((key) => /ft_*/.test(key)) ?? ""],
cell: (cell: CellContext<FeatureType, never>) => {
return h(resolveComponent("GeojsonTablePropertyCell"), {
value: cell.row.original.properties[cell.column.columnDef.id!],
});
},
accessorFn: (cell: FeatureType) => {
return Object.keys(
cell.properties[
String(Object.keys(heading).find((key) => /ft_*/.test(key)) ?? "")
] ?? {},
);
},
filterFn: (row, columnId, filterValue) => {
if (!row.getVisibleCells().find((cell) => cell.column.id === columnId)) {
return true;
}
if (Object.keys(filterValue).length === 0) return true;
const filter = Object.values(filterValue).some((val) =>
(row.getValue(columnId) as Array<string>).includes(String(val)),
);
return filter;
},
enableGlobalFilter: true,
};
}),
});
default:
return columnHelper.group({
header: "-",
enableHiding: false,
//@ts-expect-error type mismatch in accessorFn
columns: columnHeadings
?.filter((heading) => heading.category === categoryName)
.map((heading) => {
return {
id: Object.keys(heading)[0],
header: Object.values(heading)[0],
enableHiding: false,
cell: ({ cell }: CellContext<FeatureType, never>) => {
return h(
"span",
{ class: "max-w-[500px] truncate font-medium" },
cell.row.original.properties[cell.column.columnDef.id!],
);
},
accessorFn: (cell: FeatureType) => {
return cell.properties[String(Object.keys(heading)[0])];
},
enableColumnFilter: false,
enableGlobalFilter: true,
};
}),
});
}
})
const categories = projectData.value!.projectConfig?.staticData?.table?.[1] as Record<
string,
Record<string, unknown>
>;
const topLevelColumns = [
{
header: "-",
enableHiding: false,
columns: [] as Array<GroupColumnDef<unknown>>,
},
...Object.keys(categories).map((categoryName) => {
return {
header: categoryName,
columns: [] as Array<GroupColumnDef<unknown>>,
};
}),
];
topLevelColumns.forEach((col) => {
let subcategoryColumns;
if (col.header in categories) {
subcategoryColumns = Object.entries(
categories[col.header]?.subcategories ?? { [col.header]: categories[col.header]?.title },
).map(([categoryName, categoryLabel]) => {
return columnHelper.group({
header: String(categoryLabel),
id: String(categoryName),
//@ts-expect-error type mismatch in accessorFn
columns: columnHeadings
.filter((heading) => heading.category === categoryName)
.map((heading) => {
return {
id: Object.keys(heading).find((key) => /ft_*/.test(key)) ?? "",
header: heading[Object.keys(heading).find((key) => /ft_*/.test(key)) ?? ""],
cell: (cell: CellContext<FeatureType, never>) => {
return h(resolveComponent("GeojsonTablePropertyCell"), {
value: cell.row.original.properties[cell.column.columnDef.id!],
});
},
accessorFn: (cell: FeatureType) => {
return Object.keys(
cell.properties[
String(Object.keys(heading).find((key) => /ft_*/.test(key)) ?? "")
] ?? {},
);
},
filterFn: (row, columnId, filterValue) => {
if (!row.getVisibleCells().find((cell) => cell.column.id === columnId)) {
return true;
}
if (Object.keys(filterValue).length === 0) return true;
const filter = Object.values(filterValue).some((val) =>
(row.getValue(columnId) as Array<string>).includes(String(val)),
);
return filter;
},
enableGlobalFilter: true,
};
}),
});
});
} else {
subcategoryColumns = [
columnHelper.group({
header: "-",
id: "-",
enableHiding: false,
//@ts-expect-error type mismatch in accessorFn
columns: columnHeadings
.filter((heading) => !heading.category)
.map((heading) => {
return {
id: Object.keys(heading)[0],
header: Object.values(heading)[0],
enableHiding: false,
cell: ({ cell }: CellContext<FeatureType, never>) => {
return h(
"span",
{ class: "max-w-[500px] truncate font-medium" },
cell.row.original.properties[cell.column.columnDef.id!],
);
},
accessorFn: (cell: FeatureType) => {
return cell.properties[String(Object.keys(heading)[0])];
},
enableColumnFilter: false,
enableGlobalFilter: true,
};
}),
}),
];
}
col.columns = subcategoryColumns as Array<GroupColumnDef<unknown>>;
});
const groupedColumns = topLevelColumns
.filter((col) => col.columns.some((col) => (col.columns?.length ?? -1) > 0))
.map((col) => columnHelper.group(col))
.sort((a, b) => String(a.header).localeCompare(String(b.header)));
return groupedColumns;
});
Expand Down Expand Up @@ -170,7 +201,7 @@ function registerTable(table: Table<FeatureType>) {
:global-filter-fn="filterEmptyRows"
:initial-column-visibility="columnVisibility"
:items="fetchedData.get(url)?.features as Array<never>"
:min-header-depth="1"
:min-header-depth="2"
@column-visibility-change="applyGlobalFilter"
@table-ready="registerTable"
></DataTable>
Expand Down

0 comments on commit b0a7741

Please sign in to comment.