Implement initial version of database filters

This commit is contained in:
Hakan Shehu
2024-09-12 23:53:49 +02:00
parent d71e662e8c
commit 1b3ccc796c
32 changed files with 1938 additions and 334 deletions

View File

@@ -63,6 +63,7 @@
"electron-squirrel-startup": "^1.0.1",
"fractional-indexing-jittered": "^0.9.1",
"is-hotkey": "^0.2.0",
"js-sha256": "^0.11.0",
"kysely": "^0.27.4",
"lodash": "^4.17.21",
"lowlight": "^3.1.0",
@@ -8983,6 +8984,12 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/js-sha256": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz",
"integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@@ -103,6 +103,7 @@
"electron-squirrel-startup": "^1.0.1",
"fractional-indexing-jittered": "^0.9.1",
"is-hotkey": "^0.2.0",
"js-sha256": "^0.11.0",
"kysely": "^0.27.4",
"lodash": "^4.17.21",
"lowlight": "^3.1.0",

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { EmailFieldNode, ViewFilterNode } from '@/types/databases';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { getFieldIcon, emailFieldFilterOperators } from '@/lib/databases';
import { SmartTextInput } from '@/components/ui/smart-text-input';
import { useNodeAttributeDeleteMutation } from '@/mutations/use-node-attribute-delete-mutation';
import { useNodeAttributeUpsertMutation } from '@/mutations/use-node-attribute-upsert-mutation';
import { AttributeTypes } from '@/lib/constants';
import { useNodeDeleteMutation } from '@/mutations/use-node-delete-mutation';
interface ViewEmailFieldFilterProps {
field: EmailFieldNode;
filter: ViewFilterNode;
}
export const ViewEmailFieldFilter = ({
field,
filter,
}: ViewEmailFieldFilterProps) => {
const [open, setOpen] = React.useState(false);
const { mutate: upsertAttribute } = useNodeAttributeUpsertMutation();
const { mutate: deleteAttribute } = useNodeAttributeDeleteMutation();
const { mutate: deleteFilter } = useNodeDeleteMutation();
const operator =
emailFieldFilterOperators.find(
(operator) => operator.value === filter.operator,
) ?? emailFieldFilterOperators[0];
const textValue =
filter.values.length > 0 ? filter.values[0].textValue : null;
const hideInput =
operator.value === 'is_empty' || operator.value === 'is_not_empty';
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<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">
<Icon name={getFieldIcon(field.dataType)} className="h-4 w-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 hover:cursor-pointer hover:bg-gray-100">
<p>{operator.label}</p>
<Icon
name="arrow-down-s-line"
className="h-4 w-4 text-muted-foreground"
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{emailFieldFilterOperators.map((operator) => (
<DropdownMenuItem
key={operator.value}
onSelect={() => {
upsertAttribute({
nodeId: filter.id,
type: AttributeTypes.Operator,
key: '1',
textValue: operator.value,
numberValue: null,
foreignNodeId: null,
});
if (
operator.value === 'is_empty' ||
operator.value === 'is_not_empty'
) {
deleteAttribute({
nodeId: filter.id,
type: AttributeTypes.Value,
key: '1',
});
}
}}
>
{operator.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
onClick={() => {
deleteFilter(filter.id);
}}
>
<Icon name="delete-bin-line" className="h-4 w-4" />
</Button>
</div>
{!hideInput && (
<SmartTextInput
value={textValue}
onChange={(value) => {
upsertAttribute({
nodeId: filter.id,
type: AttributeTypes.Value,
key: '1',
textValue: value,
numberValue: null,
foreignNodeId: null,
});
}}
/>
)}
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { Icon } from '@/components/ui/icon';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { useDatabase } from '@/contexts/database';
import { ViewFilterNode } from '@/types/databases';
import { getFieldFilterOperators, getFieldIcon } from '@/lib/databases';
import { useViewFilterCreateMutation } from '@/mutations/use-view-filter-create-mutation';
interface ViewAddFilterButtonProps {
viewId: string;
existingFilters: ViewFilterNode[];
}
export const ViewAddFilterButton = ({
viewId,
existingFilters,
}: ViewAddFilterButtonProps) => {
const database = useDatabase();
const { mutate, isPending } = useViewFilterCreateMutation();
const [open, setOpen] = React.useState(false);
const fieldsWithoutFilters = database.fields.filter(
(field) => !existingFilters.some((filter) => filter.fieldId === field.id),
);
if (fieldsWithoutFilters.length === 0) {
return null;
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className="flex cursor-pointer flex-row items-center gap-1 rounded-lg p-1 text-sm text-muted-foreground hover:bg-gray-50">
<Icon name="add-line" />
Add filter
</button>
</PopoverTrigger>
<PopoverContent className="w-96 p-1">
<Command className="min-h-min">
<CommandInput placeholder="Search fields..." className="h-9" />
<CommandEmpty>No field found.</CommandEmpty>
<CommandList>
<CommandGroup className="h-min max-h-96">
{fieldsWithoutFilters.map((field) => (
<CommandItem
key={field.id}
onSelect={() => {
if (isPending) {
return;
}
const operators = getFieldFilterOperators(field.dataType);
mutate({
viewId,
fieldId: field.id,
operator: operators[0].value,
});
setOpen(false);
}}
>
<div className="flex w-full flex-row items-center gap-2">
<Icon name={getFieldIcon(field.dataType)} />
<p>{field.name}</p>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};

View File

@@ -1,60 +0,0 @@
import React from 'react';
import { useDatabase } from '@/contexts/database';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Icon } from '@/components/ui/icon';
import { getFieldIcon } from '@/lib/databases';
interface ViewFilterFieldDropdownProps {
value: string;
onChange: (value: string) => void;
}
export const ViewFilterFieldDropdown = ({
value,
onChange,
}: ViewFilterFieldDropdownProps) => {
const database = useDatabase();
if (database.fields.length === 0) {
return null;
}
const selectedField =
database.fields.find((field) => field.id === value) ?? database.fields[0];
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex flex-row items-center gap-1 px-2 py-1"
>
<Icon name={getFieldIcon(selectedField.dataType)} />
<span>{selectedField.name}</span>
<Icon name="arrow-down-s-line" className="ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="mr-5 w-56">
{database.fields.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
onChange(item.id);
}}
>
<div className="flex w-full flex-row items-center gap-2">
<Icon name={getFieldIcon(item.dataType)} />
<p className="flex-grow">{item.name}</p>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -1,51 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Icon } from '@/components/ui/icon';
import { FieldFilterOperator } from '@/lib/databases';
interface ViewFilterOperatorDropdownProps {
operators: FieldFilterOperator[];
value: FieldFilterOperator;
onChange: (value: FieldFilterOperator) => void;
}
export const ViewFilterOperatorDropdown = ({
operators,
value,
onChange,
}: ViewFilterOperatorDropdownProps) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex flex-row items-center gap-1 px-2 py-1"
>
<span>{value.label}</span>
<Icon name="arrow-down-s-line" className="ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="mr-5 w-56">
{operators.map((item) => (
<DropdownMenuItem
key={item.value}
onClick={() => {
onChange(item);
}}
>
<div className="flex w-full flex-row items-center gap-2">
<p className="flex-grow">{item.label}</p>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -1,72 +0,0 @@
import React from 'react';
import { ViewFilterNode } from '@/types/databases';
import { ViewFilterFieldDropdown } from '@/components/databases/filters/view-filter-field-dropdown';
import { ViewFilterOperatorDropdown } from '@/components/databases/filters/view-filter-operator-dropdown';
import { Icon } from '@/components/ui/icon';
import { useDatabase } from '@/contexts/database';
import { getFieldFilterOperators } from '@/lib/databases';
interface ViewFilterProps {
filter: ViewFilterNode;
onChange: (filter: ViewFilterNode) => void;
onRemove: () => void;
}
export const ViewFilter = ({ filter, onChange, onRemove }: ViewFilterProps) => {
const database = useDatabase();
const field = database.fields.find((field) => field.id === filter.fieldId);
if (!field) {
return null;
}
const operators = getFieldFilterOperators(field.dataType);
if (operators.length === 0) {
return null;
}
const operator =
operators.find((operator) => operator.value === filter.operator) ??
operators[0];
const showInput = true;
return (
<div className="flex flex-row gap-2 p-1">
<ViewFilterFieldDropdown
value={filter.fieldId}
onChange={(field) => {
onChange({
...filter,
fieldId: field,
});
}}
/>
<ViewFilterOperatorDropdown
operators={operators}
value={operator}
onChange={(operator) => {
onChange({
...filter,
operator: operator.value,
});
}}
/>
<div className="flex-1">
{/* {showInput && (
<FieldFilterInput
field={field}
filter={filter}
setFilter={onChange}
/>
)} */}
<p>input</p>
</div>
<div
role="presentation"
className="ml-auto flex cursor-pointer items-center text-muted-foreground"
onClick={onRemove}
>
<Icon name="delete-bin-line" />
</div>
</div>
);
};

View File

@@ -1,25 +0,0 @@
import React from 'react';
import { Icon } from '@/components/ui/icon';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
export const ViewFiltersPopover = () => {
return (
<Popover>
<PopoverTrigger asChild>
<div className="flex cursor-pointer items-center rounded-md p-1.5 hover:bg-gray-50">
<Icon name="filter-line" />
</div>
</PopoverTrigger>
<PopoverContent
align="end"
className="mr-4 flex w-[600px] flex-col gap-1.5 p-2"
>
filters goes here
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,97 @@
import React from 'react';
import { ViewFilterNode } from '@/types/databases';
import { useDatabase } from '@/contexts/database';
import { ViewTextFieldFilter } from '@/components/databases/filters/view-text-field-filter';
import { ViewAddFilterButton } from '@/components/databases/filters/view-filter-add-button';
import { ViewNumberFieldFilter } from '@/components/databases/filters/view-number-field-filter';
import { ViewEmailFieldFilter } from '@/components/databases/filters/view-email-field-filter';
import { ViewUrlFieldFilter } from '@/components/databases/filters/view-url-field-filter';
import { ViewPhoneFieldFilter } from '@/components/databases/filters/view-phone-field-filter';
interface ViewFiltersProps {
viewId: string;
filters: ViewFilterNode[];
}
export const ViewFilters = ({ viewId, filters }: ViewFiltersProps) => {
const database = useDatabase();
return (
<div className="mt-3 flex flex-row items-center gap-2">
{filters &&
filters.map((filter) => {
const field = database.fields.find(
(field) => field.id === filter.fieldId,
);
if (!field) {
return null;
}
switch (field.dataType) {
case 'boolean':
return null;
case 'collaborator':
return null;
case 'created_at':
return null;
case 'created_by':
return null;
case 'date':
return null;
case 'email':
return (
<ViewEmailFieldFilter
key={filter.id}
field={field}
filter={filter}
/>
);
case 'file':
return null;
case 'multi_select':
return null;
case 'number':
return (
<ViewNumberFieldFilter
key={filter.id}
field={field}
filter={filter}
/>
);
case 'phone':
return (
<ViewPhoneFieldFilter
key={filter.id}
field={field}
filter={filter}
/>
);
case 'select':
return null;
case 'text':
return (
<ViewTextFieldFilter
key={filter.id}
field={field}
filter={filter}
/>
);
case 'url':
return (
<ViewUrlFieldFilter
key={filter.id}
field={field}
filter={filter}
/>
);
default:
return null;
}
})}
<ViewAddFilterButton viewId={viewId} existingFilters={filters} />
</div>
);
};

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { NumberFieldNode, ViewFilterNode } from '@/types/databases';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { getFieldIcon, numberFieldFilterOperators } from '@/lib/databases';
import { useNodeAttributeDeleteMutation } from '@/mutations/use-node-attribute-delete-mutation';
import { useNodeAttributeUpsertMutation } from '@/mutations/use-node-attribute-upsert-mutation';
import { AttributeTypes } from '@/lib/constants';
import { SmartNumberInput } from '@/components/ui/smart-number-input';
import { useNodeDeleteMutation } from '@/mutations/use-node-delete-mutation';
interface ViewNumberFieldFilterProps {
field: NumberFieldNode;
filter: ViewFilterNode;
}
export const ViewNumberFieldFilter = ({
field,
filter,
}: ViewNumberFieldFilterProps) => {
const [open, setOpen] = React.useState(false);
const { mutate: upsertAttribute } = useNodeAttributeUpsertMutation();
const { mutate: deleteAttribute } = useNodeAttributeDeleteMutation();
const { mutate: deleteFilter } = useNodeDeleteMutation();
const operator =
numberFieldFilterOperators.find(
(operator) => operator.value === filter.operator,
) ?? numberFieldFilterOperators[0];
const numberValue =
filter.values.length > 0 ? filter.values[0].numberValue : null;
const hideInput =
operator.value === 'is_empty' || operator.value === 'is_not_empty';
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<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">
<Icon name={getFieldIcon(field.dataType)} className="h-4 w-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 hover:cursor-pointer hover:bg-gray-100">
<p>{operator.label}</p>
<Icon
name="arrow-down-s-line"
className="h-4 w-4 text-muted-foreground"
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{numberFieldFilterOperators.map((operator) => (
<DropdownMenuItem
key={operator.value}
onSelect={() => {
upsertAttribute({
nodeId: filter.id,
type: AttributeTypes.Operator,
key: '1',
textValue: operator.value,
numberValue: null,
foreignNodeId: null,
});
if (
operator.value === 'is_empty' ||
operator.value === 'is_not_empty'
) {
deleteAttribute({
nodeId: filter.id,
type: AttributeTypes.Value,
key: '1',
});
}
}}
>
{operator.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
onClick={() => {
deleteFilter(filter.id);
}}
>
<Icon name="delete-bin-line" className="h-4 w-4" />
</Button>
</div>
{!hideInput && (
<SmartNumberInput
value={numberValue ?? null}
onChange={(value) => {
upsertAttribute({
nodeId: filter.id,
type: AttributeTypes.Value,
key: '1',
textValue: null,
numberValue: value,
foreignNodeId: null,
});
}}
/>
)}
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { PhoneFieldNode, ViewFilterNode } from '@/types/databases';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { phoneFieldFilterOperators } from '@/lib/databases';
import { SmartTextInput } from '@/components/ui/smart-text-input';
import { useNodeAttributeDeleteMutation } from '@/mutations/use-node-attribute-delete-mutation';
import { useNodeAttributeUpsertMutation } from '@/mutations/use-node-attribute-upsert-mutation';
import { AttributeTypes } from '@/lib/constants';
import { useNodeDeleteMutation } from '@/mutations/use-node-delete-mutation';
interface ViewPhoneFieldFilterProps {
field: PhoneFieldNode;
filter: ViewFilterNode;
}
export const ViewPhoneFieldFilter = ({
field,
filter,
}: ViewPhoneFieldFilterProps) => {
const [open, setOpen] = React.useState(false);
const { mutate: upsertAttribute } = useNodeAttributeUpsertMutation();
const { mutate: deleteAttribute } = useNodeAttributeDeleteMutation();
const { mutate: deleteFilter } = useNodeDeleteMutation();
const operator =
phoneFieldFilterOperators.find(
(operator) => operator.value === filter.operator,
) ?? phoneFieldFilterOperators[0];
const phoneValue =
filter.values.length > 0 ? filter.values[0].textValue : null;
const hideInput =
operator.value === 'is_empty' || operator.value === 'is_not_empty';
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<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">
<Icon name="phone-line" className="h-4 w-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 hover:cursor-pointer hover:bg-gray-100">
<p>{operator.label}</p>
<Icon
name="arrow-down-s-line"
className="h-4 w-4 text-muted-foreground"
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{phoneFieldFilterOperators.map((operator) => (
<DropdownMenuItem
key={operator.value}
onSelect={() => {
upsertAttribute({
nodeId: filter.id,
type: AttributeTypes.Operator,
key: '1',
textValue: operator.value,
numberValue: null,
foreignNodeId: null,
});
if (
operator.value === 'is_empty' ||
operator.value === 'is_not_empty'
) {
deleteAttribute({
nodeId: filter.id,
type: AttributeTypes.Value,
key: '1',
});
}
}}
>
{operator.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
onClick={() => {
deleteFilter(filter.id);
}}
>
<Icon name="delete-bin-line" className="h-4 w-4" />
</Button>
</div>
{!hideInput && (
<SmartTextInput
value={phoneValue}
onChange={(value) => {
upsertAttribute({
nodeId: filter.id,
type: AttributeTypes.Value,
key: '1',
textValue: value,
numberValue: null,
foreignNodeId: null,
});
}}
/>
)}
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { TextFieldNode, ViewFilterNode } from '@/types/databases';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { getFieldIcon, textFieldFilterOperators } from '@/lib/databases';
import { SmartTextInput } from '@/components/ui/smart-text-input';
import { useNodeAttributeDeleteMutation } from '@/mutations/use-node-attribute-delete-mutation';
import { useNodeAttributeUpsertMutation } from '@/mutations/use-node-attribute-upsert-mutation';
import { AttributeTypes } from '@/lib/constants';
import { useNodeDeleteMutation } from '@/mutations/use-node-delete-mutation';
interface ViewTextFieldFilterProps {
field: TextFieldNode;
filter: ViewFilterNode;
}
export const ViewTextFieldFilter = ({
field,
filter,
}: ViewTextFieldFilterProps) => {
const [open, setOpen] = React.useState(false);
const { mutate: upsertAttribute } = useNodeAttributeUpsertMutation();
const { mutate: deleteAttribute } = useNodeAttributeDeleteMutation();
const { mutate: deleteFilter } = useNodeDeleteMutation();
const operator =
textFieldFilterOperators.find(
(operator) => operator.value === filter.operator,
) ?? textFieldFilterOperators[0];
const textValue =
filter.values.length > 0 ? filter.values[0].textValue : null;
const hideInput =
operator.value === 'is_empty' || operator.value === 'is_not_empty';
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<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">
<Icon name={getFieldIcon(field.dataType)} className="h-4 w-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 hover:cursor-pointer hover:bg-gray-100">
<p>{operator.label}</p>
<Icon
name="arrow-down-s-line"
className="h-4 w-4 text-muted-foreground"
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{textFieldFilterOperators.map((operator) => (
<DropdownMenuItem
key={operator.value}
onSelect={() => {
upsertAttribute({
nodeId: filter.id,
type: AttributeTypes.Operator,
key: '1',
textValue: operator.value,
numberValue: null,
foreignNodeId: null,
});
if (
operator.value === 'is_empty' ||
operator.value === 'is_not_empty'
) {
deleteAttribute({
nodeId: filter.id,
type: AttributeTypes.Value,
key: '1',
});
}
}}
>
{operator.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
onClick={() => {
deleteFilter(filter.id);
}}
>
<Icon name="delete-bin-line" className="h-4 w-4" />
</Button>
</div>
{!hideInput && (
<SmartTextInput
value={textValue}
onChange={(value) => {
upsertAttribute({
nodeId: filter.id,
type: AttributeTypes.Value,
key: '1',
textValue: value,
numberValue: null,
foreignNodeId: null,
});
}}
/>
)}
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,133 @@
import React from 'react';
import { UrlFieldNode, ViewFilterNode } from '@/types/databases';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { urlFieldFilterOperators } from '@/lib/databases';
import { SmartTextInput } from '@/components/ui/smart-text-input';
import { useNodeAttributeDeleteMutation } from '@/mutations/use-node-attribute-delete-mutation';
import { useNodeAttributeUpsertMutation } from '@/mutations/use-node-attribute-upsert-mutation';
import { AttributeTypes } from '@/lib/constants';
import { useNodeDeleteMutation } from '@/mutations/use-node-delete-mutation';
interface ViewUrlFieldFilterProps {
field: UrlFieldNode;
filter: ViewFilterNode;
}
export const ViewUrlFieldFilter = ({
field,
filter,
}: ViewUrlFieldFilterProps) => {
const [open, setOpen] = React.useState(false);
const { mutate: upsertAttribute } = useNodeAttributeUpsertMutation();
const { mutate: deleteAttribute } = useNodeAttributeDeleteMutation();
const { mutate: deleteFilter } = useNodeDeleteMutation();
const operator =
urlFieldFilterOperators.find(
(operator) => operator.value === filter.operator,
) ?? urlFieldFilterOperators[0];
const urlValue = filter.values.length > 0 ? filter.values[0].textValue : null;
const hideInput =
operator.value === 'is_empty' || operator.value === 'is_not_empty';
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<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">
<Icon name="link" className="h-4 w-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 hover:cursor-pointer hover:bg-gray-100">
<p>{operator.label}</p>
<Icon
name="arrow-down-s-line"
className="h-4 w-4 text-muted-foreground"
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{urlFieldFilterOperators.map((operator) => (
<DropdownMenuItem
key={operator.value}
onSelect={() => {
upsertAttribute({
nodeId: filter.id,
type: AttributeTypes.Operator,
key: '1',
textValue: operator.value,
numberValue: null,
foreignNodeId: null,
});
if (
operator.value === 'is_empty' ||
operator.value === 'is_not_empty'
) {
deleteAttribute({
nodeId: filter.id,
type: AttributeTypes.Value,
key: '1',
});
}
}}
>
{operator.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
onClick={() => {
deleteFilter(filter.id);
}}
>
<Icon name="delete-bin-line" className="h-4 w-4" />
</Button>
</div>
{!hideInput && (
<SmartTextInput
value={urlValue}
onChange={(value) => {
upsertAttribute({
nodeId: filter.id,
type: AttributeTypes.Value,
key: '1',
textValue: value,
numberValue: null,
foreignNodeId: null,
});
}}
/>
)}
</PopoverContent>
</Popover>
);
};

View File

@@ -1,14 +0,0 @@
import React from 'react';
import { ViewSortButton } from '@/components/databases/view-sort-button';
import { ViewFiltersPopover } from '@/components/databases/filters/view-filters-popover';
import { TableViewSettingsPopover } from '@/components/databases/tables/table-view-settings-popover';
export const TableViewActions = () => {
return (
<div className="invisible flex flex-row items-center justify-end group-hover/database:visible">
<TableViewSettingsPopover />
<ViewSortButton />
<ViewFiltersPopover />
</div>
);
};

View File

@@ -4,12 +4,14 @@ import { TableViewRow } from '@/components/databases/tables/table-view-row';
import { TableViewEmptyPlaceholder } from '@/components/databases/tables/table-view-empty-placeholder';
import { TableViewLoadMoreRow } from './table-view-load-more-row';
import { useRecordsQuery } from '@/queries/use-records-query';
import { useTableView } from '@/contexts/table-view';
export const TableViewBody = () => {
const database = useDatabase();
const tableView = useTableView();
const { data, isPending, isFetchingNextPage, fetchNextPage, hasNextPage } =
useRecordsQuery(database.id);
useRecordsQuery(database.id, tableView.filters);
const records = data ?? [];
return (

View File

@@ -6,14 +6,16 @@ import { TableViewContext } from '@/contexts/table-view';
import { useDatabase } from '@/contexts/database';
import { compareString } from '@/lib/utils';
import { FieldDataType, TableViewNode } from '@/types/databases';
import { ViewTabs } from '@/components/databases/view-tabs';
import { TableViewSettingsPopover } from '@/components/databases/tables/table-view-settings-popover';
import { getDefaultFieldWidth, getDefaultNameWidth } from '@/lib/databases';
import { generateNodeIndex } from '@/lib/nodes';
import { useUpdateViewFieldIndexMutation } from '@/mutations/use-update-view-field-index-mutation';
import { useUpdateViewNameWidthMutation } from '@/mutations/use-update-view-name-width-mutation';
import { useUpdateViewFieldWidthMutation } from '@/mutations/use-update-view-field-width-mutation';
import { useUpdateViewHiddenFieldMutation } from '@/mutations/use-update-hidden-field-mutation';
import { ViewTabs } from '@/components/databases/view-tabs';
import { TableViewActions } from '@/components/databases/tables/table-view-actions';
import { ViewFilters } from '@/components/databases/filters/view-filters';
import { ViewActionButton } from '@/components/databases/view-action-button';
interface TableViewProps {
node: TableViewNode;
@@ -40,6 +42,9 @@ export const TableView = ({ node }: TableViewProps) => {
node.nameWidth ?? getDefaultNameWidth(),
);
const [openFilters, setOpenFilters] = React.useState(true);
const [openSort, setOpenSort] = React.useState(false);
React.useEffect(() => {
setHiddenFields(node.hiddenFields ?? []);
setFieldIndexes(node.fieldIndexes ?? {});
@@ -64,6 +69,7 @@ export const TableView = ({ node }: TableViewProps) => {
id: node.id,
name: node.name,
fields,
filters: node.filters,
hideField: (id: string) => {
if (hiddenFields.includes(id)) {
return;
@@ -184,8 +190,19 @@ export const TableView = ({ node }: TableViewProps) => {
>
<div className="mt-2 flex flex-row justify-between border-b">
<ViewTabs />
<TableViewActions />
<div className="invisible flex flex-row items-center justify-end group-hover/database:visible">
<TableViewSettingsPopover />
<ViewActionButton
icon="sort-desc"
onClick={() => setOpenSort((prev) => !prev)}
/>
<ViewActionButton
icon="filter-line"
onClick={() => setOpenFilters((prev) => !prev)}
/>
</div>
</div>
{openFilters && <ViewFilters viewId={node.id} filters={node.filters} />}
<div className="mt-2 w-full min-w-full max-w-full overflow-auto pr-5">
<TableViewHeader />
<TableViewBody />

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { Icon } from '@/components/ui/icon';
interface ViewActionButtonProps {
onClick: () => void;
icon: string;
}
export const ViewActionButton = ({ onClick, icon }: ViewActionButtonProps) => {
return (
<button
className="flex cursor-pointer items-center rounded-md p-1.5 hover:bg-gray-50"
onClick={onClick}
>
<Icon name={icon} />
</button>
);
};

View File

@@ -1,10 +0,0 @@
import React from 'react';
import { Icon } from '@/components/ui/icon';
export const ViewSortButton = () => {
return (
<div className="flex cursor-pointer items-center rounded-md p-1.5 hover:bg-gray-50">
<Icon name="sort-desc" />
</div>
);
};

View File

@@ -0,0 +1,103 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import debounce from 'lodash/debounce';
interface SmartNumberInputProps {
value: number | null;
onChange: (newValue: number) => void;
className?: string;
min?: number;
max?: number;
step?: number;
}
const SmartNumberInput = React.forwardRef<
HTMLInputElement,
SmartNumberInputProps
>(({ value, onChange, className, min, max, step = 1, ...props }, ref) => {
const [localValue, setLocalValue] = React.useState(value?.toString() ?? '');
const initialValue = React.useRef(value?.toString() ?? '');
// Create a debounced version of onChange
const debouncedOnChange = React.useMemo(
() => debounce((value: number) => onChange(value), 500),
[onChange],
);
// Update localValue when value prop changes
React.useEffect(() => {
setLocalValue(value?.toString() ?? '');
initialValue.current = value?.toString() ?? '';
}, [value]);
// Cleanup debounce on unmount
React.useEffect(() => {
return () => {
debouncedOnChange.cancel();
};
}, [debouncedOnChange]);
const handleBlur = () => {
const newValue = parseFloat(localValue);
if (!isNaN(newValue) && localValue !== initialValue.current) {
debouncedOnChange.cancel(); // Cancel any pending debounced calls
onChange(applyConstraints(newValue));
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
setLocalValue(initialValue.current); // Revert to initial value
debouncedOnChange.cancel(); // Cancel any pending debounced calls
} else if (event.key === 'Enter') {
const newValue = parseFloat(localValue);
if (!isNaN(newValue) && localValue !== initialValue.current) {
onChange(applyConstraints(newValue)); // Fire onChange immediately when Enter is pressed
debouncedOnChange.cancel(); // Cancel any pending debounced calls
}
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
setLocalValue(newValue);
const parsedValue = parseFloat(newValue);
if (!isNaN(parsedValue)) {
debouncedOnChange(applyConstraints(parsedValue)); // Trigger debounced onChange
}
};
const applyConstraints = (value: number): number => {
let constrainedValue = value;
if (min !== undefined) {
constrainedValue = Math.max(min, constrainedValue);
}
if (max !== undefined) {
constrainedValue = Math.min(max, constrainedValue);
}
return Math.round(constrainedValue / step) * step;
};
return (
<input
type="number"
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
step={step}
min={min}
max={max}
{...props}
/>
);
});
SmartNumberInput.displayName = 'SmartNumberInput';
export { SmartNumberInput };

View File

@@ -0,0 +1,80 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import debounce from 'lodash/debounce';
interface SmartTextInputProps {
value: string | null;
onChange: (newValue: string) => void;
className?: string;
}
const SmartTextInput = React.forwardRef<HTMLInputElement, SmartTextInputProps>(
({ value, onChange, className, ...props }, ref) => {
const [localValue, setLocalValue] = React.useState(value ?? '');
const initialValue = React.useRef(value ?? '');
// Create a debounced version of onChange
const debouncedOnChange = React.useMemo(
() => debounce((value: string) => onChange(value), 500),
[onChange],
);
// Update localValue when value prop changes
React.useEffect(() => {
setLocalValue(value ?? '');
initialValue.current = value ?? '';
}, [value]);
// Cleanup debounce on unmount
React.useEffect(() => {
return () => {
debouncedOnChange.cancel();
};
}, [debouncedOnChange]);
const handleBlur = () => {
if (localValue !== initialValue.current) {
debouncedOnChange.cancel(); // Cancel any pending debounced calls
onChange(localValue);
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
setLocalValue(initialValue.current); // Revert to initial value
debouncedOnChange.cancel(); // Cancel any pending debounced calls
} else if (event.key === 'Enter') {
if (localValue !== initialValue.current) {
onChange(localValue); // Fire onChange immediately when Enter is pressed
debouncedOnChange.cancel(); // Cancel any pending debounced calls
}
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
setLocalValue(newValue);
debouncedOnChange(newValue); // Trigger debounced onChange
};
return (
<input
type="text"
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
{...props}
/>
);
},
);
SmartTextInput.displayName = 'SmartTextInput';
export { SmartTextInput };

View File

@@ -1,10 +1,11 @@
import { FieldNode, FieldDataType } from '@/types/databases';
import { FieldNode, FieldDataType, ViewFilterNode } from '@/types/databases';
import { createContext, useContext } from 'react';
interface TableViewContext {
id: string;
name: string;
fields: FieldNode[];
filters: ViewFilterNode[];
hideField: (id: string) => void;
showField: (id: string) => void;
isHiddenField: (id: string) => boolean;

View File

@@ -1,14 +0,0 @@
import { ViewFilterNode } from '@/types/databases';
import { createContext, useContext } from 'react';
interface ViewContext {
id: string;
filters: ViewFilterNode[];
addFilter: (filter: ViewFilterNode) => void;
removeFilter: (id: string) => void;
updateFilter: (id: string, filter: ViewFilterNode) => void;
}
export const ViewContext = createContext<ViewContext>({} as ViewContext);
export const useView = () => useContext(ViewContext);

View File

@@ -26,6 +26,7 @@ export const NodeTypes = {
CalendarView: 'calendar_view',
Field: 'field',
SelectOption: 'select_option',
ViewFilter: 'view_filter',
};
export const EditorNodeTypes = {
@@ -78,4 +79,7 @@ export const AttributeTypes = {
FieldWidth: 'field_width',
FieldIndex: 'field_index',
NameWidth: 'name_width',
FieldId: 'field_id',
Operator: 'operator',
Value: 'value',
};

View File

@@ -166,14 +166,6 @@ export const booleanFieldFilterOperators: FieldFilterOperator[] = [
];
export const collaboratorFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Empty',
value: 'is_empty',
},
{
label: 'Is Not Empty',
value: 'is_not_empty',
},
{
label: 'Is Me',
value: 'is_me',
@@ -190,6 +182,14 @@ export const collaboratorFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not In',
value: 'is_not_in',
},
{
label: 'Is Empty',
value: 'is_empty',
},
{
label: 'Is Not Empty',
value: 'is_not_empty',
},
];
export const createdAtFieldFilterOperators: FieldFilterOperator[] = [
@@ -223,14 +223,6 @@ export const createdByFieldFilterOperators: FieldFilterOperator[] = [
];
export const dateFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Empty',
value: 'is_empty',
},
{
label: 'Is Not Empty',
value: 'is_not_empty',
},
{
label: 'Is Equal To',
value: 'is_equal_to',
@@ -239,9 +231,6 @@ export const dateFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not Equal To',
value: 'is_not_equal_to',
},
];
export const emailFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Empty',
value: 'is_empty',
@@ -250,6 +239,9 @@ export const emailFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not Empty',
value: 'is_not_empty',
},
];
export const emailFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Equal To',
value: 'is_equal_to',
@@ -266,9 +258,6 @@ export const emailFieldFilterOperators: FieldFilterOperator[] = [
label: 'Does Not Contain',
value: 'does_not_contain',
},
];
export const fileFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Empty',
value: 'is_empty',
@@ -277,6 +266,9 @@ export const fileFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not Empty',
value: 'is_not_empty',
},
];
export const fileFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is In',
value: 'is_in',
@@ -285,17 +277,17 @@ export const fileFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not In',
value: 'is_not_in',
},
{
label: 'Is Empty',
value: 'is_empty',
},
{
label: 'Is Not Empty',
value: 'is_not_empty',
},
];
export const multiSelectFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Empty',
value: 'is_empty',
},
{
label: 'Is Not Empty',
value: 'is_not_empty',
},
{
label: 'Is In',
value: 'is_in',
@@ -304,9 +296,6 @@ export const multiSelectFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not In',
value: 'is_not_in',
},
];
export const numberFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Empty',
value: 'is_empty',
@@ -315,6 +304,9 @@ export const numberFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not Empty',
value: 'is_not_empty',
},
];
export const numberFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Equal To',
value: 'is_equal_to',
@@ -339,6 +331,14 @@ export const numberFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Less Than Or Equal To',
value: 'is_less_than_or_equal_to',
},
{
label: 'Is Empty',
value: 'is_empty',
},
{
label: 'Is Not Empty',
value: 'is_not_empty',
},
];
export const phoneFieldFilterOperators: FieldFilterOperator[] = [
@@ -369,14 +369,6 @@ export const phoneFieldFilterOperators: FieldFilterOperator[] = [
];
export const selectFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Empty',
value: 'is_empty',
},
{
label: 'Is Not Empty',
value: 'is_not_empty',
},
{
label: 'Is In',
value: 'is_in',
@@ -385,9 +377,6 @@ export const selectFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not In',
value: 'is_not_in',
},
];
export const textFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Empty',
value: 'is_empty',
@@ -396,6 +385,17 @@ export const textFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not Empty',
value: 'is_not_empty',
},
];
export const textFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Contains',
value: 'contains',
},
{
label: 'Does Not Contain',
value: 'does_not_contain',
},
{
label: 'Is Equal To',
value: 'is_equal_to',
@@ -404,25 +404,18 @@ export const textFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not Equal To',
value: 'is_not_equal_to',
},
{
label: 'Contains',
value: 'contains',
label: 'Is Empty',
value: 'is_empty',
},
{
label: 'Does Not Contain',
value: 'does_not_contain',
label: 'Is Not Empty',
value: 'is_not_empty',
},
];
export const urlFieldFilterOperators: FieldFilterOperator[] = [
{
label: 'Is Empty',
value: 'is_empty',
},
{
label: 'Is Not Empty',
value: 'is_not_empty',
},
{
label: 'Is Equal To',
value: 'is_equal_to',
@@ -439,6 +432,14 @@ export const urlFieldFilterOperators: FieldFilterOperator[] = [
label: 'Does Not Contain',
value: 'does_not_contain',
},
{
label: 'Is Empty',
value: 'is_empty',
},
{
label: 'Is Not Empty',
value: 'is_not_empty',
},
];
export const getFieldFilterOperators = (

View File

@@ -36,6 +36,7 @@ enum IdType {
CalendarView = 'cv',
Field = 'fi',
SelectOption = 'so',
ViewFilter = 'vf',
}
export class NeuronId {
@@ -105,6 +106,8 @@ export class NeuronId {
return IdType.Field;
case NodeTypes.SelectOption:
return IdType.SelectOption;
case NodeTypes.ViewFilter:
return IdType.ViewFilter;
default:
return IdType.Node;
}

View File

@@ -0,0 +1,29 @@
import { useWorkspace } from '@/contexts/workspace';
import { useMutation } from '@tanstack/react-query';
interface NodeAttributeDeleteInput {
nodeId: string;
type: string;
key: string;
}
export const useNodeAttributeDeleteMutation = () => {
const workspace = useWorkspace();
return useMutation({
mutationFn: async (input: NodeAttributeDeleteInput) => {
const query = workspace.schema
.deleteFrom('node_attributes')
.where((eb) =>
eb.and([
eb('node_id', '=', input.nodeId),
eb('type', '=', input.type),
eb('key', '=', input.key),
]),
)
.compile();
await workspace.mutate(query);
},
});
};

View File

@@ -0,0 +1,47 @@
import { useWorkspace } from '@/contexts/workspace';
import { NeuronId } from '@/lib/id';
import { useMutation } from '@tanstack/react-query';
interface NodeAttributeUpsertInput {
nodeId: string;
type: string;
key: string;
textValue: string | null;
numberValue: number | null;
foreignNodeId: string | null;
}
export const useNodeAttributeUpsertMutation = () => {
const workspace = useWorkspace();
return useMutation({
mutationFn: async (input: NodeAttributeUpsertInput) => {
const query = workspace.schema
.insertInto('node_attributes')
.values({
node_id: input.nodeId,
type: input.type,
key: input.key,
text_value: input.textValue,
number_value: input.numberValue,
foreign_node_id: input.foreignNodeId,
created_at: new Date().toISOString(),
created_by: workspace.userId,
version_id: NeuronId.generate(NeuronId.Type.Version),
})
.onConflict((b) =>
b.doUpdateSet({
text_value: input.textValue,
number_value: input.numberValue,
foreign_node_id: input.foreignNodeId,
updated_at: new Date().toISOString(),
updated_by: workspace.userId,
version_id: NeuronId.generate(NeuronId.Type.Version),
}),
)
.compile();
await workspace.mutate(query);
},
});
};

View File

@@ -0,0 +1,81 @@
import { useWorkspace } from '@/contexts/workspace';
import { AttributeTypes, NodeTypes } from '@/lib/constants';
import { NeuronId } from '@/lib/id';
import { generateNodeIndex } from '@/lib/nodes';
import { useMutation } from '@tanstack/react-query';
interface CreateViewFilterInput {
viewId: string;
fieldId: string;
operator: string;
}
export const useViewFilterCreateMutation = () => {
const workspace = useWorkspace();
return useMutation({
mutationFn: async (input: CreateViewFilterInput) => {
const lastChildQuery = workspace.schema
.selectFrom('nodes')
.where((eb) =>
eb.and({
parent_id: input.viewId,
type: NodeTypes.ViewFilter,
}),
)
.selectAll()
.orderBy('index', 'desc')
.limit(1)
.compile();
const result = await workspace.query(lastChildQuery);
const lastChild =
result.rows && result.rows.length > 0 ? result.rows[0] : null;
const maxIndex = lastChild?.index ? lastChild.index : null;
const viewFilterId = NeuronId.generate(NeuronId.Type.ViewFilter);
const insertViewFilterQuery = workspace.schema
.insertInto('nodes')
.values({
id: viewFilterId,
type: NodeTypes.ViewFilter,
parent_id: input.viewId,
index: generateNodeIndex(maxIndex, null),
content: null,
created_at: new Date().toISOString(),
created_by: workspace.userId,
version_id: NeuronId.generate(NeuronId.Type.Version),
})
.compile();
const insertViewFilterAttributesQuery = workspace.schema
.insertInto('node_attributes')
.values([
{
node_id: viewFilterId,
type: AttributeTypes.FieldId,
key: '1',
foreign_node_id: input.fieldId,
created_at: new Date().toISOString(),
created_by: workspace.userId,
version_id: NeuronId.generate(NeuronId.Type.Version),
},
{
node_id: viewFilterId,
type: AttributeTypes.Operator,
key: '1',
text_value: input.operator,
created_at: new Date().toISOString(),
created_by: workspace.userId,
version_id: NeuronId.generate(NeuronId.Type.Version),
},
])
.compile();
await workspace.mutate([
insertViewFilterQuery,
insertViewFilterAttributesQuery,
]);
return viewFilterId;
},
});
};

View File

@@ -0,0 +1,43 @@
import { useWorkspace } from '@/contexts/workspace';
import { AttributeTypes } from '@/lib/constants';
import { NeuronId } from '@/lib/id';
import { useMutation } from '@tanstack/react-query';
interface ViewFilterOperatorUpdateMutationInput {
filterId: string;
operator: string;
}
export const useViewFilterOperatorUpdateMutation = () => {
const workspace = useWorkspace();
return useMutation({
mutationFn: async ({
filterId,
operator,
}: ViewFilterOperatorUpdateMutationInput) => {
const query = workspace.schema
.insertInto('node_attributes')
.values({
node_id: filterId,
type: AttributeTypes.Operator,
key: '1',
text_value: operator,
created_at: new Date().toISOString(),
created_by: workspace.userId,
version_id: NeuronId.generate(NeuronId.Type.Version),
})
.onConflict((b) =>
b.doUpdateSet({
text_value: operator,
updated_at: new Date().toISOString(),
updated_by: workspace.userId,
version_id: NeuronId.generate(NeuronId.Type.Version),
}),
)
.compile();
await workspace.mutate(query);
},
});
};

View File

@@ -10,6 +10,8 @@ import {
FieldNode,
SelectOptionNode,
TableViewNode,
ViewFilterNode,
ViewFilterValueNode,
ViewNode,
} from '@/types/databases';
import { LocalNodeWithAttributes } from '@/types/nodes';
@@ -52,6 +54,16 @@ export const useDatabaseQuery = (databaseId: string) => {
)
AND type = ${NodeTypes.SelectOption}
),
view_filter_nodes AS (
SELECT *
FROM nodes
WHERE parent_id IN
(
SELECT id
FROM view_nodes
)
AND type = ${NodeTypes.ViewFilter}
),
all_nodes AS (
SELECT * FROM database_node
UNION ALL
@@ -60,6 +72,8 @@ export const useDatabaseQuery = (databaseId: string) => {
SELECT * FROM view_nodes
UNION ALL
SELECT * FROM select_option_nodes
UNION ALL
SELECT * FROM view_filter_nodes
)
SELECT
n.*,
@@ -145,7 +159,11 @@ const buildDatabaseNode = (
const viewNodes = nodes.filter((node) => ViewNodeTypes.includes(node.type));
const views: ViewNode[] = [];
for (const viewNode of viewNodes) {
const view = buildViewNode(viewNode);
const viewFilters = nodes.filter(
(node) =>
node.type === NodeTypes.ViewFilter && node.parentId === viewNode.id,
);
const view = buildViewNode(viewNode, viewFilters);
if (view) {
views.push(view);
}
@@ -290,19 +308,25 @@ const buildSelectOption = (node: LocalNodeWithAttributes): SelectOptionNode => {
};
};
const buildViewNode = (node: LocalNodeWithAttributes): ViewNode | null => {
const buildViewNode = (
node: LocalNodeWithAttributes,
filters: LocalNodeWithAttributes[],
): ViewNode | null => {
if (node.type === NodeTypes.TableView) {
return buildTableViewNode(node);
return buildTableViewNode(node, filters);
} else if (node.type === NodeTypes.BoardView) {
return buildBoardViewNode(node);
return buildBoardViewNode(node, filters);
} else if (node.type === NodeTypes.CalendarView) {
return buildCalendarViewNode(node);
return buildCalendarViewNode(node, filters);
}
return null;
};
const buildTableViewNode = (node: LocalNodeWithAttributes): TableViewNode => {
const buildTableViewNode = (
node: LocalNodeWithAttributes,
filters: LocalNodeWithAttributes[],
): TableViewNode => {
const name = node.attributes.find(
(attribute) => attribute.type === AttributeTypes.Name,
)?.textValue;
@@ -339,6 +363,8 @@ const buildTableViewNode = (node: LocalNodeWithAttributes): TableViewNode => {
(attribute) => attribute.type === AttributeTypes.NameWidth,
)?.numberValue;
const viewFilters = filters.map(buildViewFilterNode);
return {
id: node.id,
name: name ?? 'Unnamed',
@@ -348,34 +374,67 @@ const buildTableViewNode = (node: LocalNodeWithAttributes): TableViewNode => {
fieldWidths,
nameWidth: nameWidth,
versionId: node.versionId,
filters: [],
filters: viewFilters,
};
};
const buildBoardViewNode = (node: LocalNodeWithAttributes): BoardViewNode => {
const buildBoardViewNode = (
node: LocalNodeWithAttributes,
filters: LocalNodeWithAttributes[],
): BoardViewNode => {
const name = node.attributes.find(
(attribute) => attribute.type === AttributeTypes.Name,
)?.textValue;
const viewFilters = filters.map(buildViewFilterNode);
return {
id: node.id,
name: name ?? 'Unnamed',
type: 'board_view',
filters: [],
filters: viewFilters,
};
};
const buildCalendarViewNode = (
node: LocalNodeWithAttributes,
filters: LocalNodeWithAttributes[],
): CalendarViewNode => {
const name = node.attributes.find(
(attribute) => attribute.type === AttributeTypes.Name,
)?.textValue;
const viewFilters = filters.map(buildViewFilterNode);
return {
id: node.id,
name: name ?? 'Unnamed',
type: 'calendar_view',
filters: [],
filters: viewFilters,
};
};
const buildViewFilterNode = (node: LocalNodeWithAttributes): ViewFilterNode => {
const fieldId = node.attributes.find(
(attribute) => attribute.type === AttributeTypes.FieldId,
)?.foreignNodeId;
const operator = node.attributes.find(
(attribute) => attribute.type === AttributeTypes.Operator,
)?.textValue;
const values: ViewFilterValueNode[] = node.attributes
.filter((attribute) => attribute.type === AttributeTypes.Value)
.map((attribute) => ({
textValue: attribute.textValue,
numberValue: attribute.numberValue,
foreignNodeId: attribute.foreignNodeId,
}));
return {
id: node.id,
fieldId,
operator,
values,
};
};

View File

@@ -1,17 +1,38 @@
import { useDatabase } from '@/contexts/database';
import { useWorkspace } from '@/contexts/workspace';
import { SelectNode, SelectNodeWithAttributes } from '@/data/schemas/workspace';
import { AttributeTypes, NodeTypes } from '@/lib/constants';
import { mapNodeWithAttributes } from '@/lib/nodes';
import { compareString } from '@/lib/utils';
import { RecordNode } from '@/types/databases';
import {
BooleanFieldNode,
EmailFieldNode,
FieldNode,
MultiSelectFieldNode,
NumberFieldNode,
PhoneFieldNode,
RecordNode,
SelectFieldNode,
TextFieldNode,
UrlFieldNode,
ViewFilterNode,
} from '@/types/databases';
import { User } from '@/types/users';
import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query';
import { sha256 } from 'js-sha256';
import { QueryResult, sql } from 'kysely';
const RECORDS_PER_PAGE = 50;
export const useRecordsQuery = (databaseId: string) => {
export const useRecordsQuery = (
databaseId: string,
filters: ViewFilterNode[],
) => {
const workspace = useWorkspace();
const database = useDatabase();
const json = filters.length > 0 ? JSON.stringify(filters) : '';
const hash = filters.length > 0 ? sha256(json) : '';
return useInfiniteQuery<
QueryResult<SelectNodeWithAttributes>,
@@ -20,7 +41,7 @@ export const useRecordsQuery = (databaseId: string) => {
string[],
number
>({
queryKey: ['records', databaseId],
queryKey: ['records', databaseId, hash],
initialPageParam: 0,
getNextPageParam: (lastPage: QueryResult<SelectNode>, pages) => {
if (lastPage && lastPage.rows) {
@@ -40,7 +61,7 @@ export const useRecordsQuery = (databaseId: string) => {
WITH record_nodes AS (
SELECT *
FROM nodes
WHERE parent_id = ${databaseId} AND type = ${NodeTypes.Record}
WHERE parent_id = ${databaseId} AND type = ${NodeTypes.Record} ${sql.raw(buildFiltersQuery(filters, database.fields))}
ORDER BY ${sql.ref('index')} ASC
LIMIT ${sql.lit(RECORDS_PER_PAGE)}
OFFSET ${sql.lit(offset)}
@@ -50,22 +71,10 @@ export const useRecordsQuery = (databaseId: string) => {
FROM nodes
WHERE id IN (SELECT DISTINCT created_by FROM record_nodes)
),
referenced_nodes AS (
SELECT *
FROM nodes
WHERE id IN
(
SELECT DISTINCT foreign_node_id
FROM node_attributes
WHERE node_id IN (SELECT id FROM record_nodes)
)
),
all_nodes as (
SELECT * FROM record_nodes
UNION ALL
SELECT * FROM author_nodes
UNION ALL
SELECT * FROM referenced_nodes
)
SELECT
n.*,
@@ -158,3 +167,491 @@ const buildRecords = (rows: SelectNodeWithAttributes[]): RecordNode[] => {
return records.sort((a, b) => compareString(a.index, b.index));
};
const buildFiltersQuery = (
filters: ViewFilterNode[],
fields: FieldNode[],
): string => {
if (filters.length === 0) {
return '';
}
const filterQueries = filters
.map((filter) => buildFilterQuery(filter, fields))
.filter((query) => query !== null);
if (filterQueries.length === 0) {
return '';
}
const joinQueries = filterQueries
.map((query) => query.joinQuery)
.filter((query) => query !== null && query.length > 0);
const whereQueries = filterQueries
.map((query) => query.whereQuery)
.filter((query) => query !== null && query.length > 0);
return `AND id IN
(
SELECT na.node_id
FROM node_attributes na
${joinQueries.join(' ')}
WHERE ${whereQueries.join(' AND ')}
)
`;
};
interface FilterQuery {
joinQuery: string;
whereQuery: string | null;
}
const buildFilterQuery = (
filter: ViewFilterNode,
fields: FieldNode[],
): FilterQuery | null => {
const field = fields.find((field) => field.id === filter.fieldId);
if (!field) {
return null;
}
switch (field.dataType) {
case 'boolean':
return buildBooleanFilterQuery(filter, field);
case 'collaborator':
return null;
case 'created_at':
return null;
case 'created_by':
return null;
case 'date':
return null;
case 'email':
return buildEmailFilterQuery(filter, field);
case 'file':
return null;
case 'multi_select':
return buildMultiSelectFilterQuery(filter, field);
case 'number':
return buildNumberFilterQuery(filter, field);
case 'phone':
return buildPhoneFilterQuery(filter, field);
case 'select':
return buildSelectFilterQuery(filter, field);
case 'text':
return buildTextFilterQuery(filter, field);
case 'url':
return buildUrlFilterQuery(filter, field);
default:
return null;
}
};
const buildBooleanFilterQuery = (
filter: ViewFilterNode,
field: BooleanFieldNode,
): FilterQuery | null => {
if (filter.operator === 'is_true') {
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value = 1`,
whereQuery: null,
};
}
if (filter.operator === 'is_false') {
return {
joinQuery: buildLeftJoinQuery(filter.id, field.id),
whereQuery: `na_${filter.id}.node_id IS NULL OR na_${filter.id}.number_value = 0`,
};
}
return null;
};
const buildNumberFilterQuery = (
filter: ViewFilterNode,
field: NumberFieldNode,
): FilterQuery | null => {
if (filter.operator === 'is_empty') {
return buildIsEmptyFilterQuery(filter.id, field.id);
}
if (filter.operator === 'is_not_empty') {
return buildIsNotEmptyFilterQuery(filter.id, field.id);
}
if (filter.values.length === 0) {
return null;
}
const value = filter.values[0].numberValue;
if (value === null) {
return null;
}
switch (filter.operator) {
case 'is_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value = ${value}`,
whereQuery: null,
};
case 'is_not_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value != ${value}`,
whereQuery: null,
};
case 'is_greater_than':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value > ${value}`,
whereQuery: null,
};
case 'is_less_than':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value < ${value}`,
whereQuery: null,
};
case 'is_greater_than_or_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value >= ${value}`,
whereQuery: null,
};
case 'is_less_than_or_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value <= ${value}`,
whereQuery: null,
};
default:
return null;
}
};
const buildTextFilterQuery = (
filter: ViewFilterNode,
field: TextFieldNode,
): FilterQuery | null => {
if (filter.operator === 'is_empty') {
return buildIsEmptyFilterQuery(filter.id, field.id);
}
if (filter.operator === 'is_not_empty') {
return buildIsNotEmptyFilterQuery(filter.id, field.id);
}
if (filter.values.length === 0) {
return null;
}
const value = filter.values[0].textValue;
if (value === null) {
return null;
}
switch (filter.operator) {
case 'is_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value = '${value}'`,
whereQuery: null,
};
case 'is_not_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value != '${value}'`,
whereQuery: null,
};
case 'contains':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}%'`,
whereQuery: null,
};
case 'does_not_contain':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value NOT LIKE '%${value}%'`,
whereQuery: null,
};
case 'starts_with':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '${value}%'`,
whereQuery: null,
};
case 'ends_with':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}'`,
whereQuery: null,
};
default:
return null;
}
};
const buildEmailFilterQuery = (
filter: ViewFilterNode,
field: EmailFieldNode,
): FilterQuery | null => {
if (filter.operator === 'is_empty') {
return buildIsEmptyFilterQuery(filter.id, field.id);
}
if (filter.operator === 'is_not_empty') {
return buildIsNotEmptyFilterQuery(filter.id, field.id);
}
if (filter.values.length === 0) {
return null;
}
const value = filter.values[0].textValue;
if (value === null) {
return null;
}
switch (filter.operator) {
case 'is_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value = '${value}'`,
whereQuery: null,
};
case 'is_not_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value != '${value}'`,
whereQuery: null,
};
case 'contains':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}%'`,
whereQuery: null,
};
case 'does_not_contain':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value NOT LIKE '%${value}%'`,
whereQuery: null,
};
case 'starts_with':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '${value}%'`,
whereQuery: null,
};
case 'ends_with':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}'`,
whereQuery: null,
};
default:
return null;
}
};
const buildPhoneFilterQuery = (
filter: ViewFilterNode,
field: PhoneFieldNode,
): FilterQuery | null => {
if (filter.operator === 'is_empty') {
return buildIsEmptyFilterQuery(filter.id, field.id);
}
if (filter.operator === 'is_not_empty') {
return buildIsNotEmptyFilterQuery(filter.id, field.id);
}
if (filter.values.length === 0) {
return null;
}
const value = filter.values[0].textValue;
if (value === null) {
return null;
}
switch (filter.operator) {
case 'is_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value = '${value}'`,
whereQuery: null,
};
case 'is_not_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value != '${value}'`,
whereQuery: null,
};
case 'contains':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}%'`,
whereQuery: null,
};
case 'does_not_contain':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value NOT LIKE '%${value}%'`,
whereQuery: null,
};
case 'starts_with':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '${value}%'`,
whereQuery: null,
};
case 'ends_with':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}'`,
whereQuery: null,
};
default:
return null;
}
};
const buildUrlFilterQuery = (
filter: ViewFilterNode,
field: UrlFieldNode,
): FilterQuery | null => {
if (filter.operator === 'is_empty') {
return buildIsEmptyFilterQuery(filter.id, field.id);
}
if (filter.operator === 'is_not_empty') {
return buildIsNotEmptyFilterQuery(filter.id, field.id);
}
if (filter.values.length === 0) {
return null;
}
const value = filter.values[0].textValue;
if (value === null) {
return null;
}
switch (filter.operator) {
case 'is_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value = '${value}'`,
whereQuery: null,
};
case 'is_not_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value != '${value}'`,
whereQuery: null,
};
case 'contains':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}%'`,
whereQuery: null,
};
case 'does_not_contain':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value NOT LIKE '%${value}%'`,
whereQuery: null,
};
case 'starts_with':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '${value}%'`,
whereQuery: null,
};
case 'ends_with':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}'`,
whereQuery: null,
};
default:
return null;
}
};
const buildSelectFilterQuery = (
filter: ViewFilterNode,
field: SelectFieldNode,
): FilterQuery | null => {
if (filter.operator === 'is_empty') {
return buildIsEmptyFilterQuery(filter.id, field.id);
}
if (filter.operator === 'is_not_empty') {
return buildIsNotEmptyFilterQuery(filter.id, field.id);
}
if (filter.values.length === 0) {
return null;
}
const ids = filter.values.map((value) => value.foreignNodeId);
if (ids.length === 0) {
return null;
}
switch (filter.operator) {
case 'is_in':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id IN (${ids.join(',')})`,
whereQuery: null,
};
case 'is_not_in':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id NOT IN (${ids.join(',')})`,
whereQuery: null,
};
default:
return null;
}
};
const buildMultiSelectFilterQuery = (
filter: ViewFilterNode,
field: MultiSelectFieldNode,
): FilterQuery | null => {
if (filter.operator === 'is_empty') {
return buildIsEmptyFilterQuery(filter.id, field.id);
}
if (filter.operator === 'is_not_empty') {
return buildIsNotEmptyFilterQuery(filter.id, field.id);
}
if (filter.values.length === 0) {
return null;
}
const ids = filter.values.map((value) => value.foreignNodeId);
if (ids.length === 0) {
return null;
}
switch (filter.operator) {
case 'is_in':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id IN (${ids.join(',')})`,
whereQuery: null,
};
case 'is_not_in':
return {
joinQuery: `${buildLeftJoinQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id IN (${ids.join(',')})`,
whereQuery: `na_${filter.id}.node_id IS NULL`,
};
default:
return null;
}
};
const buildIsEmptyFilterQuery = (
filterId: string,
fieldId: string,
): FilterQuery => {
return {
joinQuery: buildLeftJoinQuery(filterId, fieldId),
whereQuery: `na_${filterId}.node_id IS NULL`,
};
};
const buildIsNotEmptyFilterQuery = (
filterId: string,
fieldId: string,
): FilterQuery => {
return {
joinQuery: buildJoinQuery(filterId, fieldId),
whereQuery: null,
};
};
const buildJoinQuery = (filterId: string, fieldId: string): string => {
return `JOIN node_attributes na_${filterId} ON na_${filterId}.node_id = na.node_id AND na_${filterId}.type = '${fieldId}'`;
};
const buildLeftJoinQuery = (filterId: string, fieldId: string): string => {
return `LEFT JOIN node_attributes na_${filterId} ON na_${filterId}.node_id = na.node_id AND na_${filterId}.type = '${fieldId}'`;
};

View File

@@ -180,5 +180,11 @@ export type ViewFilterNode = {
id: string;
fieldId: string;
operator: string;
value: number | string | string[] | null;
values: ViewFilterValueNode[];
};
export type ViewFilterValueNode = {
textValue: string | null;
numberValue: number | null;
foreignNodeId: string | null;
};