Implement calendar view

This commit is contained in:
Hakan Shehu
2024-09-13 22:59:24 +02:00
parent d26f566aa5
commit ffe013f414
11 changed files with 956 additions and 43 deletions

View File

@@ -69,6 +69,7 @@
"lowlight": "^3.1.0",
"re-resizable": "^6.9.18",
"react": "^18.3.1",
"react-day-picker": "^9.0.9",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
@@ -5583,6 +5584,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
@@ -11261,6 +11272,22 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.0.9.tgz",
"integrity": "sha512-Sj+1UuuUmdxSKPmJlQFb6h1wKw9N7gG0K4db9mQtHm/qzd94t6LT9X8cGwDHaW9Pdb9dua4/sLhUSW06A1Xqkg==",
"license": "MIT",
"dependencies": {
"date-fns": "^3.6.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-dnd": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",

View File

@@ -109,6 +109,7 @@
"lowlight": "^3.1.0",
"re-resizable": "^6.9.18",
"react": "^18.3.1",
"react-day-picker": "^9.0.9",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { useWorkspace } from '@/contexts/workspace';
import { RecordNode } from '@/types/databases';
import { cn } from '@/lib/utils';
interface CalendarViewCardProps {
record: RecordNode;
}
export const CalendarViewCard = ({ record }: CalendarViewCardProps) => {
const workspace = useWorkspace();
const name = record.name;
const hasName = name !== null && name !== '';
return (
<button
role="presentation"
key={record.id}
className={cn(
'animate-fade-in flex cursor-pointer flex-col gap-1 rounded-md border p-2 hover:bg-gray-50',
hasName ? '' : 'text-muted-foreground',
)}
onClick={() => workspace.openModal(record.id)}
>
{record.name ?? 'Unnamed'}
</button>
);
};

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { cn, isSameDay } from '@/lib/utils';
import { RecordNode } from '@/types/databases';
import { Icon } from '@/components/ui/icon';
import { CalendarViewCard } from '@/components/databases/calendars/calendar-view-card';
interface CalendarViewDayProps {
date: Date;
month: Date;
outside: boolean;
records: RecordNode[];
}
export const CalendarViewDay = ({
date,
month,
outside,
records,
}: CalendarViewDayProps) => {
const isToday = isSameDay(date, new Date());
const canCreateRecord = false;
return (
<td
className={cn(
'relative h-40 flex-1 p-2 text-right text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent',
'[&:has([aria-selected])]:rounded-md',
'overflow-auto border-r border-gray-100 first:border-l',
'group flex flex-col gap-1',
)}
>
<div
className={cn(
'flex justify-between text-sm',
outside ? 'text-muted-foreground' : '',
)}
>
<Icon
name="add-line"
className={cn(
'cursor-pointer opacity-0',
canCreateRecord ? 'group-hover:opacity-100' : '',
)}
onClick={() => {}}
/>
<p
className={
isToday ? 'rounder-md rounded bg-red-500 p-0.5 text-white' : ''
}
>
{date.getDate()}
</p>
</div>
{records.map((record) => {
return <CalendarViewCard key={record.id} record={record} />;
})}
</td>
);
};

View File

@@ -0,0 +1,136 @@
import React from 'react';
import { buttonVariants } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { cn, getDisplayedDates } from '@/lib/utils';
import { DayPicker, DayProps } from 'react-day-picker';
import { CalendarViewDay } from '@/components/databases/calendars/calendar-view-day';
import { CalendarViewNode, FieldNode, ViewFilterNode } from '@/types/databases';
import { useRecordsQuery } from '@/queries/use-records-query';
import { useDatabase } from '@/contexts/database';
import { filterRecords } from '@/lib/databases';
import { useWorkspace } from '@/contexts/workspace';
interface CalendarViewGridProps {
view: CalendarViewNode;
field: FieldNode;
}
export const CalendarViewGrid = ({ view, field }: CalendarViewGridProps) => {
const workspace = useWorkspace();
const database = useDatabase();
const [month, setMonth] = React.useState(new Date());
const { first, last } = getDisplayedDates(month);
const filters = [
...view.filters,
{
id: 'start_date',
fieldId: field.id,
operator: 'is_on_or_after',
values: [
{
textValue: first.toISOString(),
numberValue: null,
foreignNodeId: null,
},
],
},
{
id: 'end_date',
fieldId: field.id,
operator: 'is_on_or_before',
values: [
{
textValue: last.toISOString(),
numberValue: null,
foreignNodeId: null,
},
],
},
];
const { data, isPending, isFetchingNextPage, fetchNextPage, hasNextPage } =
useRecordsQuery(database.id, filters);
if (isPending) {
return null;
}
const records = data ?? [];
return (
<DayPicker
showOutsideDays
className="p-3"
month={month}
onMonthChange={(month) => {
setMonth(month);
}}
classNames={{
months:
'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0 w-full',
month: 'space-y-4 w-full',
month_caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
button_previous: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
'absolute left-1',
),
button_next: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
'absolute right-1',
),
month_grid: 'w-full border-collapse space-y-1',
weekdays: 'flex flex-row mb-2',
weekday:
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem]',
week: 'flex flex-row w-full border-b first:border-t',
}}
components={{
Chevron: (props) => {
if (props.orientation === 'left') {
return (
<Icon name="arrow-left-s-line" className="h-4 w-4" {...props} />
);
}
return (
<Icon name="arrow-right-s-line" className="h-4 w-4" {...props} />
);
},
Day: (props: DayProps) => {
const filter: ViewFilterNode = {
id: 'calendar_filter',
fieldId: field.id,
operator: 'is_equal_to',
values: [
{
textValue: props.day.date.toISOString(),
numberValue: null,
foreignNodeId: null,
},
],
};
const dayRecords = filterRecords(
records,
filter,
field,
workspace.userId,
);
return (
<CalendarViewDay
date={props.day.date}
month={props.day.displayMonth}
outside={props.day.outside}
records={dayRecords}
/>
);
},
}}
/>
);
};

