Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 272 additions & 19 deletions src/lib/components/filters/parsedTagList.svelte
Original file line number Diff line number Diff line change
@@ -1,35 +1,284 @@
<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 { Button } from '$lib/elements/forms';
import type { Column } from '$lib/helpers/types';
import 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';
let {
columns,
analyticsSource = ''
}: { columns: Writable<Column[]> | undefined; analyticsSource?: string } = $props();
function parseTagParts(tagString: string): { text: string; operator: boolean }[] {
const parts: { text: string; operator: boolean }[] = [];
const regex = /\*\*(.*?)\*\*/g;
let match;
let lastIndex = 0;
const matches: Array<{ text: string; index: number; endIndex: number }> = [];
// find all bold matches
while ((match = regex.exec(tagString)) !== null) {
matches.push({
text: match[1],
index: match.index,
endIndex: regex.lastIndex
});
}
// Build parts array
for (let i = 0; i < matches.length; i++) {
const currentMatch = matches[i];
// Add text before this match (operators, etc.)
if (lastIndex < currentMatch.index) {
const beforeText = tagString.substring(lastIndex, currentMatch.index).trim();
if (beforeText) {
parts.push(
...beforeText
.split(/\s+/)
.filter(Boolean)
.map((t) => ({ text: t, operator: true }))
);
}
}
// Add the bold text itself
parts.push({ text: currentMatch.text, operator: false });
lastIndex = currentMatch.endIndex;
}
// Add any remaining text after last match
if (lastIndex < tagString.length) {
const remaining = tagString.substring(lastIndex).trim();
if (remaining) {
parts.push(
...remaining
.split(/\s+/)
.filter(Boolean)
.map((t) => ({ text: t, operator: true }))
);
}
}
return parts.filter((p) => Boolean(p.text));
}
function firstBoldText(tagString: string): string | null {
const m = /\*\*(.*?)\*\*/.exec(tagString);
return m ? m[1] : null;
}
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 filter 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 placeholderVersion = $state(0); // used to force keyed re-render when needed
let activeTitles = $derived(
($parsedTags || []).map((t) => firstBoldText(t.tag)).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 = firstBoldText(tag.tag)}

{#each parts as part}
<CompoundTagChild>
<Menu>
<span>
{#if part.operator}
<Typography.Text color="--fgcolor-neutral-secondary"
>{part.text}</Typography.Text>
{:else}
{capitalize(part.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,
''
);
} else {
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
option.value,
[],
$columns,
''
);
Comment on lines +172 to +190
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

analyticsSource prop not passed to addFilterAndApply.

The analyticsSource prop is received but hardcoded empty strings are passed to addFilterAndApply calls. This will break analytics tracking for filter interactions.

Proposed fix
                                                     addFilterAndApply(
                                                         filter.id,
                                                         filter.title,
                                                         filter.operator,
                                                         null,
                                                         next,
                                                         $columns,
-                                                        ''
+                                                        analyticsSource
                                                     );
                                                 } else {
                                                     addFilterAndApply(
                                                         filter.id,
                                                         filter.title,
                                                         filter.operator,
                                                         option.value,
                                                         [],
                                                         $columns,
-                                                        ''
+                                                        analyticsSource
                                                     );
                                                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
null,
next,
$columns,
''
);
} else {
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
option.value,
[],
$columns,
''
);
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
null,
next,
$columns,
analyticsSource
);
} else {
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
option.value,
[],
$columns,
analyticsSource
);
🤖 Prompt for AI Agents
In @src/lib/components/filters/parsedTagList.svelte around lines 172 - 190, The
calls to addFilterAndApply in parsedTagList.svelte are passing empty strings for
the analyticsSource argument, which drops the component's analyticsSource prop;
update both call sites (the branch that passes null for value/next and the
branch that passes option.value/[]) to forward the component's analyticsSource
prop instead of '' so analyticsSource is sent through addFilterAndApply.

}
Comment on lines +172 to +191
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

analyticsSource prop not used in tag menu handlers.

The addFilterAndApply calls at lines 179, 189, and 266 pass an empty string '' for analyticsSource, while the component receives analyticsSource as a prop. This means filter changes made via tag menus won't be tracked, unlike those from QuickFilters (line 282) which correctly uses the prop.

