Skip to content

Commit b5b7382

Browse files
authored
Merge pull request #2779 from appwrite/feat-new-filters-for-sites-and-functions
feat: added new filters for sites and functions
2 parents 0f6b378 + d5d9b09 commit b5b7382

6 files changed

Lines changed: 289 additions & 57 deletions

File tree

Lines changed: 235 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,246 @@
11
<script lang="ts">
2-
import { Icon, Layout, Tag, Tooltip } from '@appwrite.io/pink-svelte';
3-
import { queries, tagFormat, tags } from './store';
2+
import {
3+
Icon,
4+
Layout,
5+
Tooltip,
6+
CompoundTagRoot,
7+
CompoundTagChild,
8+
Typography,
9+
ActionMenu,
10+
Selector
11+
} from '@appwrite.io/pink-svelte';
12+
import { capitalize } from '$lib/helpers/string';
13+
import { queries, tags } from './store';
414
import { IconX } from '@appwrite.io/pink-icons-svelte';
5-
import { parsedTags } from './setFilters';
15+
import { parsedTags, type ParsedTag } from './setFilters';
616
import { Button } from '$lib/elements/forms';
17+
import type { Column } from '$lib/helpers/types';
18+
import { writable, type Writable } from 'svelte/store';
19+
import Menu from '$lib/components/menu/menu.svelte';
20+
import { addFilterAndApply, buildFilterCol, type FilterData } from './quickFilters';
21+
import QuickFilters from '$lib/components/filters/quickFilters.svelte';
22+
import { isSmallViewport } from '$lib/stores/viewport';
23+
24+
let {
25+
columns = writable([]),
26+
analyticsSource = ''
27+
}: { columns?: Writable<Column[]>; analyticsSource?: string } = $props();
28+
29+
function parseTagParts(tagString: string): { text: string; operator: boolean }[] {
30+
return tagString
31+
.split(/\*\*(.*?)\*\*/)
32+
.map((part, index) => {
33+
// Even indices are outside bold (operators), odd indices are inside bold (values)
34+
if (index % 2 === 0) {
35+
return part
36+
.split(/\s+/)
37+
.filter(Boolean)
38+
.map((t) => ({ text: t, operator: true }));
39+
} else {
40+
return [{ text: part, operator: false }];
41+
}
42+
})
43+
.flat()
44+
.filter((p) => Boolean(p.text));
45+
}
46+
47+
function getFilterFor(title: string): FilterData | null {
48+
if (!columns) return null;
49+
const col = ($columns as unknown as Column[]).find((c) => c.title === title);
50+
if (!col) return null;
51+
const filter = buildFilterCol(col);
52+
return filter ?? null;
53+
}
54+
55+
// Build available filter definitions from provided columns
56+
let availableFilters = $derived(
57+
($columns as unknown as Column[] | undefined)?.length
58+
? (($columns as unknown as Column[])
59+
.map((c) => (c.filter !== false ? buildFilterCol(c) : null))
60+
.filter((f) => f && f.options) as FilterData[])
61+
: []
62+
);
63+
64+
// QuickFilters uses the same filters list
65+
let filterCols = $derived(availableFilters);
66+
67+
// Always-show placeholders are derived from available filters (no hardcoding)
68+
// Use reactive array so runes can track changes
69+
let hiddenPlaceholders: string[] = $state([]);
70+
71+
let activeTitles = $derived(
72+
($parsedTags || []).map((t) => (t as ParsedTag).title).filter(Boolean) as string[]
73+
);
74+
75+
// Compute current placeholders (major filters not already active or dismissed)
76+
let placeholders = $derived(
77+
availableFilters
78+
.filter((f) => !activeTitles.includes(f.title))
79+
.filter((f) => !hiddenPlaceholders.includes(f.title))
80+
);
781
</script>
882

