From 6fe6f324e889288b95c4a589f3a12dc77458a051 Mon Sep 17 00:00:00 2001 From: Hakan Shehu Date: Sun, 6 Jul 2025 14:55:40 +0200 Subject: [PATCH] Filter and sort by name (#117) --- .../handlers/queries/records/record-list.ts | 443 +++++++++--------- packages/core/src/lib/id.ts | 4 + .../search/view-filter-add-popover.tsx | 20 + .../databases/search/view-filters.tsx | 7 + .../search/view-name-field-filter.tsx | 124 +++++ .../search/view-sort-add-popover.tsx | 20 + ...w-sort-row.tsx => view-sort-field-row.tsx} | 4 +- .../databases/search/view-sort-name-row.tsx | 71 +++ .../databases/search/view-sorts.tsx | 10 +- .../tables/table-view-name-header.tsx | 31 +- packages/ui/src/components/databases/view.tsx | 79 ++-- packages/ui/src/lib/databases.ts | 27 ++ 12 files changed, 577 insertions(+), 263 deletions(-) create mode 100644 packages/ui/src/components/databases/search/view-name-field-filter.tsx rename packages/ui/src/components/databases/search/{view-sort-row.tsx => view-sort-field-row.tsx} (94%) create mode 100644 packages/ui/src/components/databases/search/view-sort-name-row.tsx diff --git a/packages/client/src/handlers/queries/records/record-list.ts b/packages/client/src/handlers/queries/records/record-list.ts index b889efd0..eee9ff7b 100644 --- a/packages/client/src/handlers/queries/records/record-list.ts +++ b/packages/client/src/handlers/queries/records/record-list.ts @@ -24,8 +24,23 @@ import { DatabaseViewFilterAttributes, DatabaseViewSortAttributes, MultiSelectFieldAttributes, + SpecialId, } from '@colanode/core'; +type SqliteOperator = + | '=' + | '!=' + | '>' + | '<' + | '>=' + | '<=' + | 'LIKE' + | 'NOT LIKE' + | 'IS' + | 'IS NOT' + | 'IN' + | 'NOT IN'; + export class RecordListQueryHandler extends WorkspaceQueryHandlerBase implements QueryHandler @@ -214,6 +229,10 @@ export class RecordListQueryHandler return null; } + if (filter.fieldId === SpecialId.Name) { + return this.buildNameFilterQuery(filter); + } + const field = fields[filter.fieldId]; if (!field) { return null; @@ -251,16 +270,62 @@ export class RecordListQueryHandler } }; + private buildNameFilterQuery = ( + filter: DatabaseViewFieldFilterAttributes + ): string | null => { + if (filter.operator === 'is_empty') { + return this.buildAttributeFilterQuery('name', 'IS', 'NULL'); + } + + if (filter.operator === 'is_not_empty') { + return this.buildAttributeFilterQuery('name', 'IS NOT', 'NULL'); + } + + if (filter.value === null) { + return null; + } + + if (typeof filter.value !== 'string') { + return null; + } + + const value = filter.value as string; + if (!value || value.length === 0) { + return null; + } + + switch (filter.operator) { + case 'is_equal_to': + return this.buildAttributeFilterQuery('name', '=', `'${value}'`); + case 'is_not_equal_to': + return this.buildAttributeFilterQuery('name', '!=', `'${value}'`); + case 'contains': + return this.buildAttributeFilterQuery('name', 'LIKE', `'%${value}%'`); + case 'does_not_contain': + return this.buildAttributeFilterQuery( + 'name', + 'NOT LIKE', + `'%${value}%'` + ); + case 'starts_with': + return this.buildAttributeFilterQuery('name', 'LIKE', `'${value}%'`); + case 'ends_with': + return this.buildAttributeFilterQuery('name', 'LIKE', `'%${value}'`); + default: + return null; + } + }; + private buildBooleanFilterQuery = ( filter: DatabaseViewFieldFilterAttributes, field: BooleanFieldAttributes ): string | null => { if (filter.operator === 'is_true') { - return `json_extract(n.attributes, '$.fields.${field.id}.value') = true`; + return this.buildFieldFilterQuery(field.id, '=', 'true'); } if (filter.operator === 'is_false') { - return `(json_extract(n.attributes, '$.fields.${field.id}.value') = false OR json_extract(n.attributes, '$.fields.${field.id}.value') IS NULL)`; + return `(${this.buildFieldFilterQuery(field.id, '=', 'false')} OR ${this.buildFieldFilterQuery(field.id, 'IS', 'NULL')})`; } return null; @@ -271,11 +336,11 @@ export class RecordListQueryHandler field: NumberFieldAttributes ): string | null => { if (filter.operator === 'is_empty') { - return this.buildIsEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS', 'NULL'); } if (filter.operator === 'is_not_empty') { - return this.buildIsNotEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL'); } if (filter.value === null) { @@ -287,36 +352,26 @@ export class RecordListQueryHandler } const value = filter.value as number; - let operator: string | null; - - switch (filter.operator) { - case 'is_equal_to': - operator = '='; - break; - case 'is_not_equal_to': - operator = '!='; - break; - case 'is_greater_than': - operator = '>'; - break; - case 'is_less_than': - operator = '<'; - break; - case 'is_greater_than_or_equal_to': - operator = '>='; - break; - case 'is_less_than_or_equal_to': - operator = '<='; - break; - default: - return null; - } - - if (operator === null) { + if (isNaN(value)) { return null; } - return `json_extract(n.attributes, '$.fields.${field.id}.value') ${operator} ${value}`; + switch (filter.operator) { + case 'is_equal_to': + return this.buildFieldFilterQuery(field.id, '=', value.toString()); + case 'is_not_equal_to': + return this.buildFieldFilterQuery(field.id, '!=', value.toString()); + case 'is_greater_than': + return this.buildFieldFilterQuery(field.id, '>', value.toString()); + case 'is_less_than': + return this.buildFieldFilterQuery(field.id, '<', value.toString()); + case 'is_greater_than_or_equal_to': + return this.buildFieldFilterQuery(field.id, '>=', value.toString()); + case 'is_less_than_or_equal_to': + return this.buildFieldFilterQuery(field.id, '<=', value.toString()); + default: + return null; + } }; private buildTextFilterQuery = ( @@ -324,11 +379,11 @@ export class RecordListQueryHandler field: TextFieldAttributes ): string | null => { if (filter.operator === 'is_empty') { - return this.buildIsEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS', 'NULL'); } if (filter.operator === 'is_not_empty') { - return this.buildIsNotEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL'); } if (filter.value === null) { @@ -340,41 +395,26 @@ export class RecordListQueryHandler } const value = filter.value as string; - let operator: string | null; - let formattedValue = value; - - switch (filter.operator) { - case 'is_equal_to': - operator = '='; - break; - case 'is_not_equal_to': - operator = '!='; - break; - case 'contains': - operator = 'LIKE'; - formattedValue = `%${value}%`; - break; - case 'does_not_contain': - operator = 'NOT LIKE'; - formattedValue = `%${value}%`; - break; - case 'starts_with': - operator = 'LIKE'; - formattedValue = `${value}%`; - break; - case 'ends_with': - operator = 'LIKE'; - formattedValue = `%${value}`; - break; - default: - return null; - } - - if (operator === null) { + if (!value || value.length === 0) { return null; } - return `json_extract(n.attributes, '$.fields.${field.id}.value') ${operator} '${formattedValue}'`; + switch (filter.operator) { + case 'is_equal_to': + return this.buildFieldFilterQuery(field.id, '=', `'${value}'`); + case 'is_not_equal_to': + return this.buildFieldFilterQuery(field.id, '!=', `'${value}'`); + case 'contains': + return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}%'`); + case 'does_not_contain': + return this.buildFieldFilterQuery(field.id, 'NOT LIKE', `'%${value}%'`); + case 'starts_with': + return this.buildFieldFilterQuery(field.id, 'LIKE', `'${value}%'`); + case 'ends_with': + return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}'`); + default: + return null; + } }; private buildEmailFilterQuery = ( @@ -382,11 +422,11 @@ export class RecordListQueryHandler field: EmailFieldAttributes ): string | null => { if (filter.operator === 'is_empty') { - return this.buildIsEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS', 'NULL'); } if (filter.operator === 'is_not_empty') { - return this.buildIsNotEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL'); } if (filter.value === null) { @@ -398,41 +438,26 @@ export class RecordListQueryHandler } const value = filter.value as string; - let operator: string | null; - let formattedValue = value; - - switch (filter.operator) { - case 'is_equal_to': - operator = '='; - break; - case 'is_not_equal_to': - operator = '!='; - break; - case 'contains': - operator = 'LIKE'; - formattedValue = `%${value}%`; - break; - case 'does_not_contain': - operator = 'NOT LIKE'; - formattedValue = `%${value}%`; - break; - case 'starts_with': - operator = 'LIKE'; - formattedValue = `${value}%`; - break; - case 'ends_with': - operator = 'LIKE'; - formattedValue = `%${value}`; - break; - default: - return null; - } - - if (operator === null) { + if (!value || value.length === 0) { return null; } - return `json_extract(n.attributes, '$.fields.${field.id}.value') ${operator} '${formattedValue}'`; + switch (filter.operator) { + case 'is_equal_to': + return this.buildFieldFilterQuery(field.id, '=', `'${value}'`); + case 'is_not_equal_to': + return this.buildFieldFilterQuery(field.id, '!=', `'${value}'`); + case 'contains': + return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}%'`); + case 'does_not_contain': + return this.buildFieldFilterQuery(field.id, 'NOT LIKE', `'%${value}%'`); + case 'starts_with': + return this.buildFieldFilterQuery(field.id, 'LIKE', `'${value}%'`); + case 'ends_with': + return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}'`); + default: + return null; + } }; private buildPhoneFilterQuery = ( @@ -440,11 +465,11 @@ export class RecordListQueryHandler field: PhoneFieldAttributes ): string | null => { if (filter.operator === 'is_empty') { - return this.buildIsEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS', 'NULL'); } if (filter.operator === 'is_not_empty') { - return this.buildIsNotEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL'); } if (filter.value === null) { @@ -456,41 +481,26 @@ export class RecordListQueryHandler } const value = filter.value as string; - let operator: string | null; - let formattedValue = value; - - switch (filter.operator) { - case 'is_equal_to': - operator = '='; - break; - case 'is_not_equal_to': - operator = '!='; - break; - case 'contains': - operator = 'LIKE'; - formattedValue = `%${value}%`; - break; - case 'does_not_contain': - operator = 'NOT LIKE'; - formattedValue = `%${value}%`; - break; - case 'starts_with': - operator = 'LIKE'; - formattedValue = `${value}%`; - break; - case 'ends_with': - operator = 'LIKE'; - formattedValue = `%${value}`; - break; - default: - return null; - } - - if (operator === null) { + if (!value || value.length === 0) { return null; } - return `json_extract(n.attributes, '$.fields.${field.id}.value') ${operator} '${formattedValue}'`; + switch (filter.operator) { + case 'is_equal_to': + return this.buildFieldFilterQuery(field.id, '=', `'${value}'`); + case 'is_not_equal_to': + return this.buildFieldFilterQuery(field.id, '!=', `'${value}'`); + case 'contains': + return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}%'`); + case 'does_not_contain': + return this.buildFieldFilterQuery(field.id, 'NOT LIKE', `'%${value}%'`); + case 'starts_with': + return this.buildFieldFilterQuery(field.id, 'LIKE', `'${value}%'`); + case 'ends_with': + return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}'`); + default: + return null; + } }; private buildUrlFilterQuery = ( @@ -498,11 +508,11 @@ export class RecordListQueryHandler field: UrlFieldAttributes ): string | null => { if (filter.operator === 'is_empty') { - return this.buildIsEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS', 'NULL'); } if (filter.operator === 'is_not_empty') { - return this.buildIsNotEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL'); } if (filter.value === null) { @@ -514,41 +524,26 @@ export class RecordListQueryHandler } const value = filter.value as string; - let operator: string | null; - let formattedValue = value; - - switch (filter.operator) { - case 'is_equal_to': - operator = '='; - break; - case 'is_not_equal_to': - operator = '!='; - break; - case 'contains': - operator = 'LIKE'; - formattedValue = `%${value}%`; - break; - case 'does_not_contain': - operator = 'NOT LIKE'; - formattedValue = `%${value}%`; - break; - case 'starts_with': - operator = 'LIKE'; - formattedValue = `${value}%`; - break; - case 'ends_with': - operator = 'LIKE'; - formattedValue = `%${value}`; - break; - default: - return null; - } - - if (operator === null) { + if (!value || value.length === 0) { return null; } - return `json_extract(n.attributes, '$.fields.${field.id}.value') ${operator} '${formattedValue}'`; + switch (filter.operator) { + case 'is_equal_to': + return this.buildFieldFilterQuery(field.id, '=', `'${value}'`); + case 'is_not_equal_to': + return this.buildFieldFilterQuery(field.id, '!=', `'${value}'`); + case 'contains': + return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}%'`); + case 'does_not_contain': + return this.buildFieldFilterQuery(field.id, 'NOT LIKE', `'%${value}%'`); + case 'starts_with': + return this.buildFieldFilterQuery(field.id, 'LIKE', `'${value}%'`); + case 'ends_with': + return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}'`); + default: + return null; + } }; private buildSelectFilterQuery = ( @@ -556,11 +551,11 @@ export class RecordListQueryHandler field: SelectFieldAttributes ): string | null => { if (filter.operator === 'is_empty') { - return this.buildIsEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS', 'NULL'); } if (filter.operator === 'is_not_empty') { - return this.buildIsNotEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL'); } if (!isStringArray(filter.value)) { @@ -574,9 +569,9 @@ export class RecordListQueryHandler const values = this.joinIds(filter.value); switch (filter.operator) { case 'is_in': - return `json_extract(n.attributes, '$.fields.${field.id}.value') IN (${values})`; + return this.buildFieldFilterQuery(field.id, 'IN', `(${values})`); case 'is_not_in': - return `json_extract(n.attributes, '$.fields.${field.id}.value') NOT IN (${values})`; + return this.buildFieldFilterQuery(field.id, 'NOT IN', `(${values})`); default: return null; } @@ -618,11 +613,11 @@ export class RecordListQueryHandler field: DateFieldAttributes ): string | null => { if (filter.operator === 'is_empty') { - return this.buildIsEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS', 'NULL'); } if (filter.operator === 'is_not_empty') { - return this.buildIsNotEmptyFilterQuery(field.id); + return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL'); } if (filter.value === null) { @@ -639,36 +634,23 @@ export class RecordListQueryHandler } const dateString = date.toISOString().split('T')[0]; - let operator: string | null; switch (filter.operator) { case 'is_equal_to': - operator = '='; - break; + return this.buildFieldFilterQuery(field.id, '=', `'${dateString}'`); case 'is_not_equal_to': - operator = '!='; - break; + return this.buildFieldFilterQuery(field.id, '!=', `'${dateString}'`); case 'is_on_or_after': - operator = '>='; - break; + return this.buildFieldFilterQuery(field.id, '>=', `'${dateString}'`); case 'is_on_or_before': - operator = '<='; - break; + return this.buildFieldFilterQuery(field.id, '<=', `'${dateString}'`); case 'is_after': - operator = '>'; - break; + return this.buildFieldFilterQuery(field.id, '>', `'${dateString}'`); case 'is_before': - operator = '<'; - break; + return this.buildFieldFilterQuery(field.id, '<', `'${dateString}'`); default: return null; } - - if (operator === null) { - return null; - } - - return `DATE(json_extract(n.attributes, '$.fields.${field.id}.value')) ${operator} '${dateString}'`; }; private buildCreatedAtFilterQuery = ( @@ -676,11 +658,11 @@ export class RecordListQueryHandler _: CreatedAtFieldAttributes ): string | null => { if (filter.operator === 'is_empty') { - return `n.created_at IS NULL`; + return this.buildAttributeFilterQuery('created_at', 'IS', 'NULL'); } if (filter.operator === 'is_not_empty') { - return `n.created_at IS NOT NULL`; + return this.buildAttributeFilterQuery('created_at', 'IS NOT', 'NULL'); } if (filter.value === null) { @@ -697,44 +679,67 @@ export class RecordListQueryHandler } const dateString = date.toISOString().split('T')[0]; - let operator: string | null; switch (filter.operator) { case 'is_equal_to': - operator = '='; - break; + return this.buildAttributeFilterQuery( + 'created_at', + '=', + `'${dateString}'` + ); case 'is_not_equal_to': - operator = '!='; - break; + return this.buildAttributeFilterQuery( + 'created_at', + '!=', + `'${dateString}'` + ); case 'is_on_or_after': - operator = '>='; - break; + return this.buildAttributeFilterQuery( + 'created_at', + '>=', + `'${dateString}'` + ); case 'is_on_or_before': - operator = '<='; - break; + return this.buildAttributeFilterQuery( + 'created_at', + '<=', + `'${dateString}'` + ); case 'is_after': - operator = '>'; - break; + return this.buildAttributeFilterQuery( + 'created_at', + '>', + `'${dateString}'` + ); case 'is_before': - operator = '<'; - break; + return this.buildAttributeFilterQuery( + 'created_at', + '<', + `'${dateString}'` + ); default: return null; } - - if (operator === null) { - return null; - } - - return `DATE(n.created_at) ${operator} '${dateString}'`; }; - private buildIsEmptyFilterQuery = (fieldId: string): string => { - return `json_extract(n.attributes, '$.fields.${fieldId}.value') IS NULL`; + private buildFieldFilterQuery = ( + fieldId: string, + operator: SqliteOperator, + value: string + ): string => { + return this.buildAttributeFilterQuery( + `fields.${fieldId}.value`, + operator, + value + ); }; - private buildIsNotEmptyFilterQuery = (fieldId: string): string => { - return `json_extract(n.attributes, '$.fields.${fieldId}.value') IS NOT NULL`; + private buildAttributeFilterQuery = ( + name: string, + operator: SqliteOperator, + value: string + ): string => { + return `json_extract(n.attributes, '$.${name}') ${operator} ${value}`; }; private joinIds = (ids: string[]): string => { @@ -755,6 +760,10 @@ export class RecordListQueryHandler sort: DatabaseViewSortAttributes, fields: Record ): string | null => { + if (sort.fieldId === SpecialId.Name) { + return `json_extract(n.attributes, '$.name') ${sort.direction}`; + } + const field = fields[sort.fieldId]; if (!field) { return null; diff --git a/packages/core/src/lib/id.ts b/packages/core/src/lib/id.ts index a90ca8bb..fb2029c7 100644 --- a/packages/core/src/lib/id.ts +++ b/packages/core/src/lib/id.ts @@ -43,6 +43,10 @@ export enum IdType { Save = 'sv', } +export const SpecialId = { + Name: 'name', +}; + export const generateId = (type: IdType): string => { return ulid().toLowerCase() + type; }; diff --git a/packages/ui/src/components/databases/search/view-filter-add-popover.tsx b/packages/ui/src/components/databases/search/view-filter-add-popover.tsx index c1ffd74c..444d12d1 100644 --- a/packages/ui/src/components/databases/search/view-filter-add-popover.tsx +++ b/packages/ui/src/components/databases/search/view-filter-add-popover.tsx @@ -1,5 +1,7 @@ +import { Type } from 'lucide-react'; import { useState } from 'react'; +import { SpecialId } from '@colanode/core'; import { FieldIcon } from '@colanode/ui/components/databases/fields/field-icon'; import { Command, @@ -35,6 +37,10 @@ export const ViewFilterAddPopover = ({ ) ); + const isFilteredByName = view.filters.some( + (filter) => filter.type === 'field' && filter.fieldId === SpecialId.Name + ); + if (fieldsWithoutFilters.length === 0) { return null; } @@ -48,6 +54,20 @@ export const ViewFilterAddPopover = ({ No field found. + {!isFilteredByName && ( + { + view.initFieldFilter(SpecialId.Name); + setOpen(false); + }} + > +
+ +

{database.nameField?.name ?? 'Name'}

+
+
+ )} {fieldsWithoutFilters.map((field) => ( { const database = useDatabase(); const view = useDatabaseView(); + console.log('view.filters', view.filters); return (
@@ -26,6 +29,10 @@ export const ViewFilters = () => { return null; } + if (filter.fieldId === SpecialId.Name) { + return ; + } + const field = database.fields.find( (field) => field.id === filter.fieldId ); diff --git a/packages/ui/src/components/databases/search/view-name-field-filter.tsx b/packages/ui/src/components/databases/search/view-name-field-filter.tsx new file mode 100644 index 00000000..598f42dc --- /dev/null +++ b/packages/ui/src/components/databases/search/view-name-field-filter.tsx @@ -0,0 +1,124 @@ +import { ChevronDown, Trash2, Type } from 'lucide-react'; + +import { DatabaseViewFieldFilterAttributes } from '@colanode/core'; +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 { SmartTextInput } from '@colanode/ui/components/ui/smart-text-input'; +import { useDatabase } from '@colanode/ui/contexts/database'; +import { useDatabaseView } from '@colanode/ui/contexts/database-view'; +import { nameFieldFilterOperators } from '@colanode/ui/lib/databases'; + +interface ViewNameFieldFilterProps { + filter: DatabaseViewFieldFilterAttributes; +} + +export const ViewNameFieldFilter = ({ filter }: ViewNameFieldFilterProps) => { + const database = useDatabase(); + const view = useDatabaseView(); + + const operator = + nameFieldFilterOperators.find( + (operator) => operator.value === filter.operator + ) ?? nameFieldFilterOperators[0]; + + if (!operator) { + return null; + } + + const textValue = filter.value as string | null; + + const hideInput = + operator.value === 'is_empty' || operator.value === 'is_not_empty'; + + return ( + { + if (view.isFieldFilterOpened(filter.id)) { + view.closeFieldFilter(filter.id); + } else { + view.openFieldFilter(filter.id); + } + }} + > + + + + +
+
+ +

{database.nameField?.name ?? 'Name'}

+
+ + +
+

{operator.label}

+ +
+
+ + {nameFieldFilterOperators.map((operator) => ( + { + const value = + operator.value === 'is_empty' || + operator.value === 'is_not_empty' + ? null + : textValue; + + view.updateFilter(filter.id, { + ...filter, + operator: operator.value, + value: value, + }); + }} + > + {operator.label} + + ))} + +
+ +
+ {!hideInput && ( + { + view.updateFilter(filter.id, { + ...filter, + value: value, + }); + }} + /> + )} +
+
+ ); +}; diff --git a/packages/ui/src/components/databases/search/view-sort-add-popover.tsx b/packages/ui/src/components/databases/search/view-sort-add-popover.tsx index 59d9ed23..699dd587 100644 --- a/packages/ui/src/components/databases/search/view-sort-add-popover.tsx +++ b/packages/ui/src/components/databases/search/view-sort-add-popover.tsx @@ -1,5 +1,7 @@ +import { Type } from 'lucide-react'; import { useState } from 'react'; +import { SpecialId } from '@colanode/core'; import { FieldIcon } from '@colanode/ui/components/databases/fields/field-icon'; import { Command, @@ -33,6 +35,10 @@ export const ViewSortAddPopover = ({ children }: ViewSortAddPopoverProps) => { !view.sorts.some((sort) => sort.fieldId === field.id) ); + const isSortedByName = view.sorts.some( + (sort) => sort.fieldId === SpecialId.Name + ); + if (sortableFields.length === 0) { return null; } @@ -46,6 +52,20 @@ export const ViewSortAddPopover = ({ children }: ViewSortAddPopoverProps) => { No sortable field found. + {!isSortedByName && ( + { + view.initFieldSort(SpecialId.Name, 'asc'); + setOpen(false); + }} + > +
+ +

{database.nameField?.name ?? 'Name'}

+
+
+ )} {sortableFields.map((field) => ( { +export const ViewSortFieldRow = ({ sort, field }: ViewSortFieldRowProps) => { const view = useDatabaseView(); return ( diff --git a/packages/ui/src/components/databases/search/view-sort-name-row.tsx b/packages/ui/src/components/databases/search/view-sort-name-row.tsx new file mode 100644 index 00000000..73567bbc --- /dev/null +++ b/packages/ui/src/components/databases/search/view-sort-name-row.tsx @@ -0,0 +1,71 @@ +import { ChevronDown, Trash2, Type } from 'lucide-react'; + +import { DatabaseViewSortAttributes } from '@colanode/core'; +import { Button } from '@colanode/ui/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@colanode/ui/components/ui/dropdown-menu'; +import { useDatabase } from '@colanode/ui/contexts/database'; +import { useDatabaseView } from '@colanode/ui/contexts/database-view'; + +interface ViewSortNameRowProps { + sort: DatabaseViewSortAttributes; +} + +export const ViewSortNameRow = ({ sort }: ViewSortNameRowProps) => { + const database = useDatabase(); + const view = useDatabaseView(); + + return ( +
+
+ +

{database.nameField?.name ?? 'Name'}

+
+ + +
+

{sort.direction === 'asc' ? 'Ascending' : 'Descending'}

+ +
+
+ + { + view.updateSort(sort.id, { + ...sort, + direction: 'asc', + }); + }} + > +

Ascending

+
+ { + view.updateSort(sort.id, { + ...sort, + direction: 'desc', + }); + }} + > +

Descending

+
+
+
+ +
+ ); +}; diff --git a/packages/ui/src/components/databases/search/view-sorts.tsx b/packages/ui/src/components/databases/search/view-sorts.tsx index 956dd2a1..07636f37 100644 --- a/packages/ui/src/components/databases/search/view-sorts.tsx +++ b/packages/ui/src/components/databases/search/view-sorts.tsx @@ -1,7 +1,9 @@ import { Plus } from 'lucide-react'; +import { SpecialId } from '@colanode/core'; import { ViewSortAddPopover } from '@colanode/ui/components/databases/search/view-sort-add-popover'; -import { ViewSortRow } from '@colanode/ui/components/databases/search/view-sort-row'; +import { ViewSortFieldRow } from '@colanode/ui/components/databases/search/view-sort-field-row'; +import { ViewSortNameRow } from '@colanode/ui/components/databases/search/view-sort-name-row'; import { Button } from '@colanode/ui/components/ui/button'; import { Popover, @@ -38,12 +40,16 @@ export const ViewSorts = () => { {view.sorts.map((sort) => { + if (sort.fieldId === SpecialId.Name) { + return ; + } + const field = database.fields.find( (field) => field.id === sort.fieldId ); if (!field) return null; - return ; + return ; })}