🔧 Suggested fix
                                                 addFilterAndApply(
                                                     filter.id,
                                                     filter.title,
                                                     filter.operator,
                                                     null,
                                                     next,
                                                     $columns,
-                                                    ''
+                                                    analyticsSource
                                                 );

Apply the same change to lines 189 and 266.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
null,
next,
$columns,
''
);
} else {
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
option.value,
[],
$columns,
''
);
}
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
null,
next,
$columns,
analyticsSource
);
} else {
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
option.value,
[],
$columns,
analyticsSource
);
}
🤖 Prompt for AI Agents
In @src/lib/components/filters/parsedTagList.svelte around lines 172 - 191, The
tag menu handlers call addFilterAndApply with a hardcoded empty string for
analyticsSource instead of using the component's analyticsSource prop; update
all addFilterAndApply invocations in the tag/menu-related handlers (the calls
currently passing '' — the ones near the other tag option branches and the later
tag removal branch) to pass the analyticsSource prop value so they match the
QuickFilters usage that already passes analyticsSource to addFilterAndApply.

}}>
<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.tag.split(' ')[0])
);
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 -->
{#key placeholderVersion}
{#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.push(filter.title);
}
placeholderVersion++;
}}>
Comment on lines +243 to +249
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Direct array mutation may cause reactivity issues.

In Svelte 5, while array mutations can trigger reactivity in some cases, the pattern of directly mutating hiddenPlaceholders with .push() combined with a manual placeholderVersion++ increment suggests instability. Use immutable updates for reliable reactivity:

Proposed fix
                             on:click={(e) => {
                                 e.stopPropagation();
-                                if (!hiddenPlaceholders.includes(filter.title)) {
-                                    hiddenPlaceholders.push(filter.title);
-                                }
-                                placeholderVersion++;
+                                if (!hiddenPlaceholders.includes(filter.title)) {
+                                    hiddenPlaceholders = [...hiddenPlaceholders, filter.title];
+                                }
                             }}>

If reactivity works correctly with immutable updates, you can also remove the placeholderVersion state and the {#key placeholderVersion} wrapper (lines 110 and 232).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
on:click={(e) => {
e.stopPropagation();
if (!hiddenPlaceholders.includes(filter.title)) {
hiddenPlaceholders.push(filter.title);
}
placeholderVersion++;
}}>
on:click={(e) => {
e.stopPropagation();
if (!hiddenPlaceholders.includes(filter.title)) {
hiddenPlaceholders = [...hiddenPlaceholders, filter.title];
}
}}>
🤖 Prompt for AI Agents
In @src/lib/components/filters/parsedTagList.svelte around lines 243 - 249, The
click handler mutates hiddenPlaceholders with .push() and manually increments
placeholderVersion which can break Svelte reactivity; replace the mutation with
an immutable assignment like setting hiddenPlaceholders =
[...hiddenPlaceholders, filter.title] (only if filter.title not already
included) and remove the placeholderVersion++; after confirming immutable
updates work you can also remove placeholderVersion state and the {#key
placeholderVersion} wrapper (ensure you update the on:click handler references
and any code that reads hiddenPlaceholders accordingly).

<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,
''
);
Comment on lines +259 to +267
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same issue: analyticsSource not passed in placeholder filter actions.

Apply the same fix here for consistency:

Proposed fix
                                             addFilterAndApply(
                                                 filter.id,
                                                 filter.title,
                                                 filter.operator,
                                                 filter?.array ? null : option.value,
                                                 filter?.array ? [option.value] : [],
                                                 $columns,
-                                                ''
+                                                analyticsSource
                                             );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
filter?.array ? null : option.value,
filter?.array ? [option.value] : [],
$columns,
''
);
addFilterAndApply(
filter.id,
filter.title,
filter.operator,
filter?.array ? null : option.value,
filter?.array ? [option.value] : [],
$columns,
analyticsSource
);
🤖 Prompt for AI Agents
In @src/lib/components/filters/parsedTagList.svelte around lines 259 - 267, The
placeholder filter action call to addFilterAndApply is missing the
analyticsSource argument; update the call in parsedTagList.svelte so
addFilterAndApply(filter.id, filter.title, filter.operator, filter?.array ? null
: option.value, filter?.array ? [option.value] : [], $columns, '',
analyticsSource) includes the analyticsSource parameter (or the appropriate
variable name used elsewhere) to match other calls to addFilterAndApply.

}}>
{capitalize(option.label)}
</ActionMenu.Item.Button>
</ActionMenu.Root>
{/each}
{/if}
</svelte:fragment>
</Menu>
</span>
{/each}
{/if}
{/key}

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