9-
{#if $parsedTags?.length}
10-
<Layout.Stack direction="row" gap="s" wrap="wrap" alignItems="center" inline>
83+
<Layout.Stack direction="row" gap="s" wrap="wrap" alignItems="center" inline>
84+
{#if $parsedTags?.length}
1185
{#each $parsedTags as tag (tag.tag)}
1286
<span>
1387
<Tooltip
1488
disabled={Array.isArray(tag.value) ? tag.value?.length < 3 : true}
1589
maxWidth="600px">
16-
<Tag
17-
size="s"
18-
on:click={() => {
19-
const t = $tags.filter((t) => t.tag.includes(tag.tag.split(' ')[0]));
20-
t.forEach((t) => (t ? queries.removeFilter(t) : null));
21-
queries.apply();
22-
parsedTags.update((tags) => tags.filter((t) => t.tag !== tag.tag));
23-
}}>
24-
{#key tag.tag}
25-
<span use:tagFormat>{tag.tag}</span>
26-
{/key}
27-
<Icon icon={IconX} size="s" slot="end" />
28-
</Tag>
90+
<CompoundTagRoot size="s">
91+
{@const parts = parseTagParts(tag.tag)}
92+
{@const property = (tag as ParsedTag).title}
93+
94+
{#each parts as part}
95+
<CompoundTagChild>
96+
<Menu>
97+
<span>
98+
{#if part.operator}
99+
<Typography.Text color="--fgcolor-neutral-secondary"
100+
>{part.text}</Typography.Text>
101+
{:else}
102+
<Typography.Text
103+
variant="m-500"
104+
color="--fgcolor-neutral-secondary"
105+
>{part.text
106+
.split(' or ')
107+
.map((t) => capitalize(t))
108+
.join(' or ')}</Typography.Text>
109+
{/if}
110+
</span>
111+
<svelte:fragment slot="menu">
112+
{#if property}
113+
{@const filter = getFilterFor(property)}
114+
{#if filter}
115+
{@const isArray = filter?.array}
116+
{@const selectedArray = Array.isArray(tag.value)
117+
? tag.value
118+
: []}
119+
{#each filter.options as option (filter.title + option.value + option.label)}
120+
<ActionMenu.Root>
121+
<ActionMenu.Item.Button
122+
on:click={() => {
123+
if (isArray) {
124+
const exists =
125+
selectedArray.includes(
126+
option.value
127+
);
128+
const next = exists
129+
? selectedArray.filter(
130+
(v) =>
131+
v !== option.value
132+
)
133+
: [
134+
...selectedArray,
135+
option.value
136+
];
137+
addFilterAndApply(
138+
filter.id,
139+
filter.title,
140+
filter.operator,
141+
null,
142+
next,
143+
$columns,
144+
analyticsSource
145+
);
146+
} else {
147+
addFilterAndApply(
148+
filter.id,
149+
filter.title,
150+
filter.operator,
151+
option.value,
152+
[],
153+
$columns,
154+
analyticsSource
155+
);
156+
}
157+
}}>
158+
<Layout.Stack direction="row" gap="s">
159+
{#if isArray}
160+
<Selector.Checkbox
161+
checked={selectedArray.includes(
162+
option.value
163+
)}
164+
size="s" />
165+
{/if}
166+
{capitalize(option.label)}
167+
</Layout.Stack>
168+
</ActionMenu.Item.Button>
169+
</ActionMenu.Root>
170+
{/each}
171+
{/if}
172+
{/if}
173+
</svelte:fragment>
174+
</Menu>
175+
</CompoundTagChild>
176+
{/each}
177+
<CompoundTagChild
178+
dismiss
179+
on:click={() => {
180+
const t = $tags.filter((t) =>
181+
t.tag.includes((tag as ParsedTag).title)
182+
);
183+
t.forEach((t) => (t ? queries.removeFilter(t) : null));
184+
queries.apply();
185+
parsedTags.update((tags) => tags.filter((t) => t.tag !== tag.tag));
186+
}}>
187+
<Icon icon={IconX} size="s" />
188+
</CompoundTagChild>
189+
</CompoundTagRoot>
29190
<span slot="tooltip">{tag?.value?.toString()}</span>
30191
</Tooltip>
31192
</span>
32193
{/each}
194+
{/if}
195+
196+
<!-- Always render remaining placeholder tags alongside active tags -->
197+
{#if placeholders?.length}
198+
{#each placeholders as filter (filter.title + filter.id)}
199+
<span>
200+
<Menu>
201+
<CompoundTagRoot size="s">
202+
<CompoundTagChild>
203+
<span>{capitalize(filter.title)}</span>
204+
</CompoundTagChild>
205+
<CompoundTagChild
206+
dismiss
207+
on:click={(e) => {
208+
e.stopPropagation();
209+
if (!hiddenPlaceholders.includes(filter.title)) {
210+
hiddenPlaceholders = [...hiddenPlaceholders, filter.title];
211+
}
212+
}}>
213+
<Icon icon={IconX} size="s" />
214+
</CompoundTagChild>
215+
</CompoundTagRoot>
216+
<svelte:fragment slot="menu">
217+
{#if filter.options}
218+
{#each filter.options as option (filter.title + option.value + option.label)}
219+
<ActionMenu.Root>
220+
<ActionMenu.Item.Button
221+
on:click={() => {
222+
addFilterAndApply(
223+
filter.id,
224+
filter.title,
225+
filter.operator,
226+
filter?.array ? null : option.value,
227+
filter?.array ? [option.value] : [],
228+
$columns,
229+
analyticsSource
230+
);
231+
}}>
232+
{capitalize(option.label)}
233+
</ActionMenu.Item.Button>
234+
</ActionMenu.Root>
235+
{/each}
236+
{/if}
237+
</svelte:fragment>
238+
</Menu>
239+
</span>
240+
{/each}
241+
{/if}
242+
243+
{#if $parsedTags?.length}
33244
<Button
34245
size="s"
35246
text
@@ -38,5 +249,9 @@
38249
queries.apply();
39250
parsedTags.set([]);
40251
}}>Clear all</Button>
41-
</Layout.Stack>
42-
{/if}
252+
{/if}
253+
254+
{#if filterCols?.length && !$isSmallViewport}
255+
<QuickFilters {columns} {analyticsSource} {filterCols} />
256+
{/if}
257+
</Layout.Stack>

src/lib/components/filters/quickFilters.svelte

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,12 @@
2222
</script>
2323

2424
<Menu>
25-
<Button secondary badge={$parsedTags?.length ? `${$parsedTags.length}` : undefined}>
26-
<Icon icon={IconFilterLine} slot="start" size="s" />
27-
Filters
25+
<Button
26+
ariaLabel="Filters"
27+
text
28+
icon
29+
badge={$parsedTags?.length ? `${$parsedTags.length}` : undefined}>
30+
<Icon icon={IconFilterLine} size="s" />
2831
</Button>
2932
<svelte:fragment slot="menu">
3033
{#each filterCols.filter((f) => f?.options) as filter (filter.title + filter.id)}

src/lib/components/filters/setFilters.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { get, writable } from 'svelte/store';
33
import { type FilterData } from './quickFilters';
44
import { tags, type TagValue } from './store';
55

6-
export const parsedTags = writable<TagValue[]>([]);
6+
export type ParsedTag = TagValue & {
7+
title: string;
8+
};
9+
10+
export const parsedTags = writable<ParsedTag[]>([]);
711

812
export function setFilters(localTags: TagValue[], filterCols: FilterData[], $columns: Column[]) {
913
if (!localTags?.length) {
@@ -47,9 +51,10 @@ export function setFilterData(filter: FilterData) {
4751
});
4852
}
4953
cleanOldTags(filter?.title);
50-
const newTag = {
54+
const newTag: ParsedTag = {
5155
tag: tagData.tag.replace(',', ' or '),
52-
value: tagData.value
56+
value: tagData.value,
57+
title: filter.title
5358
};
5459

5560
parsedTags.update((tags) => {
@@ -69,9 +74,10 @@ export function setTimeFilter(filter: FilterData, columns: Column[]) {
6974
const ranges = col.elements as { value: string; label: string }[];
7075
const timeRange = ranges.find((range) => range.value === timeTag.value);
7176
if (timeRange) {
72-
const newTag = {
77+
const newTag: ParsedTag = {
7378
tag: `**${filter.title}** is **${timeRange.label}**`,
74-
value: timeRange.value
79+
value: timeRange.value,
80+
title: filter.title
7581
};
7682

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

105-
const newTag = {
111+
const newTag: ParsedTag = {
106112
tag: `**${filter.title}** is **${sizeRange.label}**`,
107-
value: sizeTag.value
113+
value: sizeTag.value,
114+
title: filter.title
108115
};
109116
parsedTags.update((tags) => {
110117
tags.push(newTag);
@@ -126,9 +133,10 @@ export function setStatusCodeFilter(filter: FilterData, columns: Column[]) {
126133
const codeRange = ranges.find((c) => c?.value && c.value === statusCodeTag.value);
127134
if (codeRange) {
128135
cleanOldTags(filter?.title);
129-
const newTag = {
136+
const newTag: ParsedTag = {
130137
tag: `**${filter.title}** is **${codeRange.label}**`,
131-
value: statusCodeTag.value
138+
value: statusCodeTag.value,
139+
title: filter.title
132140
};
133141
parsedTags.update((tags) => {
134142
tags.push(newTag);
@@ -156,9 +164,10 @@ export function setDateFilter(filter: FilterData, columns: Column[]) {
156164
});
157165
if (dateRange) {
158166
cleanOldTags(filter?.title);
159-
const newTag = {
167+
const newTag: ParsedTag = {
160168
tag: `**${filter.title}** is **${dateRange.label}**`,
161-
value: dateTag.value
169+
value: dateTag.value,
170+
title: filter.title
162171
};
163172
parsedTags.update((tags) => {
164173
tags.push(newTag);

0 commit comments

Comments
 (0)