diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index 41ecd331..730377c1 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -63,6 +63,7 @@
"electron-squirrel-startup": "^1.0.1",
"fractional-indexing-jittered": "^0.9.1",
"is-hotkey": "^0.2.0",
+ "js-sha256": "^0.11.0",
"kysely": "^0.27.4",
"lodash": "^4.17.21",
"lowlight": "^3.1.0",
@@ -8983,6 +8984,12 @@
"jiti": "bin/jiti.js"
}
},
+ "node_modules/js-sha256": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz",
+ "integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==",
+ "license": "MIT"
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
diff --git a/desktop/package.json b/desktop/package.json
index 9cdc618f..dc5bfd54 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -103,6 +103,7 @@
"electron-squirrel-startup": "^1.0.1",
"fractional-indexing-jittered": "^0.9.1",
"is-hotkey": "^0.2.0",
+ "js-sha256": "^0.11.0",
"kysely": "^0.27.4",
"lodash": "^4.17.21",
"lowlight": "^3.1.0",
diff --git a/desktop/src/components/databases/filters/view-email-field-filter.tsx b/desktop/src/components/databases/filters/view-email-field-filter.tsx
new file mode 100644
index 00000000..f6c4729a
--- /dev/null
+++ b/desktop/src/components/databases/filters/view-email-field-filter.tsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import { EmailFieldNode, ViewFilterNode } from '@/types/databases';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Button } from '@/components/ui/button';
+import { Icon } from '@/components/ui/icon';
+import { getFieldIcon, emailFieldFilterOperators } from '@/lib/databases';
+import { SmartTextInput } from '@/components/ui/smart-text-input';
+import { useNodeAttributeDeleteMutation } from '@/mutations/use-node-attribute-delete-mutation';
+import { useNodeAttributeUpsertMutation } from '@/mutations/use-node-attribute-upsert-mutation';
+import { AttributeTypes } from '@/lib/constants';
+import { useNodeDeleteMutation } from '@/mutations/use-node-delete-mutation';
+
+interface ViewEmailFieldFilterProps {
+ field: EmailFieldNode;
+ filter: ViewFilterNode;
+}
+
+export const ViewEmailFieldFilter = ({
+ field,
+ filter,
+}: ViewEmailFieldFilterProps) => {
+ const [open, setOpen] = React.useState(false);
+ const { mutate: upsertAttribute } = useNodeAttributeUpsertMutation();
+ const { mutate: deleteAttribute } = useNodeAttributeDeleteMutation();
+ const { mutate: deleteFilter } = useNodeDeleteMutation();
+
+ const operator =
+ emailFieldFilterOperators.find(
+ (operator) => operator.value === filter.operator,
+ ) ?? emailFieldFilterOperators[0];
+
+ const textValue =
+ filter.values.length > 0 ? filter.values[0].textValue : null;
+
+ const hideInput =
+ operator.value === 'is_empty' || operator.value === 'is_not_empty';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {emailFieldFilterOperators.map((operator) => (
+ {
+ upsertAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Operator,
+ key: '1',
+ textValue: operator.value,
+ numberValue: null,
+ foreignNodeId: null,
+ });
+
+ if (
+ operator.value === 'is_empty' ||
+ operator.value === 'is_not_empty'
+ ) {
+ deleteAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Value,
+ key: '1',
+ });
+ }
+ }}
+ >
+ {operator.label}
+
+ ))}
+
+
+
+
+ {!hideInput && (
+ {
+ upsertAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Value,
+ key: '1',
+ textValue: value,
+ numberValue: null,
+ foreignNodeId: null,
+ });
+ }}
+ />
+ )}
+
+
+ );
+};
diff --git a/desktop/src/components/databases/filters/view-filter-add-button.tsx b/desktop/src/components/databases/filters/view-filter-add-button.tsx
new file mode 100644
index 00000000..f438c153
--- /dev/null
+++ b/desktop/src/components/databases/filters/view-filter-add-button.tsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { Icon } from '@/components/ui/icon';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command';
+import { useDatabase } from '@/contexts/database';
+import { ViewFilterNode } from '@/types/databases';
+import { getFieldFilterOperators, getFieldIcon } from '@/lib/databases';
+import { useViewFilterCreateMutation } from '@/mutations/use-view-filter-create-mutation';
+
+interface ViewAddFilterButtonProps {
+ viewId: string;
+ existingFilters: ViewFilterNode[];
+}
+
+export const ViewAddFilterButton = ({
+ viewId,
+ existingFilters,
+}: ViewAddFilterButtonProps) => {
+ const database = useDatabase();
+ const { mutate, isPending } = useViewFilterCreateMutation();
+
+ const [open, setOpen] = React.useState(false);
+ const fieldsWithoutFilters = database.fields.filter(
+ (field) => !existingFilters.some((filter) => filter.fieldId === field.id),
+ );
+
+ if (fieldsWithoutFilters.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+ No field found.
+
+
+ {fieldsWithoutFilters.map((field) => (
+ {
+ if (isPending) {
+ return;
+ }
+
+ const operators = getFieldFilterOperators(field.dataType);
+ mutate({
+ viewId,
+ fieldId: field.id,
+ operator: operators[0].value,
+ });
+ setOpen(false);
+ }}
+ >
+
+
+ ))}
+
+
+
+
+
+ );
+};
diff --git a/desktop/src/components/databases/filters/view-filter-field-dropdown.tsx b/desktop/src/components/databases/filters/view-filter-field-dropdown.tsx
deleted file mode 100644
index 82659278..00000000
--- a/desktop/src/components/databases/filters/view-filter-field-dropdown.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import React from 'react';
-import { useDatabase } from '@/contexts/database';
-import { Button } from '@/components/ui/button';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-import { Icon } from '@/components/ui/icon';
-import { getFieldIcon } from '@/lib/databases';
-
-interface ViewFilterFieldDropdownProps {
- value: string;
- onChange: (value: string) => void;
-}
-
-export const ViewFilterFieldDropdown = ({
- value,
- onChange,
-}: ViewFilterFieldDropdownProps) => {
- const database = useDatabase();
- if (database.fields.length === 0) {
- return null;
- }
-
- const selectedField =
- database.fields.find((field) => field.id === value) ?? database.fields[0];
-
- return (
-
-
-
-
-
- {database.fields.map((item) => (
- {
- onChange(item.id);
- }}
- >
-
-
- ))}
-
-
- );
-};
diff --git a/desktop/src/components/databases/filters/view-filter-operator-dropdown.tsx b/desktop/src/components/databases/filters/view-filter-operator-dropdown.tsx
deleted file mode 100644
index 0512176d..00000000
--- a/desktop/src/components/databases/filters/view-filter-operator-dropdown.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react';
-import { Button } from '@/components/ui/button';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-import { Icon } from '@/components/ui/icon';
-import { FieldFilterOperator } from '@/lib/databases';
-
-interface ViewFilterOperatorDropdownProps {
- operators: FieldFilterOperator[];
- value: FieldFilterOperator;
- onChange: (value: FieldFilterOperator) => void;
-}
-
-export const ViewFilterOperatorDropdown = ({
- operators,
- value,
- onChange,
-}: ViewFilterOperatorDropdownProps) => {
- return (
-
-
-
-
-
- {operators.map((item) => (
- {
- onChange(item);
- }}
- >
-
-
- ))}
-
-
- );
-};
diff --git a/desktop/src/components/databases/filters/view-filter.tsx b/desktop/src/components/databases/filters/view-filter.tsx
deleted file mode 100644
index 0e25123d..00000000
--- a/desktop/src/components/databases/filters/view-filter.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import React from 'react';
-import { ViewFilterNode } from '@/types/databases';
-import { ViewFilterFieldDropdown } from '@/components/databases/filters/view-filter-field-dropdown';
-import { ViewFilterOperatorDropdown } from '@/components/databases/filters/view-filter-operator-dropdown';
-import { Icon } from '@/components/ui/icon';
-import { useDatabase } from '@/contexts/database';
-import { getFieldFilterOperators } from '@/lib/databases';
-
-interface ViewFilterProps {
- filter: ViewFilterNode;
- onChange: (filter: ViewFilterNode) => void;
- onRemove: () => void;
-}
-
-export const ViewFilter = ({ filter, onChange, onRemove }: ViewFilterProps) => {
- const database = useDatabase();
- const field = database.fields.find((field) => field.id === filter.fieldId);
- if (!field) {
- return null;
- }
-
- const operators = getFieldFilterOperators(field.dataType);
- if (operators.length === 0) {
- return null;
- }
-
- const operator =
- operators.find((operator) => operator.value === filter.operator) ??
- operators[0];
-
- const showInput = true;
- return (
-
-
{
- onChange({
- ...filter,
- fieldId: field,
- });
- }}
- />
- {
- onChange({
- ...filter,
- operator: operator.value,
- });
- }}
- />
-
- {/* {showInput && (
-
- )} */}
-
input
-
-
-
-
-
- );
-};
diff --git a/desktop/src/components/databases/filters/view-filters-popover.tsx b/desktop/src/components/databases/filters/view-filters-popover.tsx
deleted file mode 100644
index d7ea1589..00000000
--- a/desktop/src/components/databases/filters/view-filters-popover.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from 'react';
-import { Icon } from '@/components/ui/icon';
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from '@/components/ui/popover';
-
-export const ViewFiltersPopover = () => {
- return (
-
-
-
-
-
-
-
- filters goes here
-
-
- );
-};
diff --git a/desktop/src/components/databases/filters/view-filters.tsx b/desktop/src/components/databases/filters/view-filters.tsx
new file mode 100644
index 00000000..b2b75968
--- /dev/null
+++ b/desktop/src/components/databases/filters/view-filters.tsx
@@ -0,0 +1,97 @@
+import React from 'react';
+import { ViewFilterNode } from '@/types/databases';
+import { useDatabase } from '@/contexts/database';
+import { ViewTextFieldFilter } from '@/components/databases/filters/view-text-field-filter';
+import { ViewAddFilterButton } from '@/components/databases/filters/view-filter-add-button';
+import { ViewNumberFieldFilter } from '@/components/databases/filters/view-number-field-filter';
+import { ViewEmailFieldFilter } from '@/components/databases/filters/view-email-field-filter';
+import { ViewUrlFieldFilter } from '@/components/databases/filters/view-url-field-filter';
+import { ViewPhoneFieldFilter } from '@/components/databases/filters/view-phone-field-filter';
+
+interface ViewFiltersProps {
+ viewId: string;
+ filters: ViewFilterNode[];
+}
+
+export const ViewFilters = ({ viewId, filters }: ViewFiltersProps) => {
+ const database = useDatabase();
+
+ return (
+
+ {filters &&
+ filters.map((filter) => {
+ const field = database.fields.find(
+ (field) => field.id === filter.fieldId,
+ );
+
+ if (!field) {
+ return null;
+ }
+
+ switch (field.dataType) {
+ case 'boolean':
+ return null;
+ case 'collaborator':
+ return null;
+ case 'created_at':
+ return null;
+ case 'created_by':
+ return null;
+ case 'date':
+ return null;
+ case 'email':
+ return (
+
+ );
+ case 'file':
+ return null;
+ case 'multi_select':
+ return null;
+ case 'number':
+ return (
+
+ );
+ case 'phone':
+ return (
+
+ );
+ case 'select':
+ return null;
+ case 'text':
+ return (
+
+ );
+
+ case 'url':
+ return (
+
+ );
+
+ default:
+ return null;
+ }
+ })}
+
+
+ );
+};
diff --git a/desktop/src/components/databases/filters/view-number-field-filter.tsx b/desktop/src/components/databases/filters/view-number-field-filter.tsx
new file mode 100644
index 00000000..93445e43
--- /dev/null
+++ b/desktop/src/components/databases/filters/view-number-field-filter.tsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import { NumberFieldNode, ViewFilterNode } from '@/types/databases';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Button } from '@/components/ui/button';
+import { Icon } from '@/components/ui/icon';
+import { getFieldIcon, numberFieldFilterOperators } from '@/lib/databases';
+import { useNodeAttributeDeleteMutation } from '@/mutations/use-node-attribute-delete-mutation';
+import { useNodeAttributeUpsertMutation } from '@/mutations/use-node-attribute-upsert-mutation';
+import { AttributeTypes } from '@/lib/constants';
+import { SmartNumberInput } from '@/components/ui/smart-number-input';
+import { useNodeDeleteMutation } from '@/mutations/use-node-delete-mutation';
+
+interface ViewNumberFieldFilterProps {
+ field: NumberFieldNode;
+ filter: ViewFilterNode;
+}
+
+export const ViewNumberFieldFilter = ({
+ field,
+ filter,
+}: ViewNumberFieldFilterProps) => {
+ const [open, setOpen] = React.useState(false);
+ const { mutate: upsertAttribute } = useNodeAttributeUpsertMutation();
+ const { mutate: deleteAttribute } = useNodeAttributeDeleteMutation();
+ const { mutate: deleteFilter } = useNodeDeleteMutation();
+
+ const operator =
+ numberFieldFilterOperators.find(
+ (operator) => operator.value === filter.operator,
+ ) ?? numberFieldFilterOperators[0];
+
+ const numberValue =
+ filter.values.length > 0 ? filter.values[0].numberValue : null;
+
+ const hideInput =
+ operator.value === 'is_empty' || operator.value === 'is_not_empty';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {numberFieldFilterOperators.map((operator) => (
+ {
+ upsertAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Operator,
+ key: '1',
+ textValue: operator.value,
+ numberValue: null,
+ foreignNodeId: null,
+ });
+
+ if (
+ operator.value === 'is_empty' ||
+ operator.value === 'is_not_empty'
+ ) {
+ deleteAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Value,
+ key: '1',
+ });
+ }
+ }}
+ >
+ {operator.label}
+
+ ))}
+
+
+
+
+ {!hideInput && (
+ {
+ upsertAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Value,
+ key: '1',
+ textValue: null,
+ numberValue: value,
+ foreignNodeId: null,
+ });
+ }}
+ />
+ )}
+
+
+ );
+};
diff --git a/desktop/src/components/databases/filters/view-phone-field-filter.tsx b/desktop/src/components/databases/filters/view-phone-field-filter.tsx
new file mode 100644
index 00000000..ac9c263c
--- /dev/null
+++ b/desktop/src/components/databases/filters/view-phone-field-filter.tsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import { PhoneFieldNode, ViewFilterNode } from '@/types/databases';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Button } from '@/components/ui/button';
+import { Icon } from '@/components/ui/icon';
+import { phoneFieldFilterOperators } from '@/lib/databases';
+import { SmartTextInput } from '@/components/ui/smart-text-input';
+import { useNodeAttributeDeleteMutation } from '@/mutations/use-node-attribute-delete-mutation';
+import { useNodeAttributeUpsertMutation } from '@/mutations/use-node-attribute-upsert-mutation';
+import { AttributeTypes } from '@/lib/constants';
+import { useNodeDeleteMutation } from '@/mutations/use-node-delete-mutation';
+
+interface ViewPhoneFieldFilterProps {
+ field: PhoneFieldNode;
+ filter: ViewFilterNode;
+}
+
+export const ViewPhoneFieldFilter = ({
+ field,
+ filter,
+}: ViewPhoneFieldFilterProps) => {
+ const [open, setOpen] = React.useState(false);
+ const { mutate: upsertAttribute } = useNodeAttributeUpsertMutation();
+ const { mutate: deleteAttribute } = useNodeAttributeDeleteMutation();
+ const { mutate: deleteFilter } = useNodeDeleteMutation();
+
+ const operator =
+ phoneFieldFilterOperators.find(
+ (operator) => operator.value === filter.operator,
+ ) ?? phoneFieldFilterOperators[0];
+
+ const phoneValue =
+ filter.values.length > 0 ? filter.values[0].textValue : null;
+
+ const hideInput =
+ operator.value === 'is_empty' || operator.value === 'is_not_empty';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {phoneFieldFilterOperators.map((operator) => (
+ {
+ upsertAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Operator,
+ key: '1',
+ textValue: operator.value,
+ numberValue: null,
+ foreignNodeId: null,
+ });
+
+ if (
+ operator.value === 'is_empty' ||
+ operator.value === 'is_not_empty'
+ ) {
+ deleteAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Value,
+ key: '1',
+ });
+ }
+ }}
+ >
+ {operator.label}
+
+ ))}
+
+
+
+
+ {!hideInput && (
+ {
+ upsertAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Value,
+ key: '1',
+ textValue: value,
+ numberValue: null,
+ foreignNodeId: null,
+ });
+ }}
+ />
+ )}
+
+
+ );
+};
diff --git a/desktop/src/components/databases/filters/view-text-field-filter.tsx b/desktop/src/components/databases/filters/view-text-field-filter.tsx
new file mode 100644
index 00000000..0a2874e9
--- /dev/null
+++ b/desktop/src/components/databases/filters/view-text-field-filter.tsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import { TextFieldNode, ViewFilterNode } from '@/types/databases';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Button } from '@/components/ui/button';
+import { Icon } from '@/components/ui/icon';
+import { getFieldIcon, textFieldFilterOperators } from '@/lib/databases';
+import { SmartTextInput } from '@/components/ui/smart-text-input';
+import { useNodeAttributeDeleteMutation } from '@/mutations/use-node-attribute-delete-mutation';
+import { useNodeAttributeUpsertMutation } from '@/mutations/use-node-attribute-upsert-mutation';
+import { AttributeTypes } from '@/lib/constants';
+import { useNodeDeleteMutation } from '@/mutations/use-node-delete-mutation';
+
+interface ViewTextFieldFilterProps {
+ field: TextFieldNode;
+ filter: ViewFilterNode;
+}
+
+export const ViewTextFieldFilter = ({
+ field,
+ filter,
+}: ViewTextFieldFilterProps) => {
+ const [open, setOpen] = React.useState(false);
+ const { mutate: upsertAttribute } = useNodeAttributeUpsertMutation();
+ const { mutate: deleteAttribute } = useNodeAttributeDeleteMutation();
+ const { mutate: deleteFilter } = useNodeDeleteMutation();
+
+ const operator =
+ textFieldFilterOperators.find(
+ (operator) => operator.value === filter.operator,
+ ) ?? textFieldFilterOperators[0];
+
+ const textValue =
+ filter.values.length > 0 ? filter.values[0].textValue : null;
+
+ const hideInput =
+ operator.value === 'is_empty' || operator.value === 'is_not_empty';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {textFieldFilterOperators.map((operator) => (
+ {
+ upsertAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Operator,
+ key: '1',
+ textValue: operator.value,
+ numberValue: null,
+ foreignNodeId: null,
+ });
+
+ if (
+ operator.value === 'is_empty' ||
+ operator.value === 'is_not_empty'
+ ) {
+ deleteAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Value,
+ key: '1',
+ });
+ }
+ }}
+ >
+ {operator.label}
+
+ ))}
+
+
+
+
+ {!hideInput && (
+ {
+ upsertAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Value,
+ key: '1',
+ textValue: value,
+ numberValue: null,
+ foreignNodeId: null,
+ });
+ }}
+ />
+ )}
+
+
+ );
+};
diff --git a/desktop/src/components/databases/filters/view-url-field-filter.tsx b/desktop/src/components/databases/filters/view-url-field-filter.tsx
new file mode 100644
index 00000000..728be35c
--- /dev/null
+++ b/desktop/src/components/databases/filters/view-url-field-filter.tsx
@@ -0,0 +1,133 @@
+import React from 'react';
+import { UrlFieldNode, ViewFilterNode } from '@/types/databases';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Button } from '@/components/ui/button';
+import { Icon } from '@/components/ui/icon';
+import { urlFieldFilterOperators } from '@/lib/databases';
+import { SmartTextInput } from '@/components/ui/smart-text-input';
+import { useNodeAttributeDeleteMutation } from '@/mutations/use-node-attribute-delete-mutation';
+import { useNodeAttributeUpsertMutation } from '@/mutations/use-node-attribute-upsert-mutation';
+import { AttributeTypes } from '@/lib/constants';
+import { useNodeDeleteMutation } from '@/mutations/use-node-delete-mutation';
+
+interface ViewUrlFieldFilterProps {
+ field: UrlFieldNode;
+ filter: ViewFilterNode;
+}
+
+export const ViewUrlFieldFilter = ({
+ field,
+ filter,
+}: ViewUrlFieldFilterProps) => {
+ const [open, setOpen] = React.useState(false);
+ const { mutate: upsertAttribute } = useNodeAttributeUpsertMutation();
+ const { mutate: deleteAttribute } = useNodeAttributeDeleteMutation();
+ const { mutate: deleteFilter } = useNodeDeleteMutation();
+
+ const operator =
+ urlFieldFilterOperators.find(
+ (operator) => operator.value === filter.operator,
+ ) ?? urlFieldFilterOperators[0];
+
+ const urlValue = filter.values.length > 0 ? filter.values[0].textValue : null;
+
+ const hideInput =
+ operator.value === 'is_empty' || operator.value === 'is_not_empty';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {urlFieldFilterOperators.map((operator) => (
+ {
+ upsertAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Operator,
+ key: '1',
+ textValue: operator.value,
+ numberValue: null,
+ foreignNodeId: null,
+ });
+
+ if (
+ operator.value === 'is_empty' ||
+ operator.value === 'is_not_empty'
+ ) {
+ deleteAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Value,
+ key: '1',
+ });
+ }
+ }}
+ >
+ {operator.label}
+
+ ))}
+
+
+
+
+ {!hideInput && (
+ {
+ upsertAttribute({
+ nodeId: filter.id,
+ type: AttributeTypes.Value,
+ key: '1',
+ textValue: value,
+ numberValue: null,
+ foreignNodeId: null,
+ });
+ }}
+ />
+ )}
+
+
+ );
+};
diff --git a/desktop/src/components/databases/tables/table-view-actions.tsx b/desktop/src/components/databases/tables/table-view-actions.tsx
deleted file mode 100644
index da90abec..00000000
--- a/desktop/src/components/databases/tables/table-view-actions.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-import { ViewSortButton } from '@/components/databases/view-sort-button';
-import { ViewFiltersPopover } from '@/components/databases/filters/view-filters-popover';
-import { TableViewSettingsPopover } from '@/components/databases/tables/table-view-settings-popover';
-
-export const TableViewActions = () => {
- return (
-
- );
-};
diff --git a/desktop/src/components/databases/tables/table-view-body.tsx b/desktop/src/components/databases/tables/table-view-body.tsx
index 3331b515..a07da282 100644
--- a/desktop/src/components/databases/tables/table-view-body.tsx
+++ b/desktop/src/components/databases/tables/table-view-body.tsx
@@ -4,12 +4,14 @@ import { TableViewRow } from '@/components/databases/tables/table-view-row';
import { TableViewEmptyPlaceholder } from '@/components/databases/tables/table-view-empty-placeholder';
import { TableViewLoadMoreRow } from './table-view-load-more-row';
import { useRecordsQuery } from '@/queries/use-records-query';
+import { useTableView } from '@/contexts/table-view';
export const TableViewBody = () => {
const database = useDatabase();
+ const tableView = useTableView();
const { data, isPending, isFetchingNextPage, fetchNextPage, hasNextPage } =
- useRecordsQuery(database.id);
+ useRecordsQuery(database.id, tableView.filters);
const records = data ?? [];
return (
diff --git a/desktop/src/components/databases/tables/table-view.tsx b/desktop/src/components/databases/tables/table-view.tsx
index 48b195b4..4faeea7f 100644
--- a/desktop/src/components/databases/tables/table-view.tsx
+++ b/desktop/src/components/databases/tables/table-view.tsx
@@ -6,14 +6,16 @@ import { TableViewContext } from '@/contexts/table-view';
import { useDatabase } from '@/contexts/database';
import { compareString } from '@/lib/utils';
import { FieldDataType, TableViewNode } from '@/types/databases';
+import { ViewTabs } from '@/components/databases/view-tabs';
+import { TableViewSettingsPopover } from '@/components/databases/tables/table-view-settings-popover';
import { getDefaultFieldWidth, getDefaultNameWidth } from '@/lib/databases';
import { generateNodeIndex } from '@/lib/nodes';
import { useUpdateViewFieldIndexMutation } from '@/mutations/use-update-view-field-index-mutation';
import { useUpdateViewNameWidthMutation } from '@/mutations/use-update-view-name-width-mutation';
import { useUpdateViewFieldWidthMutation } from '@/mutations/use-update-view-field-width-mutation';
import { useUpdateViewHiddenFieldMutation } from '@/mutations/use-update-hidden-field-mutation';
-import { ViewTabs } from '@/components/databases/view-tabs';
-import { TableViewActions } from '@/components/databases/tables/table-view-actions';
+import { ViewFilters } from '@/components/databases/filters/view-filters';
+import { ViewActionButton } from '@/components/databases/view-action-button';
interface TableViewProps {
node: TableViewNode;
@@ -40,6 +42,9 @@ export const TableView = ({ node }: TableViewProps) => {
node.nameWidth ?? getDefaultNameWidth(),
);
+ const [openFilters, setOpenFilters] = React.useState(true);
+ const [openSort, setOpenSort] = React.useState(false);
+
React.useEffect(() => {
setHiddenFields(node.hiddenFields ?? []);
setFieldIndexes(node.fieldIndexes ?? {});
@@ -64,6 +69,7 @@ export const TableView = ({ node }: TableViewProps) => {
id: node.id,
name: node.name,
fields,
+ filters: node.filters,
hideField: (id: string) => {
if (hiddenFields.includes(id)) {
return;
@@ -184,8 +190,19 @@ export const TableView = ({ node }: TableViewProps) => {
>
-
+
+
+
setOpenSort((prev) => !prev)}
+ />
+ setOpenFilters((prev) => !prev)}
+ />
+
+ {openFilters && }
diff --git a/desktop/src/components/databases/view-action-button.tsx b/desktop/src/components/databases/view-action-button.tsx
new file mode 100644
index 00000000..ef1d5af3
--- /dev/null
+++ b/desktop/src/components/databases/view-action-button.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { Icon } from '@/components/ui/icon';
+
+interface ViewActionButtonProps {
+ onClick: () => void;
+ icon: string;
+}
+
+export const ViewActionButton = ({ onClick, icon }: ViewActionButtonProps) => {
+ return (
+
+ );
+};
diff --git a/desktop/src/components/databases/view-sort-button.tsx b/desktop/src/components/databases/view-sort-button.tsx
deleted file mode 100644
index 4b675802..00000000
--- a/desktop/src/components/databases/view-sort-button.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import React from 'react';
-import { Icon } from '@/components/ui/icon';
-
-export const ViewSortButton = () => {
- return (
-
-
-
- );
-};
diff --git a/desktop/src/components/ui/smart-number-input.tsx b/desktop/src/components/ui/smart-number-input.tsx
new file mode 100644
index 00000000..0dc2c070
--- /dev/null
+++ b/desktop/src/components/ui/smart-number-input.tsx
@@ -0,0 +1,103 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+import debounce from 'lodash/debounce';
+
+interface SmartNumberInputProps {
+ value: number | null;
+ onChange: (newValue: number) => void;
+ className?: string;
+ min?: number;
+ max?: number;
+ step?: number;
+}
+
+const SmartNumberInput = React.forwardRef<
+ HTMLInputElement,
+ SmartNumberInputProps
+>(({ value, onChange, className, min, max, step = 1, ...props }, ref) => {
+ const [localValue, setLocalValue] = React.useState(value?.toString() ?? '');
+ const initialValue = React.useRef(value?.toString() ?? '');
+
+ // Create a debounced version of onChange
+ const debouncedOnChange = React.useMemo(
+ () => debounce((value: number) => onChange(value), 500),
+ [onChange],
+ );
+
+ // Update localValue when value prop changes
+ React.useEffect(() => {
+ setLocalValue(value?.toString() ?? '');
+ initialValue.current = value?.toString() ?? '';
+ }, [value]);
+
+ // Cleanup debounce on unmount
+ React.useEffect(() => {
+ return () => {
+ debouncedOnChange.cancel();
+ };
+ }, [debouncedOnChange]);
+
+ const handleBlur = () => {
+ const newValue = parseFloat(localValue);
+ if (!isNaN(newValue) && localValue !== initialValue.current) {
+ debouncedOnChange.cancel(); // Cancel any pending debounced calls
+ onChange(applyConstraints(newValue));
+ }
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent
) => {
+ if (event.key === 'Escape') {
+ setLocalValue(initialValue.current); // Revert to initial value
+ debouncedOnChange.cancel(); // Cancel any pending debounced calls
+ } else if (event.key === 'Enter') {
+ const newValue = parseFloat(localValue);
+ if (!isNaN(newValue) && localValue !== initialValue.current) {
+ onChange(applyConstraints(newValue)); // Fire onChange immediately when Enter is pressed
+ debouncedOnChange.cancel(); // Cancel any pending debounced calls
+ }
+ }
+ };
+
+ const handleChange = (event: React.ChangeEvent) => {
+ const newValue = event.target.value;
+ setLocalValue(newValue);
+ const parsedValue = parseFloat(newValue);
+ if (!isNaN(parsedValue)) {
+ debouncedOnChange(applyConstraints(parsedValue)); // Trigger debounced onChange
+ }
+ };
+
+ const applyConstraints = (value: number): number => {
+ let constrainedValue = value;
+ if (min !== undefined) {
+ constrainedValue = Math.max(min, constrainedValue);
+ }
+ if (max !== undefined) {
+ constrainedValue = Math.min(max, constrainedValue);
+ }
+ return Math.round(constrainedValue / step) * step;
+ };
+
+ return (
+
+ );
+});
+
+SmartNumberInput.displayName = 'SmartNumberInput';
+
+export { SmartNumberInput };
diff --git a/desktop/src/components/ui/smart-text-input.tsx b/desktop/src/components/ui/smart-text-input.tsx
new file mode 100644
index 00000000..291a0e4f
--- /dev/null
+++ b/desktop/src/components/ui/smart-text-input.tsx
@@ -0,0 +1,80 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+import debounce from 'lodash/debounce';
+
+interface SmartTextInputProps {
+ value: string | null;
+ onChange: (newValue: string) => void;
+ className?: string;
+}
+
+const SmartTextInput = React.forwardRef(
+ ({ value, onChange, className, ...props }, ref) => {
+ const [localValue, setLocalValue] = React.useState(value ?? '');
+ const initialValue = React.useRef(value ?? '');
+
+ // Create a debounced version of onChange
+ const debouncedOnChange = React.useMemo(
+ () => debounce((value: string) => onChange(value), 500),
+ [onChange],
+ );
+
+ // Update localValue when value prop changes
+ React.useEffect(() => {
+ setLocalValue(value ?? '');
+ initialValue.current = value ?? '';
+ }, [value]);
+
+ // Cleanup debounce on unmount
+ React.useEffect(() => {
+ return () => {
+ debouncedOnChange.cancel();
+ };
+ }, [debouncedOnChange]);
+
+ const handleBlur = () => {
+ if (localValue !== initialValue.current) {
+ debouncedOnChange.cancel(); // Cancel any pending debounced calls
+ onChange(localValue);
+ }
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setLocalValue(initialValue.current); // Revert to initial value
+ debouncedOnChange.cancel(); // Cancel any pending debounced calls
+ } else if (event.key === 'Enter') {
+ if (localValue !== initialValue.current) {
+ onChange(localValue); // Fire onChange immediately when Enter is pressed
+ debouncedOnChange.cancel(); // Cancel any pending debounced calls
+ }
+ }
+ };
+
+ const handleChange = (event: React.ChangeEvent) => {
+ const newValue = event.target.value;
+ setLocalValue(newValue);
+ debouncedOnChange(newValue); // Trigger debounced onChange
+ };
+
+ return (
+
+ );
+ },
+);
+
+SmartTextInput.displayName = 'SmartTextInput';
+
+export { SmartTextInput };
diff --git a/desktop/src/contexts/table-view.ts b/desktop/src/contexts/table-view.ts
index 8ebca77f..16fbfb8e 100644
--- a/desktop/src/contexts/table-view.ts
+++ b/desktop/src/contexts/table-view.ts
@@ -1,10 +1,11 @@
-import { FieldNode, FieldDataType } from '@/types/databases';
+import { FieldNode, FieldDataType, ViewFilterNode } from '@/types/databases';
import { createContext, useContext } from 'react';
interface TableViewContext {
id: string;
name: string;
fields: FieldNode[];
+ filters: ViewFilterNode[];
hideField: (id: string) => void;
showField: (id: string) => void;
isHiddenField: (id: string) => boolean;
diff --git a/desktop/src/contexts/view.ts b/desktop/src/contexts/view.ts
deleted file mode 100644
index 24c40677..00000000
--- a/desktop/src/contexts/view.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { ViewFilterNode } from '@/types/databases';
-import { createContext, useContext } from 'react';
-
-interface ViewContext {
- id: string;
- filters: ViewFilterNode[];
- addFilter: (filter: ViewFilterNode) => void;
- removeFilter: (id: string) => void;
- updateFilter: (id: string, filter: ViewFilterNode) => void;
-}
-
-export const ViewContext = createContext({} as ViewContext);
-
-export const useView = () => useContext(ViewContext);
diff --git a/desktop/src/lib/constants.ts b/desktop/src/lib/constants.ts
index 9bcd0469..1caf3c5b 100644
--- a/desktop/src/lib/constants.ts
+++ b/desktop/src/lib/constants.ts
@@ -26,6 +26,7 @@ export const NodeTypes = {
CalendarView: 'calendar_view',
Field: 'field',
SelectOption: 'select_option',
+ ViewFilter: 'view_filter',
};
export const EditorNodeTypes = {
@@ -78,4 +79,7 @@ export const AttributeTypes = {
FieldWidth: 'field_width',
FieldIndex: 'field_index',
NameWidth: 'name_width',
+ FieldId: 'field_id',
+ Operator: 'operator',
+ Value: 'value',
};
diff --git a/desktop/src/lib/databases.ts b/desktop/src/lib/databases.ts
index 1db656fc..03d4b6ad 100644
--- a/desktop/src/lib/databases.ts
+++ b/desktop/src/lib/databases.ts
@@ -166,14 +166,6 @@ export const booleanFieldFilterOperators: FieldFilterOperator[] = [
];
export const collaboratorFieldFilterOperators: FieldFilterOperator[] = [
- {
- label: 'Is Empty',
- value: 'is_empty',
- },
- {
- label: 'Is Not Empty',
- value: 'is_not_empty',
- },
{
label: 'Is Me',
value: 'is_me',
@@ -190,6 +182,14 @@ export const collaboratorFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not In',
value: 'is_not_in',
},
+ {
+ label: 'Is Empty',
+ value: 'is_empty',
+ },
+ {
+ label: 'Is Not Empty',
+ value: 'is_not_empty',
+ },
];
export const createdAtFieldFilterOperators: FieldFilterOperator[] = [
@@ -223,14 +223,6 @@ export const createdByFieldFilterOperators: FieldFilterOperator[] = [
];
export const dateFieldFilterOperators: FieldFilterOperator[] = [
- {
- label: 'Is Empty',
- value: 'is_empty',
- },
- {
- label: 'Is Not Empty',
- value: 'is_not_empty',
- },
{
label: 'Is Equal To',
value: 'is_equal_to',
@@ -239,9 +231,6 @@ export const dateFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not Equal To',
value: 'is_not_equal_to',
},
-];
-
-export const emailFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Empty',
value: 'is_empty',
@@ -250,6 +239,9 @@ export const emailFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not Empty',
value: 'is_not_empty',
},
+];
+
+export const emailFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Equal To',
value: 'is_equal_to',
@@ -266,9 +258,6 @@ export const emailFieldFilterOperators: FieldFilterOperator[] = [
label: 'Does Not Contain',
value: 'does_not_contain',
},
-];
-
-export const fileFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Empty',
value: 'is_empty',
@@ -277,6 +266,9 @@ export const fileFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not Empty',
value: 'is_not_empty',
},
+];
+
+export const fileFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is In',
value: 'is_in',
@@ -285,17 +277,17 @@ export const fileFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not In',
value: 'is_not_in',
},
+ {
+ label: 'Is Empty',
+ value: 'is_empty',
+ },
+ {
+ label: 'Is Not Empty',
+ value: 'is_not_empty',
+ },
];
export const multiSelectFieldFilterOperators: FieldFilterOperator[] = [
- {
- label: 'Is Empty',
- value: 'is_empty',
- },
- {
- label: 'Is Not Empty',
- value: 'is_not_empty',
- },
{
label: 'Is In',
value: 'is_in',
@@ -304,9 +296,6 @@ export const multiSelectFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not In',
value: 'is_not_in',
},
-];
-
-export const numberFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Empty',
value: 'is_empty',
@@ -315,6 +304,9 @@ export const numberFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not Empty',
value: 'is_not_empty',
},
+];
+
+export const numberFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Equal To',
value: 'is_equal_to',
@@ -339,6 +331,14 @@ export const numberFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Less Than Or Equal To',
value: 'is_less_than_or_equal_to',
},
+ {
+ label: 'Is Empty',
+ value: 'is_empty',
+ },
+ {
+ label: 'Is Not Empty',
+ value: 'is_not_empty',
+ },
];
export const phoneFieldFilterOperators: FieldFilterOperator[] = [
@@ -369,14 +369,6 @@ export const phoneFieldFilterOperators: FieldFilterOperator[] = [
];
export const selectFieldFilterOperators: FieldFilterOperator[] = [
- {
- label: 'Is Empty',
- value: 'is_empty',
- },
- {
- label: 'Is Not Empty',
- value: 'is_not_empty',
- },
{
label: 'Is In',
value: 'is_in',
@@ -385,9 +377,6 @@ export const selectFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not In',
value: 'is_not_in',
},
-];
-
-export const textFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Empty',
value: 'is_empty',
@@ -396,6 +385,17 @@ export const textFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not Empty',
value: 'is_not_empty',
},
+];
+
+export const textFieldFilterOperators: FieldFilterOperator[] = [
+ {
+ label: 'Contains',
+ value: 'contains',
+ },
+ {
+ label: 'Does Not Contain',
+ value: 'does_not_contain',
+ },
{
label: 'Is Equal To',
value: 'is_equal_to',
@@ -404,25 +404,18 @@ export const textFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not Equal To',
value: 'is_not_equal_to',
},
+
{
- label: 'Contains',
- value: 'contains',
+ label: 'Is Empty',
+ value: 'is_empty',
},
{
- label: 'Does Not Contain',
- value: 'does_not_contain',
+ label: 'Is Not Empty',
+ value: 'is_not_empty',
},
];
export const urlFieldFilterOperators: FieldFilterOperator[] = [
- {
- label: 'Is Empty',
- value: 'is_empty',
- },
- {
- label: 'Is Not Empty',
- value: 'is_not_empty',
- },
{
label: 'Is Equal To',
value: 'is_equal_to',
@@ -439,6 +432,14 @@ export const urlFieldFilterOperators: FieldFilterOperator[] = [
label: 'Does Not Contain',
value: 'does_not_contain',
},
+ {
+ label: 'Is Empty',
+ value: 'is_empty',
+ },
+ {
+ label: 'Is Not Empty',
+ value: 'is_not_empty',
+ },
];
export const getFieldFilterOperators = (
diff --git a/desktop/src/lib/id.ts b/desktop/src/lib/id.ts
index 858ab5ac..ff315cd9 100644
--- a/desktop/src/lib/id.ts
+++ b/desktop/src/lib/id.ts
@@ -36,6 +36,7 @@ enum IdType {
CalendarView = 'cv',
Field = 'fi',
SelectOption = 'so',
+ ViewFilter = 'vf',
}
export class NeuronId {
@@ -105,6 +106,8 @@ export class NeuronId {
return IdType.Field;
case NodeTypes.SelectOption:
return IdType.SelectOption;
+ case NodeTypes.ViewFilter:
+ return IdType.ViewFilter;
default:
return IdType.Node;
}
diff --git a/desktop/src/mutations/use-node-attribute-delete-mutation.tsx b/desktop/src/mutations/use-node-attribute-delete-mutation.tsx
new file mode 100644
index 00000000..aa66c01b
--- /dev/null
+++ b/desktop/src/mutations/use-node-attribute-delete-mutation.tsx
@@ -0,0 +1,29 @@
+import { useWorkspace } from '@/contexts/workspace';
+import { useMutation } from '@tanstack/react-query';
+
+interface NodeAttributeDeleteInput {
+ nodeId: string;
+ type: string;
+ key: string;
+}
+
+export const useNodeAttributeDeleteMutation = () => {
+ const workspace = useWorkspace();
+
+ return useMutation({
+ mutationFn: async (input: NodeAttributeDeleteInput) => {
+ const query = workspace.schema
+ .deleteFrom('node_attributes')
+ .where((eb) =>
+ eb.and([
+ eb('node_id', '=', input.nodeId),
+ eb('type', '=', input.type),
+ eb('key', '=', input.key),
+ ]),
+ )
+ .compile();
+
+ await workspace.mutate(query);
+ },
+ });
+};
diff --git a/desktop/src/mutations/use-node-attribute-upsert-mutation.tsx b/desktop/src/mutations/use-node-attribute-upsert-mutation.tsx
new file mode 100644
index 00000000..96122bba
--- /dev/null
+++ b/desktop/src/mutations/use-node-attribute-upsert-mutation.tsx
@@ -0,0 +1,47 @@
+import { useWorkspace } from '@/contexts/workspace';
+import { NeuronId } from '@/lib/id';
+import { useMutation } from '@tanstack/react-query';
+
+interface NodeAttributeUpsertInput {
+ nodeId: string;
+ type: string;
+ key: string;
+ textValue: string | null;
+ numberValue: number | null;
+ foreignNodeId: string | null;
+}
+
+export const useNodeAttributeUpsertMutation = () => {
+ const workspace = useWorkspace();
+
+ return useMutation({
+ mutationFn: async (input: NodeAttributeUpsertInput) => {
+ const query = workspace.schema
+ .insertInto('node_attributes')
+ .values({
+ node_id: input.nodeId,
+ type: input.type,
+ key: input.key,
+ text_value: input.textValue,
+ number_value: input.numberValue,
+ foreign_node_id: input.foreignNodeId,
+ created_at: new Date().toISOString(),
+ created_by: workspace.userId,
+ version_id: NeuronId.generate(NeuronId.Type.Version),
+ })
+ .onConflict((b) =>
+ b.doUpdateSet({
+ text_value: input.textValue,
+ number_value: input.numberValue,
+ foreign_node_id: input.foreignNodeId,
+ updated_at: new Date().toISOString(),
+ updated_by: workspace.userId,
+ version_id: NeuronId.generate(NeuronId.Type.Version),
+ }),
+ )
+ .compile();
+
+ await workspace.mutate(query);
+ },
+ });
+};
diff --git a/desktop/src/mutations/use-view-filter-create-mutation.tsx b/desktop/src/mutations/use-view-filter-create-mutation.tsx
new file mode 100644
index 00000000..64a837f9
--- /dev/null
+++ b/desktop/src/mutations/use-view-filter-create-mutation.tsx
@@ -0,0 +1,81 @@
+import { useWorkspace } from '@/contexts/workspace';
+import { AttributeTypes, NodeTypes } from '@/lib/constants';
+import { NeuronId } from '@/lib/id';
+import { generateNodeIndex } from '@/lib/nodes';
+import { useMutation } from '@tanstack/react-query';
+
+interface CreateViewFilterInput {
+ viewId: string;
+ fieldId: string;
+ operator: string;
+}
+
+export const useViewFilterCreateMutation = () => {
+ const workspace = useWorkspace();
+ return useMutation({
+ mutationFn: async (input: CreateViewFilterInput) => {
+ const lastChildQuery = workspace.schema
+ .selectFrom('nodes')
+ .where((eb) =>
+ eb.and({
+ parent_id: input.viewId,
+ type: NodeTypes.ViewFilter,
+ }),
+ )
+ .selectAll()
+ .orderBy('index', 'desc')
+ .limit(1)
+ .compile();
+
+ const result = await workspace.query(lastChildQuery);
+ const lastChild =
+ result.rows && result.rows.length > 0 ? result.rows[0] : null;
+ const maxIndex = lastChild?.index ? lastChild.index : null;
+
+ const viewFilterId = NeuronId.generate(NeuronId.Type.ViewFilter);
+ const insertViewFilterQuery = workspace.schema
+ .insertInto('nodes')
+ .values({
+ id: viewFilterId,
+ type: NodeTypes.ViewFilter,
+ parent_id: input.viewId,
+ index: generateNodeIndex(maxIndex, null),
+ content: null,
+ created_at: new Date().toISOString(),
+ created_by: workspace.userId,
+ version_id: NeuronId.generate(NeuronId.Type.Version),
+ })
+ .compile();
+
+ const insertViewFilterAttributesQuery = workspace.schema
+ .insertInto('node_attributes')
+ .values([
+ {
+ node_id: viewFilterId,
+ type: AttributeTypes.FieldId,
+ key: '1',
+ foreign_node_id: input.fieldId,
+ created_at: new Date().toISOString(),
+ created_by: workspace.userId,
+ version_id: NeuronId.generate(NeuronId.Type.Version),
+ },
+ {
+ node_id: viewFilterId,
+ type: AttributeTypes.Operator,
+ key: '1',
+ text_value: input.operator,
+ created_at: new Date().toISOString(),
+ created_by: workspace.userId,
+ version_id: NeuronId.generate(NeuronId.Type.Version),
+ },
+ ])
+ .compile();
+
+ await workspace.mutate([
+ insertViewFilterQuery,
+ insertViewFilterAttributesQuery,
+ ]);
+ return viewFilterId;
+ },
+ });
+};
diff --git a/desktop/src/mutations/use-view-filter-operator-update-mutation.tsx b/desktop/src/mutations/use-view-filter-operator-update-mutation.tsx
new file mode 100644
index 00000000..21cbfe80
--- /dev/null
+++ b/desktop/src/mutations/use-view-filter-operator-update-mutation.tsx
@@ -0,0 +1,43 @@
+import { useWorkspace } from '@/contexts/workspace';
+import { AttributeTypes } from '@/lib/constants';
+import { NeuronId } from '@/lib/id';
+import { useMutation } from '@tanstack/react-query';
+
+interface ViewFilterOperatorUpdateMutationInput {
+ filterId: string;
+ operator: string;
+}
+
+export const useViewFilterOperatorUpdateMutation = () => {
+ const workspace = useWorkspace();
+
+ return useMutation({
+ mutationFn: async ({
+ filterId,
+ operator,
+ }: ViewFilterOperatorUpdateMutationInput) => {
+ const query = workspace.schema
+ .insertInto('node_attributes')
+ .values({
+ node_id: filterId,
+ type: AttributeTypes.Operator,
+ key: '1',
+ text_value: operator,
+ created_at: new Date().toISOString(),
+ created_by: workspace.userId,
+ version_id: NeuronId.generate(NeuronId.Type.Version),
+ })
+ .onConflict((b) =>
+ b.doUpdateSet({
+ text_value: operator,
+ updated_at: new Date().toISOString(),
+ updated_by: workspace.userId,
+ version_id: NeuronId.generate(NeuronId.Type.Version),
+ }),
+ )
+ .compile();
+
+ await workspace.mutate(query);
+ },
+ });
+};
diff --git a/desktop/src/queries/use-database-query.tsx b/desktop/src/queries/use-database-query.tsx
index 5ddb3f20..d5673d92 100644
--- a/desktop/src/queries/use-database-query.tsx
+++ b/desktop/src/queries/use-database-query.tsx
@@ -10,6 +10,8 @@ import {
FieldNode,
SelectOptionNode,
TableViewNode,
+ ViewFilterNode,
+ ViewFilterValueNode,
ViewNode,
} from '@/types/databases';
import { LocalNodeWithAttributes } from '@/types/nodes';
@@ -52,6 +54,16 @@ export const useDatabaseQuery = (databaseId: string) => {
)
AND type = ${NodeTypes.SelectOption}
),
+ view_filter_nodes AS (
+ SELECT *
+ FROM nodes
+ WHERE parent_id IN
+ (
+ SELECT id
+ FROM view_nodes
+ )
+ AND type = ${NodeTypes.ViewFilter}
+ ),
all_nodes AS (
SELECT * FROM database_node
UNION ALL
@@ -60,6 +72,8 @@ export const useDatabaseQuery = (databaseId: string) => {
SELECT * FROM view_nodes
UNION ALL
SELECT * FROM select_option_nodes
+ UNION ALL
+ SELECT * FROM view_filter_nodes
)
SELECT
n.*,
@@ -145,7 +159,11 @@ const buildDatabaseNode = (
const viewNodes = nodes.filter((node) => ViewNodeTypes.includes(node.type));
const views: ViewNode[] = [];
for (const viewNode of viewNodes) {
- const view = buildViewNode(viewNode);
+ const viewFilters = nodes.filter(
+ (node) =>
+ node.type === NodeTypes.ViewFilter && node.parentId === viewNode.id,
+ );
+ const view = buildViewNode(viewNode, viewFilters);
if (view) {
views.push(view);
}
@@ -290,19 +308,25 @@ const buildSelectOption = (node: LocalNodeWithAttributes): SelectOptionNode => {
};
};
-const buildViewNode = (node: LocalNodeWithAttributes): ViewNode | null => {
+const buildViewNode = (
+ node: LocalNodeWithAttributes,
+ filters: LocalNodeWithAttributes[],
+): ViewNode | null => {
if (node.type === NodeTypes.TableView) {
- return buildTableViewNode(node);
+ return buildTableViewNode(node, filters);
} else if (node.type === NodeTypes.BoardView) {
- return buildBoardViewNode(node);
+ return buildBoardViewNode(node, filters);
} else if (node.type === NodeTypes.CalendarView) {
- return buildCalendarViewNode(node);
+ return buildCalendarViewNode(node, filters);
}
return null;
};
-const buildTableViewNode = (node: LocalNodeWithAttributes): TableViewNode => {
+const buildTableViewNode = (
+ node: LocalNodeWithAttributes,
+ filters: LocalNodeWithAttributes[],
+): TableViewNode => {
const name = node.attributes.find(
(attribute) => attribute.type === AttributeTypes.Name,
)?.textValue;
@@ -339,6 +363,8 @@ const buildTableViewNode = (node: LocalNodeWithAttributes): TableViewNode => {
(attribute) => attribute.type === AttributeTypes.NameWidth,
)?.numberValue;
+ const viewFilters = filters.map(buildViewFilterNode);
+
return {
id: node.id,
name: name ?? 'Unnamed',
@@ -348,34 +374,67 @@ const buildTableViewNode = (node: LocalNodeWithAttributes): TableViewNode => {
fieldWidths,
nameWidth: nameWidth,
versionId: node.versionId,
- filters: [],
+ filters: viewFilters,
};
};
-const buildBoardViewNode = (node: LocalNodeWithAttributes): BoardViewNode => {
+const buildBoardViewNode = (
+ node: LocalNodeWithAttributes,
+ filters: LocalNodeWithAttributes[],
+): BoardViewNode => {
const name = node.attributes.find(
(attribute) => attribute.type === AttributeTypes.Name,
)?.textValue;
+ const viewFilters = filters.map(buildViewFilterNode);
+
return {
id: node.id,
name: name ?? 'Unnamed',
type: 'board_view',
- filters: [],
+ filters: viewFilters,
};
};
const buildCalendarViewNode = (
node: LocalNodeWithAttributes,
+ filters: LocalNodeWithAttributes[],
): CalendarViewNode => {
const name = node.attributes.find(
(attribute) => attribute.type === AttributeTypes.Name,
)?.textValue;
+ const viewFilters = filters.map(buildViewFilterNode);
+
return {
id: node.id,
name: name ?? 'Unnamed',
type: 'calendar_view',
- filters: [],
+ filters: viewFilters,
+ };
+};
+
+const buildViewFilterNode = (node: LocalNodeWithAttributes): ViewFilterNode => {
+ const fieldId = node.attributes.find(
+ (attribute) => attribute.type === AttributeTypes.FieldId,
+ )?.foreignNodeId;
+
+ const operator = node.attributes.find(
+ (attribute) => attribute.type === AttributeTypes.Operator,
+ )?.textValue;
+
+ const values: ViewFilterValueNode[] = node.attributes
+ .filter((attribute) => attribute.type === AttributeTypes.Value)
+ .map((attribute) => ({
+ textValue: attribute.textValue,
+ numberValue: attribute.numberValue,
+ foreignNodeId: attribute.foreignNodeId,
+ }));
+
+ return {
+ id: node.id,
+ fieldId,
+ operator,
+ values,
};
};
diff --git a/desktop/src/queries/use-records-query.tsx b/desktop/src/queries/use-records-query.tsx
index 388031cd..9ed591e5 100644
--- a/desktop/src/queries/use-records-query.tsx
+++ b/desktop/src/queries/use-records-query.tsx
@@ -1,17 +1,38 @@
+import { useDatabase } from '@/contexts/database';
import { useWorkspace } from '@/contexts/workspace';
import { SelectNode, SelectNodeWithAttributes } from '@/data/schemas/workspace';
import { AttributeTypes, NodeTypes } from '@/lib/constants';
import { mapNodeWithAttributes } from '@/lib/nodes';
import { compareString } from '@/lib/utils';
-import { RecordNode } from '@/types/databases';
+import {
+ BooleanFieldNode,
+ EmailFieldNode,
+ FieldNode,
+ MultiSelectFieldNode,
+ NumberFieldNode,
+ PhoneFieldNode,
+ RecordNode,
+ SelectFieldNode,
+ TextFieldNode,
+ UrlFieldNode,
+ ViewFilterNode,
+} from '@/types/databases';
import { User } from '@/types/users';
import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query';
+import { sha256 } from 'js-sha256';
import { QueryResult, sql } from 'kysely';
const RECORDS_PER_PAGE = 50;
-export const useRecordsQuery = (databaseId: string) => {
+export const useRecordsQuery = (
+ databaseId: string,
+ filters: ViewFilterNode[],
+) => {
const workspace = useWorkspace();
+ const database = useDatabase();
+
+ const json = filters.length > 0 ? JSON.stringify(filters) : '';
+ const hash = filters.length > 0 ? sha256(json) : '';
return useInfiniteQuery<
QueryResult,
@@ -20,7 +41,7 @@ export const useRecordsQuery = (databaseId: string) => {
string[],
number
>({
- queryKey: ['records', databaseId],
+ queryKey: ['records', databaseId, hash],
initialPageParam: 0,
getNextPageParam: (lastPage: QueryResult, pages) => {
if (lastPage && lastPage.rows) {
@@ -40,7 +61,7 @@ export const useRecordsQuery = (databaseId: string) => {
WITH record_nodes AS (
SELECT *
FROM nodes
- WHERE parent_id = ${databaseId} AND type = ${NodeTypes.Record}
+ WHERE parent_id = ${databaseId} AND type = ${NodeTypes.Record} ${sql.raw(buildFiltersQuery(filters, database.fields))}
ORDER BY ${sql.ref('index')} ASC
LIMIT ${sql.lit(RECORDS_PER_PAGE)}
OFFSET ${sql.lit(offset)}
@@ -50,22 +71,10 @@ export const useRecordsQuery = (databaseId: string) => {
FROM nodes
WHERE id IN (SELECT DISTINCT created_by FROM record_nodes)
),
- referenced_nodes AS (
- SELECT *
- FROM nodes
- WHERE id IN
- (
- SELECT DISTINCT foreign_node_id
- FROM node_attributes
- WHERE node_id IN (SELECT id FROM record_nodes)
- )
- ),
all_nodes as (
SELECT * FROM record_nodes
UNION ALL
SELECT * FROM author_nodes
- UNION ALL
- SELECT * FROM referenced_nodes
)
SELECT
n.*,
@@ -158,3 +167,491 @@ const buildRecords = (rows: SelectNodeWithAttributes[]): RecordNode[] => {
return records.sort((a, b) => compareString(a.index, b.index));
};
+
+const buildFiltersQuery = (
+ filters: ViewFilterNode[],
+ fields: FieldNode[],
+): string => {
+ if (filters.length === 0) {
+ return '';
+ }
+
+ const filterQueries = filters
+ .map((filter) => buildFilterQuery(filter, fields))
+ .filter((query) => query !== null);
+
+ if (filterQueries.length === 0) {
+ return '';
+ }
+
+ const joinQueries = filterQueries
+ .map((query) => query.joinQuery)
+ .filter((query) => query !== null && query.length > 0);
+
+ const whereQueries = filterQueries
+ .map((query) => query.whereQuery)
+ .filter((query) => query !== null && query.length > 0);
+
+ return `AND id IN
+ (
+ SELECT na.node_id
+ FROM node_attributes na
+ ${joinQueries.join(' ')}
+ WHERE ${whereQueries.join(' AND ')}
+ )
+ `;
+};
+
+interface FilterQuery {
+ joinQuery: string;
+ whereQuery: string | null;
+}
+
+const buildFilterQuery = (
+ filter: ViewFilterNode,
+ fields: FieldNode[],
+): FilterQuery | null => {
+ const field = fields.find((field) => field.id === filter.fieldId);
+ if (!field) {
+ return null;
+ }
+
+ switch (field.dataType) {
+ case 'boolean':
+ return buildBooleanFilterQuery(filter, field);
+ case 'collaborator':
+ return null;
+ case 'created_at':
+ return null;
+ case 'created_by':
+ return null;
+ case 'date':
+ return null;
+ case 'email':
+ return buildEmailFilterQuery(filter, field);
+ case 'file':
+ return null;
+ case 'multi_select':
+ return buildMultiSelectFilterQuery(filter, field);
+ case 'number':
+ return buildNumberFilterQuery(filter, field);
+ case 'phone':
+ return buildPhoneFilterQuery(filter, field);
+ case 'select':
+ return buildSelectFilterQuery(filter, field);
+ case 'text':
+ return buildTextFilterQuery(filter, field);
+ case 'url':
+ return buildUrlFilterQuery(filter, field);
+ default:
+ return null;
+ }
+};
+
+const buildBooleanFilterQuery = (
+ filter: ViewFilterNode,
+ field: BooleanFieldNode,
+): FilterQuery | null => {
+ if (filter.operator === 'is_true') {
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value = 1`,
+ whereQuery: null,
+ };
+ }
+
+ if (filter.operator === 'is_false') {
+ return {
+ joinQuery: buildLeftJoinQuery(filter.id, field.id),
+ whereQuery: `na_${filter.id}.node_id IS NULL OR na_${filter.id}.number_value = 0`,
+ };
+ }
+
+ return null;
+};
+
+const buildNumberFilterQuery = (
+ filter: ViewFilterNode,
+ field: NumberFieldNode,
+): FilterQuery | null => {
+ if (filter.operator === 'is_empty') {
+ return buildIsEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.operator === 'is_not_empty') {
+ return buildIsNotEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.values.length === 0) {
+ return null;
+ }
+
+ const value = filter.values[0].numberValue;
+ if (value === null) {
+ return null;
+ }
+
+ switch (filter.operator) {
+ case 'is_equal_to':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value = ${value}`,
+ whereQuery: null,
+ };
+ case 'is_not_equal_to':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value != ${value}`,
+ whereQuery: null,
+ };
+ case 'is_greater_than':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value > ${value}`,
+ whereQuery: null,
+ };
+ case 'is_less_than':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value < ${value}`,
+ whereQuery: null,
+ };
+ case 'is_greater_than_or_equal_to':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value >= ${value}`,
+ whereQuery: null,
+ };
+ case 'is_less_than_or_equal_to':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value <= ${value}`,
+ whereQuery: null,
+ };
+ default:
+ return null;
+ }
+};
+
+const buildTextFilterQuery = (
+ filter: ViewFilterNode,
+ field: TextFieldNode,
+): FilterQuery | null => {
+ if (filter.operator === 'is_empty') {
+ return buildIsEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.operator === 'is_not_empty') {
+ return buildIsNotEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.values.length === 0) {
+ return null;
+ }
+
+ const value = filter.values[0].textValue;
+ if (value === null) {
+ return null;
+ }
+
+ switch (filter.operator) {
+ case 'is_equal_to':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value = '${value}'`,
+ whereQuery: null,
+ };
+ case 'is_not_equal_to':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value != '${value}'`,
+ whereQuery: null,
+ };
+ case 'contains':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}%'`,
+ whereQuery: null,
+ };
+ case 'does_not_contain':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value NOT LIKE '%${value}%'`,
+ whereQuery: null,
+ };
+ case 'starts_with':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '${value}%'`,
+ whereQuery: null,
+ };
+ case 'ends_with':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}'`,
+ whereQuery: null,
+ };
+ default:
+ return null;
+ }
+};
+
+const buildEmailFilterQuery = (
+ filter: ViewFilterNode,
+ field: EmailFieldNode,
+): FilterQuery | null => {
+ if (filter.operator === 'is_empty') {
+ return buildIsEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.operator === 'is_not_empty') {
+ return buildIsNotEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.values.length === 0) {
+ return null;
+ }
+
+ const value = filter.values[0].textValue;
+ if (value === null) {
+ return null;
+ }
+
+ switch (filter.operator) {
+ case 'is_equal_to':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value = '${value}'`,
+ whereQuery: null,
+ };
+ case 'is_not_equal_to':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value != '${value}'`,
+ whereQuery: null,
+ };
+ case 'contains':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}%'`,
+ whereQuery: null,
+ };
+ case 'does_not_contain':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value NOT LIKE '%${value}%'`,
+ whereQuery: null,
+ };
+ case 'starts_with':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '${value}%'`,
+ whereQuery: null,
+ };
+ case 'ends_with':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}'`,
+ whereQuery: null,
+ };
+ default:
+ return null;
+ }
+};
+
+const buildPhoneFilterQuery = (
+ filter: ViewFilterNode,
+ field: PhoneFieldNode,
+): FilterQuery | null => {
+ if (filter.operator === 'is_empty') {
+ return buildIsEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.operator === 'is_not_empty') {
+ return buildIsNotEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.values.length === 0) {
+ return null;
+ }
+
+ const value = filter.values[0].textValue;
+ if (value === null) {
+ return null;
+ }
+
+ switch (filter.operator) {
+ case 'is_equal_to':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value = '${value}'`,
+ whereQuery: null,
+ };
+ case 'is_not_equal_to':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value != '${value}'`,
+ whereQuery: null,
+ };
+ case 'contains':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}%'`,
+ whereQuery: null,
+ };
+ case 'does_not_contain':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value NOT LIKE '%${value}%'`,
+ whereQuery: null,
+ };
+ case 'starts_with':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '${value}%'`,
+ whereQuery: null,
+ };
+ case 'ends_with':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}'`,
+ whereQuery: null,
+ };
+ default:
+ return null;
+ }
+};
+
+const buildUrlFilterQuery = (
+ filter: ViewFilterNode,
+ field: UrlFieldNode,
+): FilterQuery | null => {
+ if (filter.operator === 'is_empty') {
+ return buildIsEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.operator === 'is_not_empty') {
+ return buildIsNotEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.values.length === 0) {
+ return null;
+ }
+
+ const value = filter.values[0].textValue;
+ if (value === null) {
+ return null;
+ }
+
+ switch (filter.operator) {
+ case 'is_equal_to':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value = '${value}'`,
+ whereQuery: null,
+ };
+ case 'is_not_equal_to':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value != '${value}'`,
+ whereQuery: null,
+ };
+ case 'contains':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}%'`,
+ whereQuery: null,
+ };
+ case 'does_not_contain':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value NOT LIKE '%${value}%'`,
+ whereQuery: null,
+ };
+ case 'starts_with':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '${value}%'`,
+ whereQuery: null,
+ };
+ case 'ends_with':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}'`,
+ whereQuery: null,
+ };
+ default:
+ return null;
+ }
+};
+
+const buildSelectFilterQuery = (
+ filter: ViewFilterNode,
+ field: SelectFieldNode,
+): FilterQuery | null => {
+ if (filter.operator === 'is_empty') {
+ return buildIsEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.operator === 'is_not_empty') {
+ return buildIsNotEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.values.length === 0) {
+ return null;
+ }
+
+ const ids = filter.values.map((value) => value.foreignNodeId);
+ if (ids.length === 0) {
+ return null;
+ }
+
+ switch (filter.operator) {
+ case 'is_in':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id IN (${ids.join(',')})`,
+ whereQuery: null,
+ };
+ case 'is_not_in':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id NOT IN (${ids.join(',')})`,
+ whereQuery: null,
+ };
+ default:
+ return null;
+ }
+};
+
+const buildMultiSelectFilterQuery = (
+ filter: ViewFilterNode,
+ field: MultiSelectFieldNode,
+): FilterQuery | null => {
+ if (filter.operator === 'is_empty') {
+ return buildIsEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.operator === 'is_not_empty') {
+ return buildIsNotEmptyFilterQuery(filter.id, field.id);
+ }
+
+ if (filter.values.length === 0) {
+ return null;
+ }
+
+ const ids = filter.values.map((value) => value.foreignNodeId);
+ if (ids.length === 0) {
+ return null;
+ }
+
+ switch (filter.operator) {
+ case 'is_in':
+ return {
+ joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id IN (${ids.join(',')})`,
+ whereQuery: null,
+ };
+ case 'is_not_in':
+ return {
+ joinQuery: `${buildLeftJoinQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id IN (${ids.join(',')})`,
+ whereQuery: `na_${filter.id}.node_id IS NULL`,
+ };
+ default:
+ return null;
+ }
+};
+
+const buildIsEmptyFilterQuery = (
+ filterId: string,
+ fieldId: string,
+): FilterQuery => {
+ return {
+ joinQuery: buildLeftJoinQuery(filterId, fieldId),
+ whereQuery: `na_${filterId}.node_id IS NULL`,
+ };
+};
+
+const buildIsNotEmptyFilterQuery = (
+ filterId: string,
+ fieldId: string,
+): FilterQuery => {
+ return {
+ joinQuery: buildJoinQuery(filterId, fieldId),
+ whereQuery: null,
+ };
+};
+
+const buildJoinQuery = (filterId: string, fieldId: string): string => {
+ return `JOIN node_attributes na_${filterId} ON na_${filterId}.node_id = na.node_id AND na_${filterId}.type = '${fieldId}'`;
+};
+
+const buildLeftJoinQuery = (filterId: string, fieldId: string): string => {
+ return `LEFT JOIN node_attributes na_${filterId} ON na_${filterId}.node_id = na.node_id AND na_${filterId}.type = '${fieldId}'`;
+};
diff --git a/desktop/src/types/databases.ts b/desktop/src/types/databases.ts
index 3859d4e8..37b0d39b 100644
--- a/desktop/src/types/databases.ts
+++ b/desktop/src/types/databases.ts
@@ -180,5 +180,11 @@ export type ViewFilterNode = {
id: string;
fieldId: string;
operator: string;
- value: number | string | string[] | null;
+ values: ViewFilterValueNode[];
+};
+
+export type ViewFilterValueNode = {
+ textValue: string | null;
+ numberValue: number | null;
+ foreignNodeId: string | null;
};