{#if filterCols?.length}
<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
34 changes: 25 additions & 9 deletions src/lib/layout/responsiveContainerHeader.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import { SearchQuery, ViewSelector } from '$lib/components';
import { FiltersBottomSheet, ParsedTagList, queryParamToMap } from '$lib/components/filters';
import QuickFilters from '$lib/components/filters/quickFilters.svelte';

import Button from '$lib/elements/forms/button.svelte';
import { View } from '$lib/helpers/load';
import type { Column } from '$lib/helpers/types';
Expand Down Expand Up @@ -99,18 +99,35 @@
{#if showSearch && hasSearch}
<SearchQuery placeholder={searchPlaceholder} />
{/if}
<div style="overflow-x: auto;">
<ParsedTagList {columns} {analyticsSource} />
</div>
</Layout.Stack>
{:else}
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center">
<Layout.Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Layout.Stack
direction="row"
alignItems="center"
gap="m"
style={`min-width: 0; flex: 1 1 auto;`}>
{#if hasSearch}
<SearchQuery placeholder={searchPlaceholder} />
{/if}
<!-- Tags with Filters button (rendered inside ParsedTagList) -->
<Layout.Stack
direction="row"
alignItems="center"
gap="s"
wrap="wrap"
style={`min-width: 0;`}>
<ParsedTagList {columns} {analyticsSource} />
</Layout.Stack>
Comment on lines +116 to +124
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing hasFilters conditional check for ParsedTagList.

In the small viewport case, filter rendering is gated by hasFilters && $columns?.length, but here ParsedTagList is rendered unconditionally. This could display filter UI when hasFilters is false.

Proposed fix
-                   <!-- Tags with Filters button (rendered inside ParsedTagList) -->
-                   <Layout.Stack
-                       direction="row"
-                       alignItems="center"
-                       gap="s"
-                       wrap="wrap"
-                       style={`min-width: 0;`}>
-                       <ParsedTagList {columns} {analyticsSource} />
-                   </Layout.Stack>
+                   {#if hasFilters && $columns?.length}
+                       <!-- Tags with Filters button (rendered inside ParsedTagList) -->
+                       <Layout.Stack
+                           direction="row"
+                           alignItems="center"
+                           gap="s"
+                           wrap="wrap"
+                           style={`min-width: 0;`}>
+                           <ParsedTagList {columns} {analyticsSource} />
+                       </Layout.Stack>
+                   {/if}
🤖 Prompt for AI Agents
In @src/lib/layout/responsiveContainerHeader.svelte around lines 113 - 121,
ParsedTagList is being rendered unconditionally inside the Layout.Stack for the
small-viewport branch which can show filter UI even when hasFilters is false;
update the rendering to conditionally render ParsedTagList only when hasFilters
&& $columns?.length (same condition used elsewhere) so wrap the
Layout.Stack/ParsedTagList block with that conditional check to match the
intended behavior.

</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
{#if hasFilters && $columns?.length}
<QuickFilters {columns} {analyticsSource} {filterCols} />
{/if}
<Layout.Stack
direction="row"
alignItems="center"
justifyContent="flex-end"
style={`align-self: flex-start; white-space: nowrap;`}>
{#if hasDisplaySettings}
<ViewSelector ui="new" {view} {columns} {hideView} {hideColumns} />
{/if}
Expand All @@ -120,7 +137,6 @@
</Layout.Stack>
</Layout.Stack>
{/if}
<ParsedTagList />
</Layout.Stack>
</header>

Expand Down Expand Up @@ -160,7 +176,7 @@
{/snippet}

{#snippet filtersButton(icon = false)}
<Button ariaLabel="Filters" on:click={() => (showFilters = !showFilters)} secondary {icon}>
<Button ariaLabel="Filters" on:click={() => (showFilters = !showFilters)} text {icon}>
<Icon icon={IconFilterLine} />
</Button>
{/snippet}
Loading