274 lines
7.5 KiB
Svelte
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>
|