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 ( + + + + + +
+
+ +

{field.name}

+
+ + +
+

{operator.label}

+ +
+
+ + {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); + }} + > +
+ +

{field.name}

+
+
+ ))} +
+
+
+
+
+ ); +}; 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); - }} - > -
- -

{item.name}

-
-
- ))} -
-
- ); -}; 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); - }} - > -
-

{item.label}

-
-
- ))} -
-
- ); -}; 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 ( + + + + + +
+
+ +

{field.name}

+
+ + +
+

{operator.label}

+ +
+
+ + {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 ( + + + + + +
+
+ +

{field.name}

+
+ + +
+

{operator.label}

+ +
+
+ + {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 ( + + + + + +
+
+ +

{field.name}

+
+ + +
+

{operator.label}

+ +
+
+ + {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 ( + + + + + +
+
+ +

{field.name}

+
+ + +
+

{operator.label}

+ +
+
+ + {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; };