Filter and sort by name (#117)

This commit is contained in:
Hakan Shehu
2025-07-06 14:55:40 +02:00
committed by GitHub
parent f86e7afe11
commit 6fe6f324e8
12 changed files with 577 additions and 263 deletions

View File

@@ -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<RecordListQueryInput>
@@ -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, FieldAttributes>
): string | null => {
if (sort.fieldId === SpecialId.Name) {
return `json_extract(n.attributes, '$.name') ${sort.direction}`;
}
const field = fields[sort.fieldId];
if (!field) {
return null;

View File

@@ -43,6 +43,10 @@ export enum IdType {
Save = 'sv',
}
export const SpecialId = {
Name: 'name',
};
export const generateId = (type: IdType): string => {
return ulid().toLowerCase() + type;
};

View File

@@ -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 = ({
<CommandEmpty>No field found.</CommandEmpty>
<CommandList>
<CommandGroup className="h-min">
{!isFilteredByName && (
<CommandItem
key={SpecialId.Name}
onSelect={() => {
view.initFieldFilter(SpecialId.Name);
setOpen(false);
}}
>
<div className="flex w-full flex-row items-center gap-2">
<Type className="size-4" />
<p>{database.nameField?.name ?? 'Name'}</p>
</div>
</CommandItem>
)}
{fieldsWithoutFilters.map((field) => (
<CommandItem
key={field.id}

View File

@@ -1,11 +1,13 @@
import { Plus } from 'lucide-react';
import { SpecialId } from '@colanode/core';
import { ViewBooleanFieldFilter } from '@colanode/ui/components/databases/search/view-boolean-field-filter';
import { ViewCreatedAtFieldFilter } from '@colanode/ui/components/databases/search/view-created-at-field-fitler';
import { ViewDateFieldFilter } from '@colanode/ui/components/databases/search/view-date-field-filter';
import { ViewEmailFieldFilter } from '@colanode/ui/components/databases/search/view-email-field-filter';
import { ViewFilterAddPopover } from '@colanode/ui/components/databases/search/view-filter-add-popover';
import { ViewMultiSelectFieldFilter } from '@colanode/ui/components/databases/search/view-multi-select-field-filter';
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 { ViewSelectFieldFilter } from '@colanode/ui/components/databases/search/view-select-field-filter';
@@ -17,6 +19,7 @@ import { useDatabaseView } from '@colanode/ui/contexts/database-view';
export const ViewFilters = () => {
const database = useDatabase();
const view = useDatabaseView();
console.log('view.filters', view.filters);
return (
<div className="flex flex-row items-center gap-2">
@@ -26,6 +29,10 @@ export const ViewFilters = () => {
return null;
}
if (filter.fieldId === SpecialId.Name) {
return <ViewNameFieldFilter key={filter.id} filter={filter} />;
}
const field = database.fields.find(
(field) => field.id === filter.fieldId
);

View File

@@ -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 (
<Popover
open={view.isFieldFilterOpened(filter.id)}
onOpenChange={() => {
if (view.isFieldFilterOpened(filter.id)) {
view.closeFieldFilter(filter.id);
} else {
view.openFieldFilter(filter.id);
}
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="text-muted-foregroundc border-dashed text-xs"
>
{database.nameField?.name ?? 'Name'}
</Button>
</PopoverTrigger>
<PopoverContent className="flex w-96 flex-col gap-2 p-2">
<div className="flex flex-row items-center gap-3 text-sm">
<div className="flex flex-row items-center gap-0.5 p-1">
<Type className="size-4" />
<p>{database.nameField?.name ?? 'Name'}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex flex-grow flex-row items-center gap-1 rounded-md p-1 font-semibold cursor-pointer hover:bg-gray-100">
<p>{operator.label}</p>
<ChevronDown className="size-4 text-muted-foreground" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{nameFieldFilterOperators.map((operator) => (
<DropdownMenuItem
key={operator.value}
onSelect={() => {
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}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
onClick={() => {
view.removeFilter(filter.id);
}}
>
<Trash2 className="size-4" />
</Button>
</div>
{!hideInput && (
<SmartTextInput
value={textValue}
onChange={(value) => {
view.updateFilter(filter.id, {
...filter,
value: value,
});
}}
/>
)}
</PopoverContent>
</Popover>
);
};

View File

@@ -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) => {
<CommandEmpty>No sortable field found.</CommandEmpty>
<CommandList>
<CommandGroup className="h-min">
{!isSortedByName && (
<CommandItem
key={SpecialId.Name}
onSelect={() => {
view.initFieldSort(SpecialId.Name, 'asc');
setOpen(false);
}}
>
<div className="flex w-full flex-row items-center gap-2">
<Type className="size-4" />
<p>{database.nameField?.name ?? 'Name'}</p>
</div>
</CommandItem>
)}
{sortableFields.map((field) => (
<CommandItem
key={field.id}

View File

@@ -11,12 +11,12 @@ import {
} from '@colanode/ui/components/ui/dropdown-menu';
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
interface ViewSortProps {
interface ViewSortFieldRowProps {
sort: DatabaseViewSortAttributes;
field: FieldAttributes;
}
export const ViewSortRow = ({ sort, field }: ViewSortProps) => {
export const ViewSortFieldRow = ({ sort, field }: ViewSortFieldRowProps) => {
const view = useDatabaseView();
return (

View File

@@ -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 (
<div className="flex flex-row items-center gap-3 text-sm">
<div className="flex flex-row items-center gap-0.5 p-1">
<Type className="size-4" />
<p>{database.nameField?.name ?? 'Name'}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex flex-grow flex-row items-center gap-1 rounded-md p-1 font-semibold cursor-pointer hover:bg-gray-100">
<p>{sort.direction === 'asc' ? 'Ascending' : 'Descending'}</p>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
view.updateSort(sort.id, {
...sort,
direction: 'asc',
});
}}
>
<p>Ascending</p>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
view.updateSort(sort.id, {
...sort,
direction: 'desc',
});
}}
>
<p>Descending</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
onClick={() => {
view.removeSort(sort.id);
}}
>
<Trash2 className="size-4" />
</Button>
</div>
);
};

View File

@@ -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 = () => {
</PopoverTrigger>
<PopoverContent className="flex flex-col gap-2 p-2">
{view.sorts.map((sort) => {
if (sort.fieldId === SpecialId.Name) {
return <ViewSortNameRow key={sort.id} sort={sort} />;
}
const field = database.fields.find(
(field) => field.id === sort.fieldId
);
if (!field) return null;
return <ViewSortRow key={sort.id} sort={sort} field={field} />;
return <ViewSortFieldRow key={sort.id} sort={sort} field={field} />;
})}
<ViewSortAddPopover>
<button className="flex cursor-pointer flex-row items-center gap-1 rounded-lg p-1 text-sm text-muted-foreground hover:bg-gray-50">

View File

@@ -1,8 +1,9 @@
import { ArrowDownAz, ArrowDownZa, Filter, Type } from 'lucide-react';
import { Resizable } from 're-resizable';
import { Fragment, useRef } from 'react';
import { Fragment, useRef, useState } from 'react';
import { useDrop } from 'react-dnd';
import { SpecialId } from '@colanode/core';
import {
Popover,
PopoverContent,
@@ -18,6 +19,8 @@ export const TableViewNameHeader = () => {
const database = useDatabase();
const view = useDatabaseView();
const [openPopover, setOpenPopover] = useState(false);
const [dropMonitor, dropRef] = useDrop({
accept: 'table-field-header',
drop: () => ({
@@ -65,7 +68,7 @@ export const TableViewNameHeader = () => {
view.resizeName(newWidth);
}}
>
<Popover modal={true}>
<Popover modal={true} open={openPopover} onOpenChange={setOpenPopover}>
<PopoverTrigger asChild>
<div
className={cn(
@@ -97,12 +100,8 @@ export const TableViewNameHeader = () => {
<div
className="flex cursor-pointer flex-row items-center gap-2 p-1 hover:bg-gray-100"
onClick={() => {
// eventBus.publish({
// __typename: 'AddSortEvent',
// field: viewField.field,
// viewId: view.id,
// direction: 'ASC',
// });
view.initFieldSort(SpecialId.Name, 'asc');
setOpenPopover(false);
}}
>
<ArrowDownAz className="size-4" />
@@ -112,12 +111,8 @@ export const TableViewNameHeader = () => {
<div
className="flex cursor-pointer flex-row items-center gap-2 p-1 hover:bg-gray-100"
onClick={() => {
// eventBus.publish({
// __typename: 'AddSortEvent',
// field: viewField.field,
// viewId: view.id,
// direction: 'DESC',
// });
view.initFieldSort(SpecialId.Name, 'desc');
setOpenPopover(false);
}}
>
<ArrowDownZa className="size-4" />
@@ -125,7 +120,13 @@ export const TableViewNameHeader = () => {
</div>
</Fragment>
)}
<div className="flex cursor-pointer flex-row items-center gap-2 p-1 hover:bg-gray-100">
<div
className="flex cursor-pointer flex-row items-center gap-2 p-1 hover:bg-gray-100"
onClick={() => {
view.initFieldFilter(SpecialId.Name);
setOpenPopover(false);
}}
>
<Filter className="size-4" />
<span>Filter</span>
</div>

View File

@@ -9,6 +9,7 @@ import {
DatabaseViewFieldFilterAttributes,
DatabaseViewFilterAttributes,
DatabaseViewSortAttributes,
SpecialId,
} from '@colanode/core';
import { BoardView } from '@colanode/ui/components/databases/boards/board-view';
import { CalendarView } from '@colanode/ui/components/databases/calendars/calendar-view';
@@ -67,7 +68,7 @@ export const View = ({ view }: ViewProps) => {
sorts: Object.values(view.attributes.sorts ?? {}),
groupBy: view.attributes.groupBy,
nameWidth: view.attributes.nameWidth ?? getDefaultNameWidth(),
isSearchBarOpened,
isSearchBarOpened: isSearchBarOpened || openedFieldFilters.length > 0,
isSortsOpened,
rename: async (name: string) => {
if (!database.canEdit) return;
@@ -241,22 +242,35 @@ export const View = ({ view }: ViewProps) => {
return;
}
const field = database.fields.find((f) => f.id === fieldId);
if (!field) {
return;
}
const operators = getFieldFilterOperators(field.type);
const filter: DatabaseViewFieldFilterAttributes = {
type: 'field',
id: fieldId,
fieldId,
operator: operators[0]?.value ?? '',
};
const viewAttributes = { ...view.attributes };
viewAttributes.filters = viewAttributes.filters ?? {};
viewAttributes.filters[fieldId] = filter;
if (fieldId === SpecialId.Name) {
const operators = getFieldFilterOperators('text');
const filter: DatabaseViewFieldFilterAttributes = {
type: 'field',
id: fieldId,
fieldId,
operator: operators[0]?.value ?? 'contains',
};
viewAttributes.filters[fieldId] = filter;
} else {
const field = database.fields.find((f) => f.id === fieldId);
if (!field) {
return;
}
const operators = getFieldFilterOperators(field.type);
const filter: DatabaseViewFieldFilterAttributes = {
type: 'field',
id: fieldId,
fieldId,
operator: operators[0]?.value ?? '',
};
viewAttributes.filters[fieldId] = filter;
}
const result = await window.colanode.executeMutation({
type: 'view.update',
@@ -339,20 +353,31 @@ export const View = ({ view }: ViewProps) => {
return;
}
const field = database.fields.find((f) => f.id === fieldId);
if (!field) {
return;
}
const sort: DatabaseViewSortAttributes = {
id: fieldId,
fieldId,
direction,
};
const viewAttributes = { ...view.attributes };
viewAttributes.sorts = viewAttributes.sorts ?? {};
viewAttributes.sorts[fieldId] = sort;
if (fieldId === SpecialId.Name) {
const sort: DatabaseViewSortAttributes = {
id: fieldId,
fieldId,
direction,
};
viewAttributes.sorts[fieldId] = sort;
} else {
const field = database.fields.find((f) => f.id === fieldId);
if (!field) {
return;
}
const sort: DatabaseViewSortAttributes = {
id: fieldId,
fieldId,
direction,
};
viewAttributes.sorts[fieldId] = sort;
}
const result = await window.colanode.executeMutation({
type: 'view.update',

View File

@@ -153,6 +153,33 @@ export interface FieldFilterOperator {
value: string;
}
export const nameFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Equal To',
value: 'is_equal_to',
},
{
label: 'Is Not Equal To',
value: 'is_not_equal_to',
},
{
label: 'Contains',
value: 'contains',
},
{
label: 'Does Not Contain',
value: 'does_not_contain',
},
{
label: 'Is Empty',
value: 'is_empty',
},
{
label: 'Is Not Empty',
value: 'is_not_empty',
},
];
export const booleanFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is True',