Skip to content

Commit e1859b4

Browse files
authored
Show advanced field filter (#1841)
* initial filters * show operation picker * allow filtering for one or many writing systems * expose gridify filter over miniLcm api * escape filter value
1 parent 7b96626 commit e1859b4

8 files changed

Lines changed: 166 additions & 5 deletions

File tree

backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Microsoft.OpenApi.Any;
55
using Microsoft.OpenApi.Models;
66
using MiniLcm;
7+
using MiniLcm.Filtering;
78
using MiniLcm.Models;
89
using MiniLcm.Project;
910
using MiniLcm.Validators;
@@ -180,7 +181,8 @@ public QueryOptions ToQueryOptions()
180181
Ascending ?? SortOptions.Default.Ascending),
181182
exemplarOptions,
182183
Count ?? QueryOptions.Default.Count,
183-
Offset ?? QueryOptions.Default.Offset);
184+
Offset ?? QueryOptions.Default.Offset,
185+
string.IsNullOrEmpty(GridifyFilter) ? null : new EntryFilter {GridifyFilter = GridifyFilter});
184186
}
185187

186188
public SortField? SortField { get; set; } = SortOptions.Default.Field;
@@ -203,5 +205,7 @@ public QueryOptions ToQueryOptions()
203205

204206
[FromQuery]
205207
public int? Offset { get; set; }
208+
[FromQuery]
209+
public string? GridifyFilter { get; set; }
206210
}
207211
}

