Skip to content
255 changes: 235 additions & 20 deletions src/lib/components/filters/parsedTagList.svelte
Original file line number Diff line number Diff line change
@@ -1,35 +1,246 @@
<script lang="ts">
import { Icon, Layout, Tag, Tooltip } from '@appwrite.io/pink-svelte';
import { queries, tagFormat, tags } from './store';
import {
Icon,
Layout,
Tooltip,
CompoundTagRoot,
CompoundTagChild,
Typography,
ActionMenu,
Selector
} from '@appwrite.io/pink-svelte';
import { capitalize } from '$lib/helpers/string';
import { queries, tags } from './store';
import { IconX } from '@appwrite.io/pink-icons-svelte';
import { parsedTags } from './setFilters';
import { parsedTags, type ParsedTag } from './setFilters';
import { Button } from '$lib/elements/forms';
import type { Column } from '$lib/helpers/types';
import { writable, type Writable } from 'svelte/store';
import Menu from '$lib/components/menu/menu.svelte';
import { addFilterAndApply, buildFilterCol, type FilterData } from './quickFilters';
import QuickFilters from '$lib/components/filters/quickFilters.svelte';
import { isSmallViewport } from '$lib/stores/viewport';

let {
columns = writable([]),
analyticsSource = ''
}: { columns?: Writable<Column[]>; analyticsSource?: string } = $props();

function parseTagParts(tagString: string): { text: string; operator: boolean }[] {
return tagString
.split(/\*\*(.*?)\*\*/)
.map((part, index) => {
// Even indices are outside bold (operators), odd indices are inside bold (values)
if (index % 2 === 0) {
return part
.split(/\s+/)
.filter(Boolean)
.map((t) => ({ text: t, operator: true }));
} else {
return [{ text: part, operator: false }];
}
})
.flat()
.filter((p) => Boolean(p.text));
}

function getFilterFor(title: string): FilterData | null {
if (!columns) return null;
const col = ($columns as unknown as Column[]).find((c) => c.title === title);
if (!col) return null;
const filter = buildFilterCol(col);
return filter ?? null;
}

// Build available filter definitions from provided columns
let availableFilters = $derived(
($columns as unknown as Column[] | undefined)?.length
? (($columns as unknown as Column[])
.map((c) => (c.filter !== false ? buildFilterCol(c) : null))
.filter((f) => f && f.options) as FilterData[])
: []
);

// QuickFilters uses the same filters list
let filterCols = $derived(availableFilters);

// Always-show placeholders are derived from available filters (no hardcoding)
// Use reactive array so runes can track changes
let hiddenPlaceholders: string[] = $state([]);

let activeTitles = $derived(
($parsedTags || []).map((t) => (t as ParsedTag).title).filter(Boolean) as string[]
);

// Compute current placeholders (major filters not already active or dismissed)
let placeholders = $derived(
availableFilters
.filter((f) => !activeTitles.includes(f.title))
.filter((f) => !hiddenPlaceholders.includes(f.title))
);
</script>

