Implement filter by relation field (#166)

This commit is contained in:
Hakan Shehu
2025-08-01 14:50:45 +02:00
committed by GitHub
parent e8f168304f
commit db4aa16891
5 changed files with 290 additions and 3 deletions

View File

@@ -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

View File

@@ -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 (
<ViewRelationFieldFilter
key={filter.id}
field={field}
filter={filter}
/>
);
default:
return null;

View File

@@ -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 (
<div className="flex flex-row items-center gap-1">
<Avatar
id={record.id}
name={name}
avatar={record.attributes.avatar}
size="small"
/>
<p className="text-sm line-clamp-1 w-full">{name}</p>
</div>
);
};
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 (
<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="border-dashed text-xs text-muted-foreground"
>
{field.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">
<FieldIcon type={field.type} className="size-4" />
<p>{field.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>
{relationFieldFilterOperators.map((operator) => (
<DropdownMenuItem
key={operator.value}
onSelect={() => {
const value = isOperatorWithoutValue(operator.value)
? []
: relationIds;
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 && (
<Popover>
<PopoverTrigger asChild>
<div className="flex h-full w-full cursor-pointer flex-row items-center gap-1 rounded-md border border-input p-2">
{relations.slice(0, 1).map((relation) => (
<RelationBadge key={relation.id} record={relation} />
))}
{relations.length === 0 && (
<p className="text-xs text-muted-foreground">
No records selected
</p>
)}
{relations.length > 1 && (
<Badge
variant="outline"
className="ml-2 text-xs px-1 text-muted-foreground"
>
+{relations.length - 1}
</Badge>
)}
</div>
</PopoverTrigger>
<PopoverContent className="w-80 p-1">
{relations.length > 0 && (
<div className="flex flex-col flex-wrap gap-2 p-2">
{relations.map((relation) => (
<div
key={relation.id}
className="flex w-full flex-row items-center gap-2"
>
<RelationBadge record={relation} />
<X
className="size-4 cursor-pointer"
onClick={() => {
const newRelations = relationIds.filter(
(id) => id !== relation.id
);
view.updateFilter(filter.id, {
...filter,
value: newRelations,
});
}}
/>
</div>
))}
<Separator className="w-full my-2" />
</div>
)}
<RecordSearch
databaseId={field.databaseId}
exclude={relationIds}
onSelect={(record) => {
const newRelations = relationIds.includes(record.id)
? relationIds.filter((id) => id !== record.id)
: [...relationIds, record.id];
view.updateFilter(filter.id, {
...filter,
value: newRelations,
});
}}
/>
</PopoverContent>
</Popover>
)}
</PopoverContent>
</Popover>
);
};

View File

@@ -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 (
<div className="flex flex-row items-center gap-1">
@@ -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);

View File

@@ -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':