mirror of
https://github.com/colanode/colanode.git
synced 2025-12-28 16:06:37 +01:00
Implement initial version of database filters
This commit is contained in:
7
desktop/package-lock.json
generated
7
desktop/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
97
desktop/src/components/databases/filters/view-filters.tsx
Normal file
97
desktop/src/components/databases/filters/view-filters.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
18
desktop/src/components/databases/view-action-button.tsx
Normal file
18
desktop/src/components/databases/view-action-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
103
desktop/src/components/ui/smart-number-input.tsx
Normal file
103
desktop/src/components/ui/smart-number-input.tsx
Normal 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 };
|
||||
80
desktop/src/components/ui/smart-text-input.tsx
Normal file
80
desktop/src/components/ui/smart-text-input.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
29
desktop/src/mutations/use-node-attribute-delete-mutation.tsx
Normal file
29
desktop/src/mutations/use-node-attribute-delete-mutation.tsx
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
47
desktop/src/mutations/use-node-attribute-upsert-mutation.tsx
Normal file
47
desktop/src/mutations/use-node-attribute-upsert-mutation.tsx
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
81
desktop/src/mutations/use-view-filter-create-mutation.tsx
Normal file
81
desktop/src/mutations/use-view-filter-create-mutation.tsx
Normal 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;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}'`;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user