mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 11:47:47 +01:00
Implement filter by relation field (#166)
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
|||||||
CreatedByFieldAttributes,
|
CreatedByFieldAttributes,
|
||||||
UpdatedAtFieldAttributes,
|
UpdatedAtFieldAttributes,
|
||||||
UpdatedByFieldAttributes,
|
UpdatedByFieldAttributes,
|
||||||
|
RelationFieldAttributes,
|
||||||
} from '@colanode/core';
|
} from '@colanode/core';
|
||||||
|
|
||||||
type SqliteOperator =
|
type SqliteOperator =
|
||||||
@@ -94,6 +95,8 @@ const buildFilterQuery = (
|
|||||||
return buildNumberFilterQuery(filter, field);
|
return buildNumberFilterQuery(filter, field);
|
||||||
case 'phone':
|
case 'phone':
|
||||||
return buildPhoneFilterQuery(filter, field);
|
return buildPhoneFilterQuery(filter, field);
|
||||||
|
case 'relation':
|
||||||
|
return buildRelationFilterQuery(filter, field);
|
||||||
case 'select':
|
case 'select':
|
||||||
return buildSelectFilterQuery(filter, field);
|
return buildSelectFilterQuery(filter, field);
|
||||||
case 'text':
|
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 = (
|
const buildUrlFilterQuery = (
|
||||||
filter: DatabaseViewFieldFilterAttributes,
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
field: UrlFieldAttributes
|
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 { ViewNameFieldFilter } from '@colanode/ui/components/databases/search/view-name-field-filter';
|
||||||
import { ViewNumberFieldFilter } from '@colanode/ui/components/databases/search/view-number-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 { 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 { ViewSelectFieldFilter } from '@colanode/ui/components/databases/search/view-select-field-filter';
|
||||||
import { ViewTextFieldFilter } from '@colanode/ui/components/databases/search/view-text-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';
|
import { ViewUpdatedAtFieldFilter } from '@colanode/ui/components/databases/search/view-updated-at-field-filter';
|
||||||
@@ -160,6 +161,14 @@ export const ViewFilters = () => {
|
|||||||
filter={filter}
|
filter={filter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'relation':
|
||||||
|
return (
|
||||||
|
<ViewRelationFieldFilter
|
||||||
|
key={filter.id}
|
||||||
|
field={field}
|
||||||
|
filter={filter}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
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 { X } from 'lucide-react';
|
||||||
import { Fragment, useState } from '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 { Avatar } from '@colanode/ui/components/avatars/avatar';
|
||||||
import { RecordSearch } from '@colanode/ui/components/records/record-search';
|
import { RecordSearch } from '@colanode/ui/components/records/record-search';
|
||||||
import { Badge } from '@colanode/ui/components/ui/badge';
|
import { Badge } from '@colanode/ui/components/ui/badge';
|
||||||
@@ -20,7 +21,7 @@ interface RecordRelationValueProps {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RelationBadge = ({ record }: { record: RecordNode }) => {
|
const RelationBadge = ({ record }: { record: LocalRecordNode }) => {
|
||||||
const name = record.attributes.name ?? 'Unnamed';
|
const name = record.attributes.name ?? 'Unnamed';
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row items-center gap-1">
|
<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) {
|
for (const result of results) {
|
||||||
if (result.data && result.data.type === 'record') {
|
if (result.data && result.data.type === 'record') {
|
||||||
relations.push(result.data);
|
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[] = [
|
export const selectFieldFilterOperators: FieldFilterOperator[] = [
|
||||||
{
|
{
|
||||||
label: 'Is In',
|
label: 'Is In',
|
||||||
@@ -588,6 +607,8 @@ export const getFieldFilterOperators = (
|
|||||||
return numberFieldFilterOperators;
|
return numberFieldFilterOperators;
|
||||||
case 'phone':
|
case 'phone':
|
||||||
return phoneFieldFilterOperators;
|
return phoneFieldFilterOperators;
|
||||||
|
case 'relation':
|
||||||
|
return relationFieldFilterOperators;
|
||||||
case 'select':
|
case 'select':
|
||||||
return selectFieldFilterOperators;
|
return selectFieldFilterOperators;
|
||||||
case 'text':
|
case 'text':
|
||||||
|
|||||||
Reference in New Issue
Block a user