Merge pull request #490 from appwrite/filtering

Filtering
This commit is contained in:
Christy Jacob
2023-08-09 19:47:47 +04:00
committed by GitHub
13 changed files with 487 additions and 48 deletions
+5 -3
View File
@@ -29,7 +29,8 @@ const groups = [
'teams',
'security',
'buckets',
'files'
'files',
'misc'
] as const;
export type CommandGroup = (typeof groups)[number];
@@ -278,8 +279,9 @@ export const commandGroupRanks = derived(groupRanksMap, ($groupRankTransformatio
databases: 3,
users: 2,
teams: 1,
navigation: -1,
help: -2
navigation: -10,
help: -20,
misc: -30
} as CommandGroupRanks;
const transformations = Array.from($groupRankTransformations.values());
+12 -2
View File
@@ -77,16 +77,26 @@
event.target === element ||
element.contains(event.target as Node) ||
event.target === tooltip ||
tooltip.contains(event.target as Node)
tooltip.contains(event.target as Node) ||
// Avoid deleted elements triggering blur
!document.body.contains(event.target as Node)
)
) {
show = false;
dispatch('blur');
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && show) {
event.preventDefault();
show = false;
dispatch('blur');
}
};
</script>
<svelte:window on:click={onBlur} />
<svelte:window on:click={onBlur} on:keydown={onKeyDown} />
<div class:drop-wrapper={!noStyle} class:u-cross-child-start={childStart} bind:this={element}>
<slot />
+2 -1
View File
@@ -23,6 +23,7 @@
export let hideView = false;
export let hideColumns = false;
export let allowNoColumns = false;
export let showColsTextMobile = false;
let showSelectColumns = false;
@@ -84,7 +85,7 @@
class="icon-view-boards u-opacity-50"
aria-hidden="true"
aria-label="columns" />
<span class="text is-only-desktop">Columns</span>
<span class="text {showColsTextMobile ? '' : 'is-only-desktop'}">Columns</span>
<span class="inline-tag">{selectedColumnsNumber}</span>
</Button>
<svelte:fragment slot="list">
+6 -4
View File
@@ -3,7 +3,7 @@
import { FormItem, Helper, Label } from '.';
import NullCheckbox from './nullCheckbox.svelte';
export let label: string;
export let label: string | undefined = undefined;
export let optionalText: string | undefined = undefined;
export let showLabel = true;
export let id: string;
@@ -65,9 +65,11 @@
</script>
<FormItem>
<Label {required} {optionalText} hide={!showLabel} for={id}>
{label}
</Label>
{#if label}
<Label {required} {optionalText} hide={!showLabel} for={id}>
{label}
</Label>
{/if}
<div class="input-text-wrapper">
<input
+6 -4
View File
@@ -2,7 +2,7 @@
import { FormItem, Helper, Label } from '.';
export let id: string;
export let label: string;
export let label: string | undefined = undefined;
export let optionalText: string | undefined = undefined;
export let showLabel = true;
export let value: string | number | boolean;
@@ -45,9 +45,11 @@
</script>
<FormItem>
<Label {required} {optionalText} hide={!showLabel} for={id}>
{label}
</Label>
{#if label}
<Label {required} {optionalText} hide={!showLabel} for={id}>
{label}
</Label>
{/if}
<div class="select">
<select
+6 -4
View File
@@ -4,7 +4,7 @@
import NullCheckbox from './nullCheckbox.svelte';
import TextCounter from './textCounter.svelte';
export let label: string;
export let label: string = undefined;
export let optionalText: string | undefined = undefined;
export let showLabel = true;
export let id: string;
@@ -67,9 +67,11 @@
</script>
<FormItem>
<Label {required} {tooltip} {optionalText} hide={!showLabel} for={id}>
{label}
</Label>
{#if label}
<Label {required} {tooltip} {optionalText} hide={!showLabel} for={id}>
{label}
</Label>
{/if}
<div class="input-text-wrapper">
<input
+21 -1
View File
@@ -7,7 +7,7 @@
import Header from '$lib/layout/header.svelte';
import SideNavigation from '$lib/layout/navigation.svelte';
import Shell from '$lib/layout/shell.svelte';
import { feedback } from '$lib/stores/feedback';
import { app, feedback } from '$lib/stores/app';
import { log } from '$lib/stores/logs';
import { newOrgModal } from '$lib/stores/organization';
import { wizard } from '$lib/stores/wizard';
@@ -21,6 +21,7 @@
import { AIPanel, OrganizationsPanel, ProjectsPanel } from '$lib/commandCenter/panels';
import { orgSearcher, projectsSearcher } from '$lib/commandCenter/searchers';
import { addSubPanel } from '$lib/commandCenter/subPanels';
import { addNotification } from '$lib/stores/notifications';
import { openMigrationWizard } from './(migration-wizard)';
$: $registerCommands([
@@ -105,6 +106,25 @@
},
group: 'help',
icon: 'discord'
},
{
label: 'Toggle theme',
callback: () => {
if ($app.theme === 'auto') {
$app.theme = 'light';
} else if ($app.theme === 'light') {
$app.theme = 'dark';
} else {
$app.theme = 'auto';
}
addNotification({
title: 'Theme changed',
message: `Theme changed to ${$app.theme}`,
type: 'success'
});
},
group: 'misc',
icon: 'switch-horizontal'
}
]);
let isOpen = false;
@@ -0,0 +1,189 @@
<script lang="ts">
import { Button, InputNumber } from '$lib/elements/forms';
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
import InputText from '$lib/elements/forms/inputText.svelte';
import { Query } from '@appwrite.io/console';
import { createEventDispatcher } from 'svelte';
import { columns } from '../store';
import { tags, type Column, type Operator, queries } from './store';
const dispatch = createEventDispatcher<{
clear: void;
apply: { applied: number };
}>();
let columnId: string | null = null;
$: column = $columns.find((c) => c.id === columnId) as Column;
dispatch('apply', { applied: $tags.length });
const operators: Record<string, Operator> = {
'starts with': {
toQuery: Query.startsWith,
toTag: (attribute, input) => `**${attribute}** starts with **${input}**`,
types: ['string']
},
'ends with': {
toQuery: Query.endsWith,
toTag: (attribute, input) => `**${attribute}** ends with **${input}**`,
types: ['string']
},
'greater than': {
toQuery: (attr, input) => Query.greaterThan(attr, Number(input)),
toTag: (attribute, input) => `**${attribute}** greater than **${input}**`,
types: ['integer', 'double']
},
'greater than or equal to': {
toQuery: (attr, input) => Query.greaterThanEqual(attr, Number(input)),
toTag: (attribute, input) => `**${attribute}** greater than or equal to **${input}**`,
types: ['integer', 'double']
},
'less than': {
toQuery: Query.lessThan,
toTag: (attribute, input) => `**${attribute}** less than **${input}**`,
types: ['integer', 'double']
},
'less than or equal to': {
toQuery: Query.lessThanEqual,
toTag: (attribute, input) => `**${attribute}** less than or equal to **${input}**`,
types: ['integer', 'double']
},
equal: {
toQuery: Query.equal,
toTag: (attribute, input) => `**${attribute}** equal to **${input}**`,
types: ['string', 'integer', 'double']
},
'not null': {
toQuery: Query.isNotNull,
toTag: (attribute) => `**${attribute}** is not null`,
types: ['string', 'integer', 'double', 'boolean', 'datetime', 'relationship'],
hideInput: true
},
null: {
toQuery: Query.isNull,
toTag: (attribute) => `**${attribute}** is null`,
types: ['string', 'integer', 'double', 'boolean', 'datetime', 'relationship'],
hideInput: true
}
};
$: operatorsForColumn = Object.entries(operators)
.filter(([_, v]) => v.types.includes(column?.type))
.map(([k]) => ({
label: k,
value: k
}));
let operatorKey: string | null = null;
$: operator = operatorKey ? operators[operatorKey] : null;
$: {
columnId;
operatorKey = null;
}
// We cast to any to not cause type errors in the input components
let value: any = null;
$: {
columnId;
value = null;
}
// This Map is keyed by tags, and has a query as the value
function addFilter() {
if (column && operator) {
queries.addFilter({ column, operator, value });
value = null;
}
}
function tagFormat(node: HTMLElement) {
node.innerHTML = node.innerHTML.replace(/\*\*(.*?)\*\*/g, '<b>$1</b>');
}
$: isDisabled = (function getDisabled() {
return !operator || (!operator?.hideInput && !value);
})();
</script>
<div>
<form on:submit|preventDefault={addFilter}>
<div class="selects u-flex u-gap-12 u-margin-block-start-16">
<InputSelect
id="column"
options={$columns.map((c) => ({
label: c.title,
value: c.id
}))}
placeholder="Select column"
bind:value={columnId} />
<InputSelect
id="operator"
disabled={!column}
options={operatorsForColumn}
placeholder="Select operator"
bind:value={operatorKey} />
</div>
{#if column && operator && !operator?.hideInput}
<div class="u-margin-block-start-16">
{#if column.type === 'integer' || column.type === 'double'}
<InputNumber id="value" bind:value placeholder="Enter value" />
{:else}
<InputText id="value" bind:value placeholder="Enter value" />
{/if}
</div>
{/if}
<Button text disabled={isDisabled} class="u-margin-block-start-12" submit>
<i class="icon-plus" />
Add filter
</Button>
</form>
<ul class="u-flex u-flex-wrap u-cross-center u-gap-8 u-margin-block-start-16 tags">
{#each $tags as tag (tag)}
<button
class="tag"
on:click={() => {
queries.removeFilter(tag);
}}>
<span class="text" use:tagFormat>
{tag}
</span>
<i class="icon-x" />
</button>
{/each}
</ul>
</div>
<style lang="scss">
.dropped {
border-radius: 0.5rem;
box-shadow: 0px 16px 32px 0px rgba(55, 59, 77, 0.04);
padding: 1rem;
margin-top: 0.5rem;
width: 37.5rem;
}
.selects {
:global(> *) {
flex: 1;
}
}
.tags {
:global(b) {
font-weight: bold;
}
}
hr {
height: 1px;
width: calc(100% + 2rem);
background-color: hsl(var(--color-border));
margin-block-start: 1rem;
margin-inline: -1rem;
}
</style>
@@ -0,0 +1,91 @@
<script lang="ts">
import { Drop, Modal } from '$lib/components';
import Content from './content.svelte';
import { Button } from '$lib/elements/forms';
import { queries, tags } from './store';
// We need to separate them so we don't trigger Drop's handlers
let showFiltersDesktop = false;
let showFiltersMobile = false;
let applied = $tags.length;
function apply() {
queries.apply();
applied = $tags.length;
}
function clearAll() {
queries.clearAll();
queries.apply();
applied = 0;
}
</script>
<div class="is-not-mobile">
<Drop bind:show={showFiltersDesktop} noArrow>
<Button secondary on:click={() => (showFiltersDesktop = !showFiltersDesktop)}>
<i class="icon-filter u-opacity-50" />
Filters
{#if applied > 0}
<span class="inline-tag">
{applied}
</span>
{/if}
</Button>
<svelte:fragment slot="list">
<div class="dropped card">
<p>Apply filter rules to refine the table view</p>
<Content
on:apply={(e) => (applied = e.detail.applied)}
on:clear={() => (applied = 0)} />
<hr />
<div class="u-flex u-margin-block-start-16 u-main-end u-gap-8">
<Button text on:click={clearAll}>Clear all</Button>
<Button on:click={apply}>Apply</Button>
</div>
</div>
</svelte:fragment>
</Drop>
</div>
<div class="is-only-mobile">
<Button secondary on:click={() => (showFiltersMobile = !showFiltersMobile)}>
<i class="icon-filter u-opacity-50" />
Filters
{#if applied > 0}
<span class="inline-tag">
{applied}
</span>
{/if}
</Button>
<Modal bind:show={showFiltersMobile} size="big">
<svelte:fragment slot="header">Filters</svelte:fragment>
<Content on:apply={(e) => (applied = e.detail.applied)} on:clear={() => (applied = 0)} />
<svelte:fragment slot="footer">
<Button text on:click={clearAll}>Clear all</Button>
<Button on:click={apply}>Apply</Button></svelte:fragment>
</Modal>
</div>
<style lang="scss">
.dropped {
border-radius: 0.5rem;
box-shadow: 0px 16px 32px 0px rgba(55, 59, 77, 0.04);
padding: 1rem;
margin-top: 0.5rem;
width: 37.5rem;
hr {
height: 1px;
width: calc(100% + 2rem);
background-color: hsl(var(--color-border));
margin-block-start: 1rem;
margin-inline: -1rem;
}
}
</style>
@@ -0,0 +1,74 @@
import { goto } from '$app/navigation';
import { derived, get, writable, type Writable } from 'svelte/store';
import type { columns } from '../store';
const columnTypes = ['string', 'integer', 'double', 'boolean', 'datetime', 'relationship'] as const;
type ColumnType = (typeof columnTypes)[number];
type StoreValues<Store> = Store extends Writable<infer T> ? T : never;
export type Column = Omit<StoreValues<typeof columns>[number], 'type'> & {
type: ColumnType;
};
export type Operator = {
toTag: (attribute: string, input?: string | number) => string;
toQuery: (attribute: string, input?: string | number) => string;
types: ColumnType[];
hideInput?: boolean;
};
export function mapToQueryParams(map: Map<string, string>) {
return encodeURIComponent(JSON.stringify(Array.from(map.entries())));
}
export function queryParamToMap(queryParam: string) {
const decodedQueryParam = decodeURIComponent(queryParam);
const queries = JSON.parse(decodedQueryParam) as [string, string][];
return new Map(queries);
}
function initQueries(initialValue = new Map<string, string>()) {
const queries = writable(initialValue);
type AddFilterArgs = {
operator: Operator;
column: Column;
value: string | number;
};
function addFilter({ column, operator, value }: AddFilterArgs) {
queries.update((map) => {
map.set(operator.toTag(column.id, value), operator.toQuery(column.id, value));
return map;
});
}
function removeFilter(tag: string) {
queries.update((map) => {
map.delete(tag);
return map;
});
}
function clearAll() {
queries.set(new Map());
}
function apply() {
const queryParam = mapToQueryParams(get(queries));
const currentLocation = window.location.pathname;
goto(`${currentLocation}?query=${queryParam}`);
}
return {
...queries,
addFilter,
removeFilter,
clearAll,
apply
};
}
export const queries = initQueries();
export const tags = derived(queries, ($queries) => Array.from($queries.keys()));
@@ -1,17 +1,19 @@
<script lang="ts">
import { Empty, Heading, PaginationWithLimit } from '$lib/components';
import { Container, GridHeader } from '$lib/layout';
import { Button } from '$lib/elements/forms';
import { wizard } from '$lib/stores/wizard';
import Create from './createDocument.svelte';
import type { PageData } from './$types';
import { collection, columns } from './store';
import CreateAttribute from './createAttribute.svelte';
import Table from './table.svelte';
import { preferences } from '$lib/stores/preferences';
import { page } from '$app/stores';
import { Empty, Heading, PaginationWithLimit } from '$lib/components';
import ViewSelector from '$lib/components/viewSelector.svelte';
import { Button } from '$lib/elements/forms';
import { Container } from '$lib/layout';
import { preferences } from '$lib/stores/preferences';
import { wizard } from '$lib/stores/wizard';
import type { PageData } from './$types';
import CreateAttributeDropdown from './attributes/createAttributeDropdown.svelte';
import type { Option } from './attributes/store';
import CreateAttribute from './createAttribute.svelte';
import Create from './createDocument.svelte';
import Filters from './(filters)/filters.svelte';
import { collection, columns } from './store';
import Table from './table.svelte';
export let data: PageData;
@@ -39,21 +41,39 @@
</script>
<Container>
<GridHeader
title="Documents"
{columns}
view={data.view}
hideView
isCustomCollection
allowNoColumns>
<Button
disabled={!(hasAttributes && hasValidAttributes)}
on:click={openWizard}
event="create_document">
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create document</span>
</Button>
</GridHeader>
<div class="heading-grid u-main-justify-between u-cross-center">
<Heading tag="h2" size="5">Documents</Heading>
<div class="u-flex u-main-end is-only-mobile">
<Button
disabled={!(hasAttributes && hasValidAttributes)}
on:click={openWizard}
event="create_document">
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create document</span>
</Button>
</div>
<Filters />
<div class="u-flex u-main-end u-gap-16">
<ViewSelector
view={data.view}
{columns}
isCustomCollection
hideView
allowNoColumns
showColsTextMobile />
<div class="is-not-mobile">
<Button
disabled={!(hasAttributes && hasValidAttributes)}
on:click={openWizard}
event="create_document">
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create document</span>
</Button>
</div>
</div>
</div>
{#if hasAttributes && hasValidAttributes}
{#if data.documents.total}
@@ -105,3 +125,17 @@
</Container>
<CreateAttribute bind:showCreate={showCreateAttribute} selectedOption={selectedAttribute} />
<style lang="scss">
.heading-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
@media (min-width: 768px) {
:global(h2) {
grid-column: span 2;
}
}
}
</style>
@@ -4,6 +4,8 @@ import { sdk } from '$lib/stores/sdk';
import { Query } from '@appwrite.io/console';
import type { PageLoad } from './$types';
import { queries, queryParamToMap } from './(filters)/store';
export const load: PageLoad = async ({ params, depends, url, route }) => {
depends(Dependencies.DOCUMENTS);
const page = getPage(url);
@@ -11,6 +13,10 @@ export const load: PageLoad = async ({ params, depends, url, route }) => {
const view = getView(url, route, View.Grid);
const offset = pageToOffset(page, limit);
const paramQueries = url.searchParams.get('query');
const parsedQueries = queryParamToMap(paramQueries || '[]');
queries.set(parsedQueries);
return {
offset,
limit,
@@ -18,7 +24,12 @@ export const load: PageLoad = async ({ params, depends, url, route }) => {
documents: await sdk.forProject.databases.listDocuments(
params.database,
params.collection,
[Query.limit(limit), Query.offset(offset), Query.orderDesc('$createdAt')]
[
Query.limit(limit),
Query.offset(offset),
Query.orderDesc(''),
...parsedQueries.values()
]
)
};
};
@@ -135,9 +135,10 @@
}))} />
</FormList>
<p class="u-text-center u-margin-block-start-24">
Signed in as test@test.com <button
class="u-bold"
on:click|preventDefault={deauthorizeGoogle}>Sign Out?</button>
Signed in
<button class="u-bold" on:click|preventDefault={deauthorizeGoogle}>
Sign Out?
</button>
</p>
{/if}
{:else if $provider.provider === 'supabase'}