Visitenbuch/src/lib/components/filter/FilterBar.svelte
ThetaDev 04d9883c96
Some checks failed
Visitenbuch CI / test (push) Failing after 4m26s
Visitenbuch CI / release (push) Has been skipped
feat: make page printable
2024-05-14 02:13:44 +02:00

274 lines
7.5 KiB
Svelte

<script lang="ts">
import { mdiClose } from "@mdi/js";
import { Debouncer } from "$lib/shared/util";
import IconButton from "$lib/components/ui/IconButton.svelte";
import Autocomplete from "./Autocomplete.svelte";
import EntryFilterChip from "./FilterChip.svelte";
import SavedFilters from "./SavedFilters.svelte";
import type {
FilterDef,
FilterQdata,
FilterData,
BaseItem,
SelectionOrText,
} from "./types";
import { InputType, isFilterValueless } from "./types";
/** Filter definitions */
export let FILTERS: Record<string, FilterDef>;
/** Filter data from the query */
export let filterData: FilterQdata | null | undefined;
/** Callback when filters are updated */
export let onUpdate: (filterData: FilterQdata | undefined) => void = () => {};
/** List of hidden filter IDs, can be specified for prefiltered views (e.g. by patient) */
export let hiddenFilters: string[] = [];
/** True if a separate search field should be displayed */
export let search = false;
export let view: string | undefined = undefined;
let autocomplete: Autocomplete<BaseItem> | undefined;
let activeFilters: FilterData[] = [];
const cache: Record<string, BaseItem[]> = {};
let searchVal = "";
const searchDebounce = new Debouncer(400, () => {
onUpdate(getFilterQdata());
});
// Filter items to be displayed in the autocomplete menu
let filterMenuItems: BaseItem[];
$: filterMenuItems = Object.values(FILTERS).flatMap((f) => {
if (f.toggleOff) {
return [
f,
{
id: f.id, name: f.toggleOff.name, icon: f.toggleOff.icon, toggle: false,
},
];
}
return [f];
});
// Filter menu items to be hidden
$: hiddenIds = new Set([
...Object.values(FILTERS).flatMap((f) => {
return f.inputType === InputType.FilterList
|| activeFilters.every((af) => af.id !== f.id)
? []
: [f.id];
}),
...hiddenFilters,
]);
// Load query data if present
$: if (filterData || !filterData) {
updateFromQueryData(filterData ?? {});
}
function updateFromQueryData(fd: FilterQdata): void {
const filters: FilterData[] = [];
for (const [id, value] of Object.entries(fd)) {
// If filter is hidden or undefined, dont display it
if (hiddenFilters.includes(id) || !FILTERS[id]) continue;
// Extract search parameter if a separate search field is used
if (search && id === "search") {
searchVal = value.toString();
} else if (Array.isArray(value)) {
value.forEach((v) => {
filters.push({
id,
selection: { id: v.id, name: v.name },
editing: false,
});
});
} else {
const selection: SelectionOrText = {};
if (typeof value === "string") selection.name = value;
else if (typeof value === "boolean") selection.toggle = value;
else selection.id = value;
filters.push({ id, selection, editing: false });
}
}
activeFilters = filters;
}
/** Get a list of item IDs to hide for the given filter
* This ensures that a filter item cannot be selected twice
*/
function getHiddenIds(fid: string, fpos: number): Set<string | number> {
if (FILTERS[fid].inputType === InputType.FilterList) {
return new Set(
activeFilters.flatMap((f, i) => {
return i !== fpos && f.id === fid && f.selection?.id ? [f.selection?.id] : [];
}),
);
}
return new Set();
}
function focusInput(): void {
if (autocomplete) autocomplete.open();
}
function getFilterQdata(): FilterQdata | undefined {
const fd: FilterQdata = {};
activeFilters.forEach((fdata) => {
const filter = FILTERS[fdata.id];
const key = filter.id;
let val = null;
if (filter.inputType === InputType.None) {
// Valueless filter (val = true)
val = true;
} else if (filter.inputType === InputType.FreeText) {
// Text input
val = fdata.selection?.name;
} else if (filter.inputType === InputType.FilterList && fdata.selection) {
// Filter list
val = [{ id: fdata.selection.id!, name: fdata.selection.name }];
} else if (filter.inputType === InputType.Boolean) {
val = Boolean(fdata.selection?.toggle);
}
if (val !== null && val !== undefined) {
if (filter.inputType === InputType.FilterList) {
// @ts-expect-error fd[key] is checked
if (Array.isArray(fd[key])) fd[key].push(...val);
else fd[key] = val;
} else {
fd[key] = val;
}
}
});
if (searchVal) {
fd.search = searchVal;
}
if (Object.keys(fd).length === 0) return undefined;
return fd;
}
function addFilter(item: SelectionOrText): boolean {
if (!item.id) return false;
const valueless = isFilterValueless(FILTERS[item.id].inputType);
let selection = null;
if (FILTERS[item.id].inputType === InputType.Boolean) {
selection = { toggle: item.toggle ?? true };
}
activeFilters.push({ id: item.id.toString(), selection, editing: !valueless });
activeFilters = activeFilters;
if (valueless) updateFilter();
// Returns true if the main autocomplete should be closed
return !valueless;
}
function removeFilter(i: number): void {
const shouldUpdate = isFilterValueless(FILTERS[activeFilters[i].id].inputType)
|| activeFilters[i].selection !== null;
activeFilters.splice(i, 1);
activeFilters = activeFilters;
if (shouldUpdate) updateFilter();
}
function updateFilter(): void {
onUpdate(getFilterQdata());
}
function onSearchInput(e: Event): void {
searchDebounce.trigger();
}
function onSearchKeypress(e: KeyboardEvent): void {
if (e.key === "Enter") {
searchDebounce.now();
}
}
</script>
<div class="filterbar-outer">
<div class="filterbar-inner input input-sm input-bordered">
{#each activeFilters as fdata, i}
<EntryFilterChip
{cache}
{fdata}
filter={FILTERS[fdata.id]}
hiddenIds={() => getHiddenIds(fdata.id, i)}
onRemove={() => removeFilter(i)}
onSelection={(sel, kb) => {
updateFilter();
if (kb) focusInput();
}}
/>
{/each}
<Autocomplete
bind:this={autocomplete}
cls="mr-8"
{hiddenIds}
items={filterMenuItems}
onBackspace={() => {
activeFilters.pop();
activeFilters = activeFilters;
updateFilter();
}}
onSelect={(item) => {
const close = addFilter(item);
return { newValue: "", close };
}}
partOfFilterbar
placeholder="Filter"
/>
<IconButton
cls="absolute bottom-0 right-0"
path={mdiClose}
title="Alle Filter entfernen"
on:click={() => {
activeFilters = [];
searchVal = "";
updateFilter();
}} />
</div>
{#if search}
<input
class="input input-sm input-bordered"
placeholder="Suche"
type="text"
bind:value={searchVal}
on:input={onSearchInput}
on:keypress={onSearchKeypress}
/>
{/if}
<slot />
</div>
{#if view}
<SavedFilters {view} />
{/if}
<style lang="postcss">
.filterbar-outer {
@apply flex flex-wrap w-full items-start justify-center gap-2 relative;
}
.filterbar-inner {
@apply flex flex-wrap flex-grow items-stretch h-min p-0 gap-2 relative;
:global(input) {
height: 32px;
}
}
@media print {
.filterbar-outer {
display: none;
}
}
</style>