View File

@@ -1,19 +1,47 @@
import React from 'react';
import { CalendarViewNode } from '@/types/databases';
import { ViewTabs } from '@/components/databases/view-tabs';
import { useDatabase } from '@/contexts/database';
import { ViewActionButton } from '@/components/databases/view-action-button';
import { ViewFilters } from '@/components/databases/filters/view-filters';
import { CalendarViewGrid } from './calendar-view-grid';
interface CalendarViewProps {
node: CalendarViewNode;
}
export const CalendarView = ({ node }: CalendarViewProps) => {
const database = useDatabase();
const [openFilters, setOpenFilters] = React.useState(true);
const [openSort, setOpenSort] = React.useState(false);
const groupByField = database.fields.find(
(field) => field.id === node.groupBy,
);
if (!groupByField) {
return null;
}
return (
<React.Fragment>
<div className="mt-2 flex flex-row justify-between border-b">
<ViewTabs />
<div className="invisible flex flex-row items-center justify-end group-hover/database:visible">
<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">
calendar view {node.id}
<CalendarViewGrid view={node} field={groupByField} />
</div>
</React.Fragment>
);

View File

@@ -1,4 +1,9 @@
import { FieldDataType } from '@/types/databases';
import {
FieldDataType,
FieldNode,
RecordNode,
ViewFilterNode,
} from '@/types/databases';
export const getFieldIcon = (type?: FieldDataType): string => {
if (!type) return '';
@@ -214,6 +219,22 @@ export const createdAtFieldFilterOperators: FieldFilterOperator[] = [
label: 'Is Not Equal To',
value: 'is_not_equal_to',
},
{
label: 'Is on or after',
value: 'is_on_or_after',
},
{
label: 'Is on or before',
value: 'is_on_or_before',
},
{
label: 'Is After',
value: 'is_after',
},
{
label: 'Is Before',
value: 'is_before',
},
];
export const createdByFieldFilterOperators: FieldFilterOperator[] = [
@@ -491,3 +512,493 @@ export const getFieldFilterOperators = (
return [];
}
};
export const filterRecords = (
records: RecordNode[],
filter: ViewFilterNode,
field: FieldNode,
currentUserId: string,
): RecordNode[] => {
return records.filter((record) =>
recordMatchesFilter(record, filter, field, currentUserId),
);
};
const recordMatchesFilter = (
record: RecordNode,
filter: ViewFilterNode,
field: FieldNode,
currentUserId: string,
) => {
switch (field.dataType) {
case 'boolean':
return recordMatchesBooleanFilter(record, filter, field);
case 'collaborator':
return recordMatchesCollaboratorFilter(record, filter, field);
case 'created_at':
return recordMatchesCreatedAtFilter(record, filter);
case 'created_by':
return recordMatchesCreatedByFilter(record, filter, currentUserId);
case 'date':
return recordMatchesDateFilter(record, filter, field);
case 'email':
return recordMatchesEmailFilter(record, filter, field);
case 'file':
return recordMatchesFileFilter(record, filter, field);
case 'multi_select':
return recordMatchesMultiSelectFilter(record, filter, field);
case 'number':
return recordMatchesNumberFilter(record, filter, field);
case 'phone':
return recordMatchesPhoneFilter(record, filter, field);
case 'select':
return recordMatchesSelectFilter(record, filter, field);
case 'text':
return recordMatchesTextFilter(record, filter, field);
case 'url':
return recordMatchesUrlFilter(record, filter, field);
default:
return false;
}
};
const recordMatchesBooleanFilter = (
record: RecordNode,
filter: ViewFilterNode,
field: FieldNode,
) => {
const fieldValue = record.attributes.find(
(attribute) => attribute.type === field.id,
)?.numberValue;
if (filter.operator === 'is_true') {
return fieldValue === 1;
}
if (filter.operator === 'is_false') {
return !fieldValue || fieldValue === 0;
}
return false;
};
const recordMatchesCollaboratorFilter = (
record: RecordNode,
filter: ViewFilterNode,
field: FieldNode,
) => {
return false;
};
const recordMatchesCreatedAtFilter = (
record: RecordNode,
filter: ViewFilterNode,
) => {
if (filter.values.length === 0) return false;
const filterValue = filter.values[0].textValue;
const filterDate = new Date(filterValue);
filterDate.setHours(0, 0, 0, 0); // Set time to midnight
const recordDate = new Date(record.createdAt);
recordDate.setHours(0, 0, 0, 0); // Set time to midnight
switch (filter.operator) {
case 'is_equal_to':
return recordDate.getTime() === filterDate.getTime();
case 'is_not_equal_to':
return recordDate.getTime() !== filterDate.getTime();
case 'is_on_or_after':
return recordDate.getTime() >= filterDate.getTime();
case 'is_on_or_before':
return recordDate.getTime() <= filterDate.getTime();
case 'is_after':
return recordDate.getTime() > filterDate.getTime();
case 'is_before':
return recordDate.getTime() < filterDate.getTime();
}
return false;
};
const recordMatchesCreatedByFilter = (
record: RecordNode,
filter: ViewFilterNode,
currentUserId: string,
) => {
const createdBy = record.createdBy?.id;
if (!createdBy) {
return false;
}
const filterValues = filter.values.map((value) => value.foreignNodeId);
if (filterValues.length === 0) {
return true;
}
switch (filter.operator) {
case 'is_me':
return createdBy === currentUserId;
case 'is_not_me':
return createdBy !== currentUserId;
case 'is_in':
return filterValues.includes(createdBy);
case 'is_not_in':
return !filterValues.includes(createdBy);
default:
return false;
}
};
const recordMatchesDateFilter = (
record: RecordNode,
filter: ViewFilterNode,
field: FieldNode,
) => {
const fieldValue = record.attributes.find(
(attribute) => attribute.type === field.id,
)?.textValue;
if (!fieldValue) {
return false;
}
const recordDate = new Date(fieldValue);
recordDate.setHours(0, 0, 0, 0); // Set time to midnight
if (filter.values.length === 0) {
return true;
}
const filterValue = filter.values[0].textValue;
if (!filterValue) {
return true;
}
const filterDate = new Date(filterValue);
filterDate.setHours(0, 0, 0, 0); // Set time to midnight
switch (filter.operator) {
case 'is_equal_to':
return recordDate.getTime() === filterDate.getTime();
case 'is_not_equal_to':
return recordDate.getTime() !== filterDate.getTime();
case 'is_on_or_after':
return recordDate.getTime() >= filterDate.getTime();
case 'is_on_or_before':
return recordDate.getTime() <= filterDate.getTime();
case 'is_after':
return recordDate.getTime() > filterDate.getTime();
case 'is_before':
return recordDate.getTime() < filterDate.getTime();
}
return false;
};
const recordMatchesEmailFilter = (
record: RecordNode,
filter: ViewFilterNode,
field: FieldNode,
) => {
const fieldValue = record.attributes.find(
(attribute) => attribute.type === field.id,
)?.textValue;
if (filter.operator === 'is_empty') {
return !fieldValue;
}
if (filter.operator === 'is_not_empty') {
return !!fieldValue;
}
if (!fieldValue) {
return false;
}
if (filter.values.length === 0) {
return true;
}
const filterValue = filter.values[0].textValue;
if (!filterValue) {
return true;
}
switch (filter.operator) {
case 'is_equal_to':
return fieldValue === filterValue;
case 'is_not_equal_to':
return fieldValue !== filterValue;
case 'contains':
return fieldValue.includes(filterValue);
case 'does_not_contain':
return !fieldValue.includes(filterValue);
}
return false;
};
const recordMatchesFileFilter = (
record: RecordNode,
filter: ViewFilterNode,
field: FieldNode,
) => {
return false;
};
const recordMatchesMultiSelectFilter = (
record: RecordNode,
filter: ViewFilterNode,
field: FieldNode,
) => {
const fieldValues = record.attributes
.filter((attribute) => attribute.type === field.id)
?.map((attribute) => attribute.foreignNodeId);
if (filter.operator === 'is_empty') {
return fieldValues?.length === 0;
}
if (filter.operator === 'is_not_empty') {
return fieldValues?.length > 0;
}
if (!fieldValues) {
return false;
}
const selectOptionIds = filter.values.map((value) => value.foreignNodeId);
if (selectOptionIds.length === 0) {
return true;
}
switch (filter.operator) {
case 'is_in':
return fieldValues.some((value) => selectOptionIds.includes(value));
case 'is_not_in':
return !fieldValues.some((value) => selectOptionIds.includes(value));
}
return false;
};
const recordMatchesNumberFilter = (
record: RecordNode,
filter: ViewFilterNode,
field: FieldNode,
) => {
const fieldValue = record.attributes.find(
(attribute) => attribute.type === field.id,
)?.numberValue;
if (filter.operator === 'is_empty') {
return !fieldValue;
}
if (filter.operator === 'is_not_empty') {
return !!fieldValue;
}
if (!fieldValue) {
return false;
}
if (filter.values.length === 0) {
return true;
}
const filterValue = filter.values[0].numberValue;
if (!filterValue) {
return true;
}
switch (filter.operator) {
case 'is_equal_to':
return fieldValue === filterValue;
case 'is_not_equal_to':
return fieldValue !== filterValue;
case 'is_greater_than':
return fieldValue > filterValue;
case 'is_less_than':
return fieldValue < filterValue;
case 'is_greater_than_or_equal_to':
return fieldValue >= filterValue;
case 'is_less_than_or_equal_to':
return fieldValue <= filterValue;
}
return false;
};
const recordMatchesPhoneFilter = (
record: RecordNode,
filter: ViewFilterNode,
field: FieldNode,
) => {
const fieldValue = record.attributes.find(
(attribute) => attribute.type === field.id,
)?.textValue;
if (filter.operator === 'is_empty') {
return !fieldValue;
}
if (filter.operator === 'is_not_empty') {
return !!fieldValue;
}
if (!fieldValue) {
return false;
}
if (filter.values.length === 0) {
return true;
}
const filterValue = filter.values[0].textValue;
if (!filterValue) {
return true;
}
switch (filter.operator) {
case 'is_equal_to':
return fieldValue === filterValue;
case 'is_not_equal_to':
return fieldValue !== filterValue;
case 'contains':
return fieldValue.includes(filterValue);
case 'does_not_contain':
return !fieldValue.includes(filterValue);
}
return false;
};
const recordMatchesSelectFilter = (
record: RecordNode,
filter: ViewFilterNode,
field: FieldNode,
) => {
const fieldValue = record.attributes.find(
(attribute) => attribute.type === field.id,
)?.foreignNodeId;
if (filter.operator === 'is_empty') {
return !fieldValue;
}
if (filter.operator === 'is_not_empty') {
return !!fieldValue;
}
if (!fieldValue) {
return false;
}
if (filter.values.length === 0) {
return true;
}
const selectOptionIds = filter.values.map((value) => value.foreignNodeId);
if (selectOptionIds.length === 0) {
return true;
}
switch (filter.operator) {
case 'is_in':
return selectOptionIds.includes(fieldValue);
case 'is_not_in':
return !selectOptionIds.includes(fieldValue);
}
return false;
};
const recordMatchesTextFilter = (
record: RecordNode,
filter: ViewFilterNode,
field: FieldNode,
) => {
const fieldValue = record.attributes.find(
(attribute) => attribute.type === field.id,
)?.textValue;
if (filter.operator === 'is_empty') {
return !fieldValue;
}
if (filter.operator === 'is_not_empty') {
return !!fieldValue;
}
if (!fieldValue) {
return false;
}
if (filter.values.length === 0) {
return true;
}
const filterValue = filter.values[0].textValue;
if (!filterValue) {
return true;
}
switch (filter.operator) {
case 'is_equal_to':
return fieldValue === filterValue;
case 'is_not_equal_to':
return fieldValue !== filterValue;
case 'contains':
return fieldValue.includes(filterValue);
case 'does_not_contain':
return !fieldValue.includes(filterValue);
}
return false;
};
const recordMatchesUrlFilter = (
record: RecordNode,
filter: ViewFilterNode,
field: FieldNode,
) => {
const fieldValue = record.attributes.find(
(attribute) => attribute.type === field.id,
)?.textValue;
if (filter.operator === 'is_empty') {
return !fieldValue;
}
if (filter.operator === 'is_not_empty') {
return !!fieldValue;
}
if (!fieldValue) {
return false;
}
if (filter.values.length === 0) {
return true;
}
const filterValue = filter.values[0].textValue;
if (!filterValue) {
return true;
}
switch (filter.operator) {
case 'is_equal_to':
return fieldValue === filterValue;
case 'is_not_equal_to':
return fieldValue !== filterValue;
case 'contains':
return fieldValue.includes(filterValue);
case 'does_not_contain':
return !fieldValue.includes(filterValue);
}
return false;
};

View File

@@ -134,3 +134,49 @@ export const isValidUrl = (url: string) => {
return false;
}
};
export const getDisplayedDates = (
month: Date,
): {
first: Date;
last: Date;
} => {
const firstDayOfMonth = new Date(month.getFullYear(), month.getMonth(), 1);
const lastDayOfMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0);
// Find the first day of the visible grid (Sunday of the week containing the 1st of the month)
const firstDayOfWeek = firstDayOfMonth.getDay();
const firstDayDisplayed = new Date(
firstDayOfMonth.getFullYear(),
firstDayOfMonth.getMonth(),
firstDayOfMonth.getDate() - firstDayOfWeek,
);
// Find the last day of the visible grid (Saturday of the week containing the last day of the month)
const lastDayOfWeek = lastDayOfMonth.getDay();
const lastDayDisplayed = new Date(
lastDayOfMonth.getFullYear(),
lastDayOfMonth.getMonth(),
lastDayOfMonth.getDate() + (6 - lastDayOfWeek),
);
return { first: firstDayDisplayed, last: lastDayDisplayed };
};
export const isSameDay = (
date1: Date | string | null,
date2: Date | string | null,
) => {
if (date1 == null) {
return false;
}
if (date2 == null) {
return false;
}
const d1 = typeof date1 === 'string' ? new Date(date1) : date1;
const d2 = typeof date2 === 'string' ? new Date(date2) : date2;
return d1.getDate() === d2.getDate() && d1.getMonth() === d2.getMonth();
};