frontend/viewer/src/lib/components/field-editors/select.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
emptyResultsPlaceholder?: string;
3030
drawerTitle?: string;
3131
onchange?: (value: Value) => void;
32+
class?: string;
3233
} = $props();
3334
3435
const {
@@ -41,6 +42,7 @@
4142
emptyResultsPlaceholder,
4243
drawerTitle,
4344
onchange,
45+
class: className
4446
} = $derived(constProps);
4547
4648
function getId(value: Value): Primitive {
@@ -89,7 +91,7 @@
8991

9092
{#snippet trigger({ props }: { props: Record<string, unknown> })}
9193
<Button disabled={readonly} bind:ref={triggerRef} variant="outline" {...props} role="combobox" aria-expanded={open}
92-
class="w-full h-auto px-2 justify-between disabled:opacity-100 disabled:border-transparent">
94+
class={cn('w-full h-auto px-2 justify-between disabled:opacity-100 disabled:border-transparent', className)}>
9395
{#if value}
9496
<span>
9597
{getLabel(value)}

frontend/viewer/src/lib/components/ui/select/select-trigger.svelte

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22
import {cn} from '$lib/utils.js';
33
import {Select as SelectPrimitive, type WithoutChild} from 'bits-ui';
44
import {Icon} from '../icon';
5+
import type {IconClass} from '$lib/icon-class';
56
67
let {
78
ref = $bindable(null),
89
class: className,
10+
downIcon = 'i-mdi-chevron-down',
911
children,
1012
...restProps
11-
}: WithoutChild<SelectPrimitive.TriggerProps> = $props();
13+
}: WithoutChild<SelectPrimitive.TriggerProps> & {
14+
downIcon?: IconClass | null;
15+
} = $props();
1216
</script>
1317

1418
<SelectPrimitive.Trigger
@@ -20,5 +24,7 @@
2024
{...restProps}
2125
>
2226
{@render children?.()}
23-
<Icon icon="i-mdi-chevron-down" class="size-4 opacity-50" />
27+
{#if downIcon}
28+
<Icon icon={downIcon} class="size-4 opacity-50" />
29+
{/if}
2430
</SelectPrimitive.Trigger>

frontend/viewer/src/lib/writing-system-service.svelte.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {type ResourceReturn} from 'runed';
1515
export type WritingSystemSelection =
1616
| 'vernacular'
1717
| 'analysis'
18+
| 'vernacular-no-audio'
19+
| 'analysis-no-audio'
1820
| 'first-vernacular'
1921
| 'first-analysis'
2022
| 'vernacular-analysis'
@@ -88,6 +90,10 @@ export class WritingSystemService {
8890
return this.writingSystems.vernacular;
8991
case 'analysis':
9092
return this.writingSystems.analysis;
93+
case 'vernacular-no-audio':
94+
return this.vernacularNoAudio;
95+
case 'analysis-no-audio':
96+
return this.analysisNoAudio;
9197
}
9298
console.error(`Unknown writing system selection: ${ws as string}`);
9399
return [];

frontend/viewer/src/project/browse/BrowseView.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
collapsible
5353
collapsedSize={0}
5454
minSize={15}
55-
class="min-h-0 flex flex-col relative"
55+
class="min-h-0 flex flex-col relative @container/list"
5656
>
5757
<div class="flex flex-col h-full p-2 md:p-4 md:pr-0">
5858
<div class="md:mr-3">

frontend/viewer/src/project/browse/SearchFilter.svelte

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,16 @@
1313
import {useCurrentView} from '$lib/views/view-service';
1414
import {formatNumber} from '$lib/components/ui/format';
1515
import ViewT from '$lib/views/ViewT.svelte';
16+
import Select from '$lib/components/field-editors/select.svelte';
17+
import { Input } from '$lib/components/ui/input';
18+
import {watch} from 'runed';
19+
import OpFilter, {type Op} from './filter/OpFilter.svelte';
20+
import WsSelect from './filter/WsSelect.svelte';
21+
import {useWritingSystemService} from '$lib/writing-system-service.svelte';
1622
1723
const stats = useProjectStats();
1824
const currentView = useCurrentView();
25+
const wsService = useWritingSystemService();
1926
2027
let {
2128
search = $bindable(),
@@ -28,6 +35,23 @@
2835
let missingSenses = $state(false);
2936
let missingPartOfSpeech = $state(false);
3037
let missingSemanticDomains = $state(false);
38+
39+
let fields: Array<{id: string, label: string, ws: 'vernacular-no-audio' | 'analysis-no-audio'}> = $derived([
40+
{ id: 'lexemeForm', label: pt($t`Lexeme Form`, $t`Word`, $currentView), ws: 'vernacular-no-audio' },
41+
{ id: 'citationForm', label: pt($t`Citation Form`, $t`Display as`, $currentView), ws: 'vernacular-no-audio' },
42+
{ id: 'senses.gloss', label: $t`Gloss`, ws: 'analysis-no-audio' },
43+
]);
44+
45+
// svelte-ignore state_referenced_locally
46+
let selectedField = $state(fields[0]);
47+
let selectedWs = $state<string[]>(wsService.vernacularNoAudio.map(ws => ws.wsId));
48+
watch(() => fields, fields => {
49+
//updates selected field when selected view changes
50+
selectedField = fields.find(f => f.id === selectedField.id) ?? fields[0];
51+
});
52+
let fieldFilterValue = $state('');
53+
let filterOp = $state<Op>('contains')
54+
3155
$effect(() => {
3256
let newFilter: string[] = [];
3357
if (missingExamples) {
@@ -42,9 +66,32 @@
4266
if (missingSemanticDomains) {
4367
newFilter.push('Senses.SemanticDomains=null')
4468
}
69+
if (fieldFilterValue && selectedWs?.length > 0) {
70+
let op: string;
71+
switch (filterOp) {
72+
case 'starts-with': op = '^'; break;
73+
case 'contains': op = '=*'; break;
74+
case 'ends-with': op = '$'; break;
75+
case 'equals': op = '='; break;
76+
case 'not-equals': op = '!='; break;
77+
}
78+
let fieldFilter = [];
79+
let escapedValue = escapeGridifyValue(fieldFilterValue);
80+
for (let ws of selectedWs) {
81+
fieldFilter.push(`${selectedField.id}[${ws}]${op}${escapedValue}`);
82+
}
83+
//construct a filter like LexemeForm[en]=value|LexemeForm[fr]=value
84+
newFilter.push('(' + fieldFilter.join('|') + ')')
85+
}
4586
gridifyFilter = newFilter.join(', ');
4687
});
4788
89+
90+
function escapeGridifyValue(v: string) {
91+
//from https://alirezanet.github.io/Gridify/guide/filtering#escaping
92+
return v.replace(/([(),|\\]|\/i)/g, '\\$1');
93+
}
94+
4895
let filtersExpanded = $state(false);
4996
</script>
5097

@@ -78,6 +125,30 @@
78125
</ComposableInput>
79126
</div>
80127
<Collapsible.Content class="p-2 mb-2 space-y-2">
128+
<div class="flex flex-col @md/list:flex-row gap-2 items-stretch">
129+
<div class="flex flex-row gap-2 flex-1">
130+
<!-- Field Picker -->
131+
<Select
132+
bind:value={selectedField}
133+
options={fields}
134+
idSelector="id"
135+
labelSelector="label"
136+
placeholder={$t`Field`}
137+
class="flex-1"
138+
/>
139+
<!-- Writing System Picker -->
140+
<WsSelect bind:value={selectedWs} wsType={selectedField.ws} />
141+
</div>
142+
<!-- Text Box: on mobile, wraps to new line -->
143+
<div class="flex flex-row gap-2 flex-1">
144+
<OpFilter bind:value={filterOp}/>
145+
<Input
146+
bind:value={fieldFilterValue}
147+
placeholder={$t`Filter for`}
148+
class="flex-1"
149+
/>
150+
</div>
151+
</div>
81152
<Switch bind:checked={missingExamples} label={$t`Missing Examples`} />
82153
<Switch bind:checked={missingSenses} label={$t`Missing Senses`} />
83154
<Switch bind:checked={missingPartOfSpeech} label={$t`Missing Part of Speech`} />
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<script lang="ts" module>
2+
export type Op = 'starts-with' | 'contains' | 'ends-with' | 'equals' | 'not-equals';
3+
</script>
4+
5+
<script lang="ts">
6+
import * as Select from '$lib/components/ui/select';
7+
import {Icon} from '$lib/components/ui/icon';
8+
import {t} from 'svelte-i18n-lingui';
9+
import type {IconClass} from '$lib/icon-class';
10+
let {value = $bindable()}: {value: string} = $props();
11+
const ops: {label: string, value: Op, icon: IconClass}[] = $derived([
12+
{label: $t`Starts with`, value: 'starts-with', icon: 'i-mdi-contain-start'},
13+
{label: $t`Contains`, value: 'contains', icon: 'i-mdi-contain'},
14+
{label: $t`Ends with`, value: 'ends-with', icon: 'i-mdi-contain-end'},
15+
{label: $t`Equals`, value: 'equals', icon: 'i-mdi-equal'},
16+
{label: $t`Not equal`, value: 'not-equals', icon: 'i-mdi-not-equal-variant'},
17+
]);
18+
</script>
19+
20+
<Select.Root type="single" bind:value>
21+
<Select.Trigger class="w-13" downIcon={null}>
22+
<Icon icon={ops.find(o => o.value === value)?.icon ?? 'i-mdi-close'}/>
23+
</Select.Trigger>
24+
<Select.Content>
25+
<Select.Group>
26+
<Select.GroupHeading>Filter By</Select.GroupHeading>
27+
{#each ops as op}
28+
<Select.Item value={op.value}>
29+
<Icon icon={op.icon} class="mr-2"/>
30+
{op.label}
31+
</Select.Item>
32+
{/each}
33+
</Select.Group>
34+
</Select.Content>
35+
</Select.Root>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<script lang="ts">
2+
import * as Select from '$lib/components/ui/select';
3+
import {useWritingSystemService, type WritingSystemSelection} from '$lib/writing-system-service.svelte';
4+
import {t} from 'svelte-i18n-lingui';
5+
import {watch} from 'runed';
6+
7+
const wsService = useWritingSystemService();
8+
9+
let {value = $bindable(), wsType}: { value: string[], wsType: WritingSystemSelection } = $props();
10+
let writingSystems = $derived(wsService.pickWritingSystems(wsType));
11+
watch(() => writingSystems, () => {
12+
value = writingSystems.map(ws => ws.wsId);
13+
});
14+
</script>
15+
<Select.Root type="multiple" bind:value>
16+
<Select.Trigger class="flex-1">
17+
{#if value.length === 0}
18+
<span class="text-muted-foreground">{$t`Writing System`}</span>
19+
{:else if value.length === writingSystems.length}
20+
<span class="text-muted-foreground">
21+
{$t`Any Ws`}
22+
</span>
23+
{:else}
24+
{writingSystems.filter(w => value.includes(w.wsId)).map(w => w.abbreviation).join(', ')}
25+
{/if}
26+
</Select.Trigger>
27+
<Select.Content>
28+
<Select.Group>
29+
<Select.GroupHeading>{$t`Writing Systems`}</Select.GroupHeading>
30+
{#each writingSystems as ws}
31+
<Select.Item value={ws.wsId}>
32+
{ws.abbreviation} ({ws.wsId})
33+
</Select.Item>
34+
{/each}
35+
</Select.Group>
36+
</Select.Content>
37+
</Select.Root>

0 commit comments

Comments
 (0)