mirror of
https://github.com/colanode/colanode.git
synced 2025-12-15 19:27:46 +01:00
Implement filter by relation field (#166)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user