View File

@@ -205,6 +205,10 @@ const buildCalendarViewNode = (
(attribute) => attribute.type === AttributeTypes.Name,
)?.textValue;
const groupBy = node.attributes.find(
(attribute) => attribute.type === AttributeTypes.GroupBy,
)?.foreignNodeId;
const viewFilters = filters.map(buildViewFilterNode);
return {
@@ -212,6 +216,7 @@ const buildCalendarViewNode = (
name: name ?? 'Unnamed',
type: 'calendar_view',
filters: viewFilters,
groupBy,
};
};

View File

@@ -6,6 +6,7 @@ import { mapNodeWithAttributes } from '@/lib/nodes';
import { compareString } from '@/lib/utils';
import {
BooleanFieldNode,
CreatedAtFieldNode,
EmailFieldNode,
FieldNode,
MultiSelectFieldNode,
@@ -222,7 +223,7 @@ const buildFilterQuery = (
case 'collaborator':
return null;
case 'created_at':
return null;
return buildCreatedAtFilterQuery(filter, field);
case 'created_by':
return null;
case 'date':
@@ -254,14 +255,14 @@ const buildBooleanFilterQuery = (
): FilterQuery | null => {
if (filter.operator === 'is_true') {
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value = 1`,
joinQuery: `${buildJoinNodeAttributesQuery(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),
joinQuery: buildLeftJoinNodeAttributesQuery(filter.id, field.id),
whereQuery: `na_${filter.id}.node_id IS NULL OR na_${filter.id}.number_value = 0`,
};
}
@@ -293,32 +294,32 @@ const buildNumberFilterQuery = (
switch (filter.operator) {
case 'is_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.number_value = ${value}`,
joinQuery: `${buildJoinNodeAttributesQuery(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}`,
joinQuery: `${buildJoinNodeAttributesQuery(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}`,
joinQuery: `${buildJoinNodeAttributesQuery(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}`,
joinQuery: `${buildJoinNodeAttributesQuery(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}`,
joinQuery: `${buildJoinNodeAttributesQuery(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}`,
joinQuery: `${buildJoinNodeAttributesQuery(filter.id, field.id)} AND na_${filter.id}.number_value <= ${value}`,
whereQuery: null,
};
default:
@@ -350,32 +351,32 @@ const buildTextFilterQuery = (
switch (filter.operator) {
case 'is_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value = '${value}'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}%'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}%'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}%'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}'`,
joinQuery: `${buildJoinNodeAttributesQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}'`,
whereQuery: null,
};
default:
@@ -407,32 +408,32 @@ const buildEmailFilterQuery = (
switch (filter.operator) {
case 'is_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value = '${value}'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}%'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}%'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}%'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}'`,
joinQuery: `${buildJoinNodeAttributesQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}'`,
whereQuery: null,
};
default:
@@ -464,32 +465,32 @@ const buildPhoneFilterQuery = (
switch (filter.operator) {
case 'is_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value = '${value}'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}%'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}%'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}%'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}'`,
joinQuery: `${buildJoinNodeAttributesQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}'`,
whereQuery: null,
};
default:
@@ -521,32 +522,32 @@ const buildUrlFilterQuery = (
switch (filter.operator) {
case 'is_equal_to':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.text_value = '${value}'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}%'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}%'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}%'`,
joinQuery: `${buildJoinNodeAttributesQuery(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}'`,
joinQuery: `${buildJoinNodeAttributesQuery(filter.id, field.id)} AND na_${filter.id}.text_value LIKE '%${value}'`,
whereQuery: null,
};
default:
@@ -578,12 +579,12 @@ const buildSelectFilterQuery = (
switch (filter.operator) {
case 'is_in':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id IN (${joinIds(ids)})`,
joinQuery: `${buildJoinNodeAttributesQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id IN (${joinIds(ids)})`,
whereQuery: null,
};
case 'is_not_in':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id NOT IN (${joinIds(ids)})`,
joinQuery: `${buildJoinNodeAttributesQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id NOT IN (${joinIds(ids)})`,
whereQuery: null,
};
default:
@@ -615,12 +616,12 @@ const buildMultiSelectFilterQuery = (
switch (filter.operator) {
case 'is_in':
return {
joinQuery: `${buildJoinQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id IN (${joinIds(ids)})`,
joinQuery: `${buildJoinNodeAttributesQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id IN (${joinIds(ids)})`,
whereQuery: null,
};
case 'is_not_in':
return {
joinQuery: `${buildLeftJoinQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id IN (${joinIds(ids)})`,
joinQuery: `${buildLeftJoinNodeAttributesQuery(filter.id, field.id)} AND na_${filter.id}.foreign_node_id IN (${joinIds(ids)})`,
whereQuery: `na_${filter.id}.node_id IS NULL`,
};
default:
@@ -628,12 +629,67 @@ const buildMultiSelectFilterQuery = (
}
};
const buildCreatedAtFilterQuery = (
filter: ViewFilterNode,
field: CreatedAtFieldNode,
): FilterQuery | null => {
if (filter.values.length === 0) {
return null;
}
const value = filter.values[0].textValue;
if (value === null) {
return null;
}
const date = new Date(value);
if (isNaN(date.getTime())) {
return null;
}
const dateString = date.toISOString().split('T')[0];
switch (filter.operator) {
case 'is_equal_to':
return {
joinQuery: `${buildJoinNodesQuery(filter.id)} AND DATE(n_${filter.id}.created_at) = '${dateString}'`,
whereQuery: null,
};
case 'is_not_equal_to':
return {
joinQuery: `${buildLeftJoinNodesQuery(filter.id)} AND DATE(n_${filter.id}.created_at) != '${dateString}'`,
whereQuery: null,
};
case 'is_on_or_after':
return {
joinQuery: `${buildJoinNodesQuery(filter.id)} AND DATE(n_${filter.id}.created_at) >= '${dateString}'`,
whereQuery: null,
};
case 'is_on_or_before':
return {
joinQuery: `${buildJoinNodesQuery(filter.id)} AND DATE(n_${filter.id}.created_at) <= '${dateString}'`,
whereQuery: null,
};
case 'is_after':
return {
joinQuery: `${buildJoinNodesQuery(filter.id)} AND DATE(n_${filter.id}.created_at) > '${dateString}'`,
whereQuery: null,
};
case 'is_before':
return {
joinQuery: `${buildJoinNodesQuery(filter.id)} AND DATE(n_${filter.id}.created_at) < '${dateString}'`,
whereQuery: null,
};
default:
return null;
}
};
const buildIsEmptyFilterQuery = (
filterId: string,
fieldId: string,
): FilterQuery => {
return {
joinQuery: buildLeftJoinQuery(filterId, fieldId),
joinQuery: buildLeftJoinNodeAttributesQuery(filterId, fieldId),
whereQuery: `na_${filterId}.node_id IS NULL`,
};
};
@@ -643,16 +699,30 @@ const buildIsNotEmptyFilterQuery = (
fieldId: string,
): FilterQuery => {
return {
joinQuery: buildJoinQuery(filterId, fieldId),
joinQuery: buildJoinNodeAttributesQuery(filterId, fieldId),
whereQuery: null,
};
};
const buildJoinQuery = (filterId: string, fieldId: string): string => {
const buildJoinNodesQuery = (filterId: string): string => {
return `JOIN nodes n_${filterId} ON n_${filterId}.id = na.node_id`;
};
const buildLeftJoinNodesQuery = (filterId: string): string => {
return `LEFT JOIN nodes n_${filterId} ON n_${filterId}.id = na.node_id`;
};
const buildJoinNodeAttributesQuery = (
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 => {
const buildLeftJoinNodeAttributesQuery = (
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

@@ -34,6 +34,7 @@ export type CalendarViewNode = {
name: string;
type: 'calendar_view';
filters: ViewFilterNode[];
groupBy: string | null;
};
export type FieldDataType =