diff --git a/packages/client/src/lib/records.ts b/packages/client/src/lib/records.ts index 998be22e..86694104 100644 --- a/packages/client/src/lib/records.ts +++ b/packages/client/src/lib/records.ts @@ -19,6 +19,7 @@ import { CreatedByFieldAttributes, UpdatedAtFieldAttributes, UpdatedByFieldAttributes, + RelationFieldAttributes, } from '@colanode/core'; type SqliteOperator = @@ -94,6 +95,8 @@ const buildFilterQuery = ( return buildNumberFilterQuery(filter, field); case 'phone': return buildPhoneFilterQuery(filter, field); + case 'relation': + return buildRelationFilterQuery(filter, field); case 'select': return buildSelectFilterQuery(filter, field); case 'text': @@ -377,6 +380,36 @@ const buildPhoneFilterQuery = ( } }; +const buildRelationFilterQuery = ( + filter: DatabaseViewFieldFilterAttributes, + field: RelationFieldAttributes +): string | null => { + if (filter.operator === 'is_empty') { + return buildFieldArrayIsEmptyFilterQuery(field.id); + } + + if (filter.operator === 'is_not_empty') { + return buildFieldArrayIsNotEmptyFilterQuery(field.id); + } + + if (!isStringArray(filter.value)) { + return null; + } + + if (filter.value.length === 0) { + return null; + } + + switch (filter.operator) { + case 'is_in': + return buildArrayFieldContainsFilterQuery(field.id, filter.value); + case 'is_not_in': + return buildArrayFieldDoesNotContainFilterQuery(field.id, filter.value); + default: + return null; + } +}; + const buildUrlFilterQuery = ( filter: DatabaseViewFieldFilterAttributes, field: UrlFieldAttributes diff --git a/packages/ui/src/components/databases/search/view-filters.tsx b/packages/ui/src/components/databases/search/view-filters.tsx index 28d86b19..a3a057e1 100644 --- a/packages/ui/src/components/databases/search/view-filters.tsx +++ b/packages/ui/src/components/databases/search/view-filters.tsx @@ -12,6 +12,7 @@ import { ViewMultiSelectFieldFilter } from '@colanode/ui/components/databases/se import { ViewNameFieldFilter } from '@colanode/ui/components/databases/search/view-name-field-filter'; import { ViewNumberFieldFilter } from '@colanode/ui/components/databases/search/view-number-field-filter'; import { ViewPhoneFieldFilter } from '@colanode/ui/components/databases/search/view-phone-field-filter'; +import { ViewRelationFieldFilter } from '@colanode/ui/components/databases/search/view-relation-field-filter'; import { ViewSelectFieldFilter } from '@colanode/ui/components/databases/search/view-select-field-filter'; import { ViewTextFieldFilter } from '@colanode/ui/components/databases/search/view-text-field-filter'; import { ViewUpdatedAtFieldFilter } from '@colanode/ui/components/databases/search/view-updated-at-field-filter'; @@ -160,6 +161,14 @@ export const ViewFilters = () => { filter={filter} /> ); + case 'relation': + return ( + + ); default: return null; diff --git a/packages/ui/src/components/databases/search/view-relation-field-filter.tsx b/packages/ui/src/components/databases/search/view-relation-field-filter.tsx new file mode 100644 index 00000000..a0370721 --- /dev/null +++ b/packages/ui/src/components/databases/search/view-relation-field-filter.tsx @@ -0,0 +1,223 @@ +import { ChevronDown, Trash2, X } from 'lucide-react'; + +import { LocalRecordNode } from '@colanode/client/types'; +import { + DatabaseViewFieldFilterAttributes, + RelationFieldAttributes, +} from '@colanode/core'; +import { Avatar } from '@colanode/ui/components/avatars/avatar'; +import { FieldIcon } from '@colanode/ui/components/databases/fields/field-icon'; +import { RecordSearch } from '@colanode/ui/components/records/record-search'; +import { Badge } from '@colanode/ui/components/ui/badge'; +import { Button } from '@colanode/ui/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@colanode/ui/components/ui/dropdown-menu'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@colanode/ui/components/ui/popover'; +import { Separator } from '@colanode/ui/components/ui/separator'; +import { useDatabaseView } from '@colanode/ui/contexts/database-view'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { useLiveQueries } from '@colanode/ui/hooks/use-live-queries'; +import { relationFieldFilterOperators } from '@colanode/ui/lib/databases'; + +interface ViewRelationFieldFilterProps { + field: RelationFieldAttributes; + filter: DatabaseViewFieldFilterAttributes; +} + +const RelationBadge = ({ record }: { record: LocalRecordNode }) => { + const name = record.attributes.name ?? 'Unnamed'; + return ( +
+ +

{name}

+
+ ); +}; + +const isOperatorWithoutValue = (operator: string) => { + return operator === 'is_empty' || operator === 'is_not_empty'; +}; + +export const ViewRelationFieldFilter = ({ + field, + filter, +}: ViewRelationFieldFilterProps) => { + const workspace = useWorkspace(); + const view = useDatabaseView(); + + const operator = + relationFieldFilterOperators.find( + (operator) => operator.value === filter.operator + ) ?? relationFieldFilterOperators[0]!; + + const relationIds = (filter.value as string[]) ?? []; + const results = useLiveQueries( + relationIds.map((id) => ({ + type: 'node.get', + nodeId: id, + accountId: workspace.accountId, + workspaceId: workspace.id, + })) + ); + + const relations: LocalRecordNode[] = []; + for (const result of results) { + if (result.data && result.data.type === 'record') { + relations.push(result.data); + } + } + + const hideInput = isOperatorWithoutValue(operator.value); + + if (!field.databaseId) { + return null; + } + + return ( + { + if (view.isFieldFilterOpened(filter.id)) { + view.closeFieldFilter(filter.id); + } else { + view.openFieldFilter(filter.id); + } + }} + > + + + + +
+
+ +

{field.name}

+
+ + +
+

{operator.label}

+ +
+
+ + {relationFieldFilterOperators.map((operator) => ( + { + const value = isOperatorWithoutValue(operator.value) + ? [] + : relationIds; + + view.updateFilter(filter.id, { + ...filter, + operator: operator.value, + value: value, + }); + }} + > + {operator.label} + + ))} + +
+ +
+ {!hideInput && ( + + +
+ {relations.slice(0, 1).map((relation) => ( + + ))} + {relations.length === 0 && ( +

+ No records selected +

+ )} + {relations.length > 1 && ( + + +{relations.length - 1} + + )} +
+
+ + {relations.length > 0 && ( +
+ {relations.map((relation) => ( +
+ + { + const newRelations = relationIds.filter( + (id) => id !== relation.id + ); + + view.updateFilter(filter.id, { + ...filter, + value: newRelations, + }); + }} + /> +
+ ))} + +
+ )} + { + const newRelations = relationIds.includes(record.id) + ? relationIds.filter((id) => id !== record.id) + : [...relationIds, record.id]; + + view.updateFilter(filter.id, { + ...filter, + value: newRelations, + }); + }} + /> +
+
+ )} +
+
+ ); +}; diff --git a/packages/ui/src/components/records/values/record-relation-value.tsx b/packages/ui/src/components/records/values/record-relation-value.tsx index c6c525ee..9069c5dd 100644 --- a/packages/ui/src/components/records/values/record-relation-value.tsx +++ b/packages/ui/src/components/records/values/record-relation-value.tsx @@ -1,7 +1,8 @@ import { X } from 'lucide-react'; import { Fragment, useState } from 'react'; -import { RecordNode, RelationFieldAttributes } from '@colanode/core'; +import { LocalRecordNode } from '@colanode/client/types'; +import { RelationFieldAttributes } from '@colanode/core'; import { Avatar } from '@colanode/ui/components/avatars/avatar'; import { RecordSearch } from '@colanode/ui/components/records/record-search'; import { Badge } from '@colanode/ui/components/ui/badge'; @@ -20,7 +21,7 @@ interface RecordRelationValueProps { readOnly?: boolean; } -const RelationBadge = ({ record }: { record: RecordNode }) => { +const RelationBadge = ({ record }: { record: LocalRecordNode }) => { const name = record.attributes.name ?? 'Unnamed'; return (
@@ -54,7 +55,7 @@ export const RecordRelationValue = ({ })) ); - const relations: RecordNode[] = []; + const relations: LocalRecordNode[] = []; for (const result of results) { if (result.data && result.data.type === 'record') { relations.push(result.data); diff --git a/packages/ui/src/lib/databases.ts b/packages/ui/src/lib/databases.ts index de912597..df741b46 100644 --- a/packages/ui/src/lib/databases.ts +++ b/packages/ui/src/lib/databases.ts @@ -426,6 +426,25 @@ export const phoneFieldFilterOperators: FieldFilterOperator[] = [ }, ]; +export const relationFieldFilterOperators: FieldFilterOperator[] = [ + { + label: 'Is In', + value: 'is_in', + }, + { + label: 'Is Not In', + value: 'is_not_in', + }, + { + label: 'Is Empty', + value: 'is_empty', + }, + { + label: 'Is Not Empty', + value: 'is_not_empty', + }, +]; + export const selectFieldFilterOperators: FieldFilterOperator[] = [ { label: 'Is In', @@ -588,6 +607,8 @@ export const getFieldFilterOperators = ( return numberFieldFilterOperators; case 'phone': return phoneFieldFilterOperators; + case 'relation': + return relationFieldFilterOperators; case 'select': return selectFieldFilterOperators; case 'text':