{#if $parsedTags?.length}
<Layout.Stack direction="row" gap="s" wrap="wrap" alignItems="center" inline>
<Layout.Stack direction="row" gap="s" wrap="wrap" alignItems="center" inline>
{#if $parsedTags?.length}
{#each $parsedTags as tag (tag.tag)}
<span>
<Tooltip
disabled={Array.isArray(tag.value) ? tag.value?.length < 3 : true}
maxWidth="600px">
<Tag
size="s"
on:click={() => {
const t = $tags.filter((t) => t.tag.includes(tag.tag.split(' ')[0]));
t.forEach((t) => (t ? queries.removeFilter(t) : null));
queries.apply();
parsedTags.update((tags) => tags.filter((t) => t.tag !== tag.tag));
}}>
{#key tag.tag}
<span use:tagFormat>{tag.tag}</span>
{/key}
<Icon icon={IconX} size="s" slot="end" />
</Tag>
<CompoundTagRoot size="s">
{@const parts = parseTagParts(tag.tag)}
{@const property = (tag as ParsedTag).title}

{#each parts as part}
<CompoundTagChild>
<Menu>
<span>
{#if part.operator}
<Typography.Text color="--fgcolor-neutral-secondary"
>{part.text}</Typography.Text>
{:else}
<Typography.Text
variant="m-500"
color="--fgcolor-neutral-secondary"
>{part.text
.split(' or ')
.map((t) => capitalize(t))
.join(' or ')}</Typography.Text>
{/if}
</span>
<svelte:fragment slot="menu">
{#if property}
{@const filter = getFilterFor(property)}
{#if filter}
{@const isArray = filter?.array}
{@const selectedArray = Array.isArray(tag.value)
? tag.value
: []}
{#each filter.options as option (filter.title + option.value + option.label)}
<ActionMenu.Root>
<ActionMenu.Item.Button
on:click={() => {
if (isArray) {
const exists =
selectedArray.includes(
option.value
);
const next = exists
? selectedArray.filter(
(v) =>
v !== option.value
)
: [
...selectedArray,
option.value
];
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
null,
next,
$columns,
analyticsSource
);
} else {
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
option.value,
[],
$columns,
analyticsSource
);
}
}}>
<Layout.Stack direction="row" gap="s">
{#if isArray}
<Selector.Checkbox
checked={selectedArray.includes(
option.value
)}
size="s" />
{/if}
{capitalize(option.label)}
</Layout.Stack>
</ActionMenu.Item.Button>
</ActionMenu.Root>
{/each}
{/if}
{/if}
</svelte:fragment>
</Menu>
</CompoundTagChild>
{/each}
<CompoundTagChild
dismiss
on:click={() => {
const t = $tags.filter((t) =>
t.tag.includes((tag as ParsedTag).title)
);
t.forEach((t) => (t ? queries.removeFilter(t) : null));
queries.apply();
parsedTags.update((tags) => tags.filter((t) => t.tag !== tag.tag));
}}>
<Icon icon={IconX} size="s" />
</CompoundTagChild>
</CompoundTagRoot>
<span slot="tooltip">{tag?.value?.toString()}</span>
</Tooltip>
</span>
{/each}
{/if}

<!-- Always render remaining placeholder tags alongside active tags -->
{#if placeholders?.length}
{#each placeholders as filter (filter.title + filter.id)}
<span>
<Menu>
<CompoundTagRoot size="s">
<CompoundTagChild>
<span>{capitalize(filter.title)}</span>
</CompoundTagChild>
<CompoundTagChild
dismiss
on:click={(e) => {
e.stopPropagation();
if (!hiddenPlaceholders.includes(filter.title)) {
hiddenPlaceholders = [...hiddenPlaceholders, filter.title];
}
}}>
<Icon icon={IconX} size="s" />
</CompoundTagChild>
</CompoundTagRoot>
<svelte:fragment slot="menu">
{#if filter.options}
{#each filter.options as option (filter.title + option.value + option.label)}
<ActionMenu.Root>
<ActionMenu.Item.Button
on:click={() => {
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
filter?.array ? null : option.value,
filter?.array ? [option.value] : [],
$columns,
analyticsSource
);
}}>
{capitalize(option.label)}
</ActionMenu.Item.Button>
</ActionMenu.Root>
{/each}
{/if}
</svelte:fragment>
</Menu>
</span>
{/each}
{/if}

{#if $parsedTags?.length}
<Button
size="s"
text
Expand All @@ -38,5 +249,9 @@
queries.apply();
parsedTags.set([]);
}}>Clear all</Button>
</Layout.Stack>
{/if}
{/if}

{#if filterCols?.length && !$isSmallViewport}
<QuickFilters {columns} {analyticsSource} {filterCols} />
{/if}
</Layout.Stack>
9 changes: 6 additions & 3 deletions src/lib/components/filters/quickFilters.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@
</script>

<Menu>
<Button secondary badge={$parsedTags?.length ? `${$parsedTags.length}` : undefined}>
<Icon icon={IconFilterLine} slot="start" size="s" />
Filters
<Button
ariaLabel="Filters"
text
icon
badge={$parsedTags?.length ? `${$parsedTags.length}` : undefined}>
<Icon icon={IconFilterLine} size="s" />
</Button>
<svelte:fragment slot="menu">
{#each filterCols.filter((f) => f?.options) as filter (filter.title + filter.id)}
Expand Down
31 changes: 20 additions & 11 deletions src/lib/components/filters/setFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { get, writable } from 'svelte/store';
import { type FilterData } from './quickFilters';
import { tags, type TagValue } from './store';

export const parsedTags = writable<TagValue[]>([]);
export type ParsedTag = TagValue & {
title: string;
};

export const parsedTags = writable<ParsedTag[]>([]);

export function setFilters(localTags: TagValue[], filterCols: FilterData[], $columns: Column[]) {
if (!localTags?.length) {
Expand Down Expand Up @@ -47,9 +51,10 @@ export function setFilterData(filter: FilterData) {
});
}
cleanOldTags(filter?.title);
const newTag = {
const newTag: ParsedTag = {
tag: tagData.tag.replace(',', ' or '),
value: tagData.value
value: tagData.value,
title: filter.title
};

parsedTags.update((tags) => {
Expand All @@ -69,9 +74,10 @@ export function setTimeFilter(filter: FilterData, columns: Column[]) {
const ranges = col.elements as { value: string; label: string }[];
const timeRange = ranges.find((range) => range.value === timeTag.value);
if (timeRange) {
const newTag = {
const newTag: ParsedTag = {
tag: `**${filter.title}** is **${timeRange.label}**`,
value: timeRange.value
value: timeRange.value,
title: filter.title
};

cleanOldTags(filter?.title);
Expand Down Expand Up @@ -102,9 +108,10 @@ export function setSizeFilter(filter: FilterData, columns: Column[]) {
if (sizeRange) {
cleanOldTags(filter?.title);

const newTag = {
const newTag: ParsedTag = {
tag: `**${filter.title}** is **${sizeRange.label}**`,
value: sizeTag.value
value: sizeTag.value,
title: filter.title
};
parsedTags.update((tags) => {
tags.push(newTag);
Expand All @@ -126,9 +133,10 @@ export function setStatusCodeFilter(filter: FilterData, columns: Column[]) {
const codeRange = ranges.find((c) => c?.value && c.value === statusCodeTag.value);
if (codeRange) {
cleanOldTags(filter?.title);
const newTag = {
const newTag: ParsedTag = {
tag: `**${filter.title}** is **${codeRange.label}**`,
value: statusCodeTag.value
value: statusCodeTag.value,
title: filter.title
};
parsedTags.update((tags) => {
tags.push(newTag);
Expand Down Expand Up @@ -156,9 +164,10 @@ export function setDateFilter(filter: FilterData, columns: Column[]) {
});
if (dateRange) {
cleanOldTags(filter?.title);
const newTag = {
const newTag: ParsedTag = {
tag: `**${filter.title}** is **${dateRange.label}**`,
value: dateTag.value
value: dateTag.value,
title: filter.title
};
parsedTags.update((tags) => {
tags.push(newTag);
Expand Down
Loading