mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 11:47:47 +01:00
Group board views by multi-select, collaborator or creator and some calendar view improvements (#121)
* Improve board views * Allow group by collaborator in board views * Allow group by creator in boards * Add calendar view no group component * Add updated by and updated at field selectors
This commit is contained in:
@@ -48,7 +48,6 @@ export class ViewCreateMutationHandler
|
|||||||
index: generateFractionalIndex(maxIndex, null),
|
index: generateFractionalIndex(maxIndex, null),
|
||||||
layout: input.viewType,
|
layout: input.viewType,
|
||||||
parentId: input.databaseId,
|
parentId: input.databaseId,
|
||||||
groupBy: input.groupBy,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await workspace.nodes.createNode({
|
await workspace.nodes.createNode({
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { NodeGetQueryHandler } from './nodes/node-get';
|
|||||||
import { NodeReactionsListQueryHandler } from './nodes/node-reaction-list';
|
import { NodeReactionsListQueryHandler } from './nodes/node-reaction-list';
|
||||||
import { NodeReactionsAggregateQueryHandler } from './nodes/node-reactions-aggregate';
|
import { NodeReactionsAggregateQueryHandler } from './nodes/node-reactions-aggregate';
|
||||||
import { NodeTreeGetQueryHandler } from './nodes/node-tree-get';
|
import { NodeTreeGetQueryHandler } from './nodes/node-tree-get';
|
||||||
|
import { RecordFieldValueCountQueryHandler } from './records/record-field-value-count';
|
||||||
import { RecordListQueryHandler } from './records/record-list';
|
import { RecordListQueryHandler } from './records/record-list';
|
||||||
import { RecordSearchQueryHandler } from './records/record-search';
|
import { RecordSearchQueryHandler } from './records/record-search';
|
||||||
import { ServerListQueryHandler } from './servers/server-list';
|
import { ServerListQueryHandler } from './servers/server-list';
|
||||||
@@ -58,6 +59,7 @@ export const buildQueryHandlerMap = (app: AppService): QueryHandlerMap => {
|
|||||||
'node.get': new NodeGetQueryHandler(app),
|
'node.get': new NodeGetQueryHandler(app),
|
||||||
'node.tree.get': new NodeTreeGetQueryHandler(app),
|
'node.tree.get': new NodeTreeGetQueryHandler(app),
|
||||||
'record.list': new RecordListQueryHandler(app),
|
'record.list': new RecordListQueryHandler(app),
|
||||||
|
'record.field.value.count': new RecordFieldValueCountQueryHandler(app),
|
||||||
'server.list': new ServerListQueryHandler(app),
|
'server.list': new ServerListQueryHandler(app),
|
||||||
'user.search': new UserSearchQueryHandler(app),
|
'user.search': new UserSearchQueryHandler(app),
|
||||||
'workspace.list': new WorkspaceListQueryHandler(app),
|
'workspace.list': new WorkspaceListQueryHandler(app),
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { sql } from 'kysely';
|
||||||
|
|
||||||
|
import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base';
|
||||||
|
import { mapNode } from '@colanode/client/lib/mappers';
|
||||||
|
import { buildFiltersQuery } from '@colanode/client/lib/records';
|
||||||
|
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
|
||||||
|
import {
|
||||||
|
RecordFieldValueCountQueryInput,
|
||||||
|
RecordFieldValueCountQueryOutput,
|
||||||
|
RecordFieldValueCount,
|
||||||
|
} from '@colanode/client/queries/records/record-field-value-count';
|
||||||
|
import { Event } from '@colanode/client/types/events';
|
||||||
|
import { DatabaseNode, FieldAttributes } from '@colanode/core';
|
||||||
|
|
||||||
|
export class RecordFieldValueCountQueryHandler
|
||||||
|
extends WorkspaceQueryHandlerBase
|
||||||
|
implements QueryHandler<RecordFieldValueCountQueryInput>
|
||||||
|
{
|
||||||
|
public async handleQuery(
|
||||||
|
input: RecordFieldValueCountQueryInput
|
||||||
|
): Promise<RecordFieldValueCountQueryOutput> {
|
||||||
|
const counts = await this.fetchFieldValueCounts(input);
|
||||||
|
return {
|
||||||
|
items: counts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async checkForChanges(
|
||||||
|
event: Event,
|
||||||
|
input: RecordFieldValueCountQueryInput,
|
||||||
|
_output: RecordFieldValueCountQueryOutput
|
||||||
|
): Promise<ChangeCheckResult<RecordFieldValueCountQueryInput>> {
|
||||||
|
if (
|
||||||
|
event.type === 'workspace.deleted' &&
|
||||||
|
event.workspace.accountId === input.accountId &&
|
||||||
|
event.workspace.id === input.workspaceId
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
hasChanges: true,
|
||||||
|
result: { items: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.type === 'node.created' &&
|
||||||
|
event.accountId === input.accountId &&
|
||||||
|
event.workspaceId === input.workspaceId &&
|
||||||
|
event.node.type === 'record'
|
||||||
|
) {
|
||||||
|
const newResult = await this.handleQuery(input);
|
||||||
|
return {
|
||||||
|
hasChanges: true,
|
||||||
|
result: newResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.type === 'node.updated' &&
|
||||||
|
event.accountId === input.accountId &&
|
||||||
|
event.workspaceId === input.workspaceId
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
event.node.type === 'record' &&
|
||||||
|
event.node.attributes.databaseId === input.databaseId
|
||||||
|
) {
|
||||||
|
const newResult = await this.handleQuery(input);
|
||||||
|
return {
|
||||||
|
hasChanges: true,
|
||||||
|
result: newResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.node.type === 'database' &&
|
||||||
|
event.node.id === input.databaseId
|
||||||
|
) {
|
||||||
|
const newResult = await this.handleQuery(input);
|
||||||
|
return {
|
||||||
|
hasChanges: true,
|
||||||
|
result: newResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.type === 'node.deleted' &&
|
||||||
|
event.accountId === input.accountId &&
|
||||||
|
event.workspaceId === input.workspaceId
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
event.node.type === 'record' &&
|
||||||
|
event.node.attributes.databaseId === input.databaseId
|
||||||
|
) {
|
||||||
|
const newResult = await this.handleQuery(input);
|
||||||
|
return {
|
||||||
|
hasChanges: true,
|
||||||
|
result: newResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.node.type === 'database' &&
|
||||||
|
event.node.id === input.databaseId
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
hasChanges: true,
|
||||||
|
result: { items: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasChanges: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchFieldValueCounts(
|
||||||
|
input: RecordFieldValueCountQueryInput
|
||||||
|
): Promise<RecordFieldValueCount[]> {
|
||||||
|
const database = await this.fetchDatabase(input);
|
||||||
|
const field = database.attributes.fields[input.fieldId];
|
||||||
|
|
||||||
|
if (!field) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterQuery = buildFiltersQuery(
|
||||||
|
input.filters,
|
||||||
|
database.attributes.fields
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryString = this.buildQuery(input.databaseId, field, filterQuery);
|
||||||
|
|
||||||
|
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
|
||||||
|
const query = sql<RecordFieldValueCount>`${sql.raw(queryString)}`.compile(
|
||||||
|
workspace.database
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await workspace.database.executeQuery(query);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildQuery(
|
||||||
|
databaseId: string,
|
||||||
|
field: FieldAttributes,
|
||||||
|
filterQuery: string
|
||||||
|
): string {
|
||||||
|
switch (field.type) {
|
||||||
|
case 'boolean':
|
||||||
|
return this.buildBooleanQuery(databaseId, field, filterQuery);
|
||||||
|
case 'multi_select':
|
||||||
|
case 'collaborator':
|
||||||
|
case 'relation':
|
||||||
|
return this.buildStringArrayQuery(databaseId, field, filterQuery);
|
||||||
|
default:
|
||||||
|
return this.buildDefaultQuery(databaseId, field, filterQuery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildBooleanQuery(
|
||||||
|
databaseId: string,
|
||||||
|
field: FieldAttributes,
|
||||||
|
filterQuery: string
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN ${this.buildFieldSelector(field)} = 'true' THEN 'true'
|
||||||
|
ELSE 'false'
|
||||||
|
END as value,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM nodes n
|
||||||
|
WHERE n.parent_id = '${databaseId}'
|
||||||
|
AND n.type = 'record'
|
||||||
|
${filterQuery}
|
||||||
|
GROUP BY value
|
||||||
|
ORDER BY count DESC, value ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStringArrayQuery(
|
||||||
|
databaseId: string,
|
||||||
|
field: FieldAttributes,
|
||||||
|
filterQuery: string
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
SELECT
|
||||||
|
json_each.value as value,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM nodes n,
|
||||||
|
json_each(${this.buildFieldSelector(field)})
|
||||||
|
WHERE n.parent_id = '${databaseId}'
|
||||||
|
AND n.type = 'record'
|
||||||
|
AND ${this.buildFieldSelector(field)} IS NOT NULL
|
||||||
|
${filterQuery}
|
||||||
|
GROUP BY json_each.value
|
||||||
|
ORDER BY count DESC, value ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDefaultQuery(
|
||||||
|
databaseId: string,
|
||||||
|
field: FieldAttributes,
|
||||||
|
filterQuery: string
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
SELECT
|
||||||
|
COALESCE(CAST(${this.buildFieldSelector(field)} AS TEXT), 'null') as value,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM nodes n
|
||||||
|
WHERE n.parent_id = '${databaseId}'
|
||||||
|
AND n.type = 'record'
|
||||||
|
AND ${this.buildFieldSelector(field)} IS NOT NULL
|
||||||
|
${filterQuery}
|
||||||
|
GROUP BY value
|
||||||
|
ORDER BY count DESC, value ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFieldSelector(field: FieldAttributes): string {
|
||||||
|
if (field.type === 'created_at') {
|
||||||
|
return `n.created_at`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'created_by') {
|
||||||
|
return `n.created_by`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'updated_at') {
|
||||||
|
return `n.updated_at`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'updated_by') {
|
||||||
|
return `n.updated_by`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `json_extract(n.attributes, '$.fields.${field.id}.value')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchDatabase(
|
||||||
|
input: RecordFieldValueCountQueryInput
|
||||||
|
): Promise<DatabaseNode> {
|
||||||
|
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
|
||||||
|
|
||||||
|
const row = await workspace.database
|
||||||
|
.selectFrom('nodes')
|
||||||
|
.where('id', '=', input.databaseId)
|
||||||
|
.selectAll()
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('Database not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = mapNode(row) as DatabaseNode;
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,43 +3,15 @@ import { sql } from 'kysely';
|
|||||||
import { SelectNode } from '@colanode/client/databases/workspace';
|
import { SelectNode } from '@colanode/client/databases/workspace';
|
||||||
import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base';
|
import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base';
|
||||||
import { mapNode } from '@colanode/client/lib/mappers';
|
import { mapNode } from '@colanode/client/lib/mappers';
|
||||||
|
import {
|
||||||
|
buildFiltersQuery,
|
||||||
|
buildSortOrdersQuery,
|
||||||
|
} from '@colanode/client/lib/records';
|
||||||
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
|
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
|
||||||
import { RecordListQueryInput } from '@colanode/client/queries/records/record-list';
|
import { RecordListQueryInput } from '@colanode/client/queries/records/record-list';
|
||||||
import { Event } from '@colanode/client/types/events';
|
import { Event } from '@colanode/client/types/events';
|
||||||
import { LocalRecordNode } from '@colanode/client/types/nodes';
|
import { LocalRecordNode } from '@colanode/client/types/nodes';
|
||||||
import {
|
import { DatabaseNode } from '@colanode/core';
|
||||||
BooleanFieldAttributes,
|
|
||||||
CreatedAtFieldAttributes,
|
|
||||||
DatabaseNode,
|
|
||||||
DateFieldAttributes,
|
|
||||||
EmailFieldAttributes,
|
|
||||||
FieldAttributes,
|
|
||||||
isStringArray,
|
|
||||||
NumberFieldAttributes,
|
|
||||||
PhoneFieldAttributes,
|
|
||||||
SelectFieldAttributes,
|
|
||||||
TextFieldAttributes,
|
|
||||||
UrlFieldAttributes,
|
|
||||||
DatabaseViewFieldFilterAttributes,
|
|
||||||
DatabaseViewFilterAttributes,
|
|
||||||
DatabaseViewSortAttributes,
|
|
||||||
MultiSelectFieldAttributes,
|
|
||||||
SpecialId,
|
|
||||||
} from '@colanode/core';
|
|
||||||
|
|
||||||
type SqliteOperator =
|
|
||||||
| '='
|
|
||||||
| '!='
|
|
||||||
| '>'
|
|
||||||
| '<'
|
|
||||||
| '>='
|
|
||||||
| '<='
|
|
||||||
| 'LIKE'
|
|
||||||
| 'NOT LIKE'
|
|
||||||
| 'IS'
|
|
||||||
| 'IS NOT'
|
|
||||||
| 'IN'
|
|
||||||
| 'NOT IN';
|
|
||||||
|
|
||||||
export class RecordListQueryHandler
|
export class RecordListQueryHandler
|
||||||
extends WorkspaceQueryHandlerBase
|
extends WorkspaceQueryHandlerBase
|
||||||
@@ -162,13 +134,13 @@ export class RecordListQueryHandler
|
|||||||
input: RecordListQueryInput
|
input: RecordListQueryInput
|
||||||
): Promise<SelectNode[]> {
|
): Promise<SelectNode[]> {
|
||||||
const database = await this.fetchDatabase(input);
|
const database = await this.fetchDatabase(input);
|
||||||
const filterQuery = this.buildFiltersQuery(
|
const filterQuery = buildFiltersQuery(
|
||||||
input.filters,
|
input.filters,
|
||||||
database.attributes.fields
|
database.attributes.fields
|
||||||
);
|
);
|
||||||
|
|
||||||
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
|
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
|
||||||
const orderByQuery = `ORDER BY ${input.sorts.length > 0 ? this.buildSortOrdersQuery(input.sorts, database.attributes.fields) : 'n."id" ASC'}`;
|
const orderByQuery = `ORDER BY ${input.sorts.length > 0 ? buildSortOrdersQuery(input.sorts, database.attributes.fields) : 'n."id" ASC'}`;
|
||||||
const offset = (input.page - 1) * input.count;
|
const offset = (input.page - 1) * input.count;
|
||||||
const query = sql<SelectNode>`
|
const query = sql<SelectNode>`
|
||||||
SELECT n.*
|
SELECT n.*
|
||||||
@@ -201,582 +173,4 @@ export class RecordListQueryHandler
|
|||||||
const database = mapNode(row) as DatabaseNode;
|
const database = mapNode(row) as DatabaseNode;
|
||||||
return database;
|
return database;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildFiltersQuery = (
|
|
||||||
filters: DatabaseViewFilterAttributes[],
|
|
||||||
fields: Record<string, FieldAttributes>
|
|
||||||
): string => {
|
|
||||||
if (filters.length === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterQueries = filters
|
|
||||||
.map((filter) => this.buildFilterQuery(filter, fields))
|
|
||||||
.filter((query) => query !== null);
|
|
||||||
|
|
||||||
if (filterQueries.length === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `AND (${filterQueries.join(' AND ')})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildFilterQuery = (
|
|
||||||
filter: DatabaseViewFilterAttributes,
|
|
||||||
fields: Record<string, FieldAttributes>
|
|
||||||
): string | null => {
|
|
||||||
if (filter.type === 'group') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.fieldId === SpecialId.Name) {
|
|
||||||
return this.buildNameFilterQuery(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
const field = fields[filter.fieldId];
|
|
||||||
if (!field) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (field.type) {
|
|
||||||
case 'boolean':
|
|
||||||
return this.buildBooleanFilterQuery(filter, field);
|
|
||||||
case 'collaborator':
|
|
||||||
return null;
|
|
||||||
case 'created_at':
|
|
||||||
return this.buildCreatedAtFilterQuery(filter, field);
|
|
||||||
case 'created_by':
|
|
||||||
return null;
|
|
||||||
case 'date':
|
|
||||||
return this.buildDateFilterQuery(filter, field);
|
|
||||||
case 'email':
|
|
||||||
return this.buildEmailFilterQuery(filter, field);
|
|
||||||
case 'file':
|
|
||||||
return null;
|
|
||||||
case 'multi_select':
|
|
||||||
return this.buildMultiSelectFilterQuery(filter, field);
|
|
||||||
case 'number':
|
|
||||||
return this.buildNumberFilterQuery(filter, field);
|
|
||||||
case 'phone':
|
|
||||||
return this.buildPhoneFilterQuery(filter, field);
|
|
||||||
case 'select':
|
|
||||||
return this.buildSelectFilterQuery(filter, field);
|
|
||||||
case 'text':
|
|
||||||
return this.buildTextFilterQuery(filter, field);
|
|
||||||
case 'url':
|
|
||||||
return this.buildUrlFilterQuery(filter, field);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildNameFilterQuery = (
|
|
||||||
filter: DatabaseViewFieldFilterAttributes
|
|
||||||
): string | null => {
|
|
||||||
if (filter.operator === 'is_empty') {
|
|
||||||
return this.buildAttributeFilterQuery('name', 'IS', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.operator === 'is_not_empty') {
|
|
||||||
return this.buildAttributeFilterQuery('name', 'IS NOT', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof filter.value !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = filter.value as string;
|
|
||||||
if (!value || value.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (filter.operator) {
|
|
||||||
case 'is_equal_to':
|
|
||||||
return this.buildAttributeFilterQuery('name', '=', `'${value}'`);
|
|
||||||
case 'is_not_equal_to':
|
|
||||||
return this.buildAttributeFilterQuery('name', '!=', `'${value}'`);
|
|
||||||
case 'contains':
|
|
||||||
return this.buildAttributeFilterQuery('name', 'LIKE', `'%${value}%'`);
|
|
||||||
case 'does_not_contain':
|
|
||||||
return this.buildAttributeFilterQuery(
|
|
||||||
'name',
|
|
||||||
'NOT LIKE',
|
|
||||||
`'%${value}%'`
|
|
||||||
);
|
|
||||||
case 'starts_with':
|
|
||||||
return this.buildAttributeFilterQuery('name', 'LIKE', `'${value}%'`);
|
|
||||||
case 'ends_with':
|
|
||||||
return this.buildAttributeFilterQuery('name', 'LIKE', `'%${value}'`);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildBooleanFilterQuery = (
|
|
||||||
filter: DatabaseViewFieldFilterAttributes,
|
|
||||||
field: BooleanFieldAttributes
|
|
||||||
): string | null => {
|
|
||||||
if (filter.operator === 'is_true') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, '=', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.operator === 'is_false') {
|
|
||||||
return `(${this.buildFieldFilterQuery(field.id, '=', 'false')} OR ${this.buildFieldFilterQuery(field.id, 'IS', 'NULL')})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildNumberFilterQuery = (
|
|
||||||
filter: DatabaseViewFieldFilterAttributes,
|
|
||||||
field: NumberFieldAttributes
|
|
||||||
): string | null => {
|
|
||||||
if (filter.operator === 'is_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.operator === 'is_not_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof filter.value !== 'number') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = filter.value as number;
|
|
||||||
if (isNaN(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (filter.operator) {
|
|
||||||
case 'is_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '=', value.toString());
|
|
||||||
case 'is_not_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '!=', value.toString());
|
|
||||||
case 'is_greater_than':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '>', value.toString());
|
|
||||||
case 'is_less_than':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '<', value.toString());
|
|
||||||
case 'is_greater_than_or_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '>=', value.toString());
|
|
||||||
case 'is_less_than_or_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '<=', value.toString());
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildTextFilterQuery = (
|
|
||||||
filter: DatabaseViewFieldFilterAttributes,
|
|
||||||
field: TextFieldAttributes
|
|
||||||
): string | null => {
|
|
||||||
if (filter.operator === 'is_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.operator === 'is_not_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof filter.value !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = filter.value as string;
|
|
||||||
if (!value || value.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (filter.operator) {
|
|
||||||
case 'is_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '=', `'${value}'`);
|
|
||||||
case 'is_not_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '!=', `'${value}'`);
|
|
||||||
case 'contains':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}%'`);
|
|
||||||
case 'does_not_contain':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'NOT LIKE', `'%${value}%'`);
|
|
||||||
case 'starts_with':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'LIKE', `'${value}%'`);
|
|
||||||
case 'ends_with':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}'`);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildEmailFilterQuery = (
|
|
||||||
filter: DatabaseViewFieldFilterAttributes,
|
|
||||||
field: EmailFieldAttributes
|
|
||||||
): string | null => {
|
|
||||||
if (filter.operator === 'is_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.operator === 'is_not_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof filter.value !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = filter.value as string;
|
|
||||||
if (!value || value.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (filter.operator) {
|
|
||||||
case 'is_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '=', `'${value}'`);
|
|
||||||
case 'is_not_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '!=', `'${value}'`);
|
|
||||||
case 'contains':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}%'`);
|
|
||||||
case 'does_not_contain':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'NOT LIKE', `'%${value}%'`);
|
|
||||||
case 'starts_with':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'LIKE', `'${value}%'`);
|
|
||||||
case 'ends_with':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}'`);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildPhoneFilterQuery = (
|
|
||||||
filter: DatabaseViewFieldFilterAttributes,
|
|
||||||
field: PhoneFieldAttributes
|
|
||||||
): string | null => {
|
|
||||||
if (filter.operator === 'is_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.operator === 'is_not_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof filter.value !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = filter.value as string;
|
|
||||||
if (!value || value.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (filter.operator) {
|
|
||||||
case 'is_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '=', `'${value}'`);
|
|
||||||
case 'is_not_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '!=', `'${value}'`);
|
|
||||||
case 'contains':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}%'`);
|
|
||||||
case 'does_not_contain':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'NOT LIKE', `'%${value}%'`);
|
|
||||||
case 'starts_with':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'LIKE', `'${value}%'`);
|
|
||||||
case 'ends_with':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}'`);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildUrlFilterQuery = (
|
|
||||||
filter: DatabaseViewFieldFilterAttributes,
|
|
||||||
field: UrlFieldAttributes
|
|
||||||
): string | null => {
|
|
||||||
if (filter.operator === 'is_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.operator === 'is_not_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof filter.value !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = filter.value as string;
|
|
||||||
if (!value || value.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (filter.operator) {
|
|
||||||
case 'is_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '=', `'${value}'`);
|
|
||||||
case 'is_not_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '!=', `'${value}'`);
|
|
||||||
case 'contains':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}%'`);
|
|
||||||
case 'does_not_contain':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'NOT LIKE', `'%${value}%'`);
|
|
||||||
case 'starts_with':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'LIKE', `'${value}%'`);
|
|
||||||
case 'ends_with':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'LIKE', `'%${value}'`);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildSelectFilterQuery = (
|
|
||||||
filter: DatabaseViewFieldFilterAttributes,
|
|
||||||
field: SelectFieldAttributes
|
|
||||||
): string | null => {
|
|
||||||
if (filter.operator === 'is_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.operator === 'is_not_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isStringArray(filter.value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.value.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = this.joinIds(filter.value);
|
|
||||||
switch (filter.operator) {
|
|
||||||
case 'is_in':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IN', `(${values})`);
|
|
||||||
case 'is_not_in':
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'NOT IN', `(${values})`);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildMultiSelectFilterQuery = (
|
|
||||||
filter: DatabaseViewFieldFilterAttributes,
|
|
||||||
field: MultiSelectFieldAttributes
|
|
||||||
): string | null => {
|
|
||||||
if (filter.operator === 'is_empty') {
|
|
||||||
return `json_extract(n.attributes, '$.fields.${field.id}.value') IS NULL OR json_array_length(json_extract(n.attributes, '$.fields.${field.id}.value')) = 0`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.operator === 'is_not_empty') {
|
|
||||||
return `json_extract(n.attributes, '$.fields.${field.id}.value') IS NOT NULL AND json_array_length(json_extract(n.attributes, '$.fields.${field.id}.value')) > 0`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isStringArray(filter.value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.value.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = this.joinIds(filter.value);
|
|
||||||
switch (filter.operator) {
|
|
||||||
case 'is_in':
|
|
||||||
return `EXISTS (SELECT 1 FROM json_each(json_extract(n.attributes, '$.fields.${field.id}.value')) WHERE json_each.value IN (${values}))`;
|
|
||||||
case 'is_not_in':
|
|
||||||
return `NOT EXISTS (SELECT 1 FROM json_each(json_extract(n.attributes, '$.fields.${field.id}.value')) WHERE json_each.value IN (${values}))`;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildDateFilterQuery = (
|
|
||||||
filter: DatabaseViewFieldFilterAttributes,
|
|
||||||
field: DateFieldAttributes
|
|
||||||
): string | null => {
|
|
||||||
if (filter.operator === 'is_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.operator === 'is_not_empty') {
|
|
||||||
return this.buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof filter.value !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(filter.value);
|
|
||||||
if (isNaN(date.getTime())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateString = date.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
switch (filter.operator) {
|
|
||||||
case 'is_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '=', `'${dateString}'`);
|
|
||||||
case 'is_not_equal_to':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '!=', `'${dateString}'`);
|
|
||||||
case 'is_on_or_after':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '>=', `'${dateString}'`);
|
|
||||||
case 'is_on_or_before':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '<=', `'${dateString}'`);
|
|
||||||
case 'is_after':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '>', `'${dateString}'`);
|
|
||||||
case 'is_before':
|
|
||||||
return this.buildFieldFilterQuery(field.id, '<', `'${dateString}'`);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildCreatedAtFilterQuery = (
|
|
||||||
filter: DatabaseViewFieldFilterAttributes,
|
|
||||||
_: CreatedAtFieldAttributes
|
|
||||||
): string | null => {
|
|
||||||
if (filter.operator === 'is_empty') {
|
|
||||||
return this.buildAttributeFilterQuery('created_at', 'IS', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.operator === 'is_not_empty') {
|
|
||||||
return this.buildAttributeFilterQuery('created_at', 'IS NOT', 'NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof filter.value !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(filter.value);
|
|
||||||
if (isNaN(date.getTime())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateString = date.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
switch (filter.operator) {
|
|
||||||
case 'is_equal_to':
|
|
||||||
return this.buildAttributeFilterQuery(
|
|
||||||
'created_at',
|
|
||||||
'=',
|
|
||||||
`'${dateString}'`
|
|
||||||
);
|
|
||||||
case 'is_not_equal_to':
|
|
||||||
return this.buildAttributeFilterQuery(
|
|
||||||
'created_at',
|
|
||||||
'!=',
|
|
||||||
`'${dateString}'`
|
|
||||||
);
|
|
||||||
case 'is_on_or_after':
|
|
||||||
return this.buildAttributeFilterQuery(
|
|
||||||
'created_at',
|
|
||||||
'>=',
|
|
||||||
`'${dateString}'`
|
|
||||||
);
|
|
||||||
case 'is_on_or_before':
|
|
||||||
return this.buildAttributeFilterQuery(
|
|
||||||
'created_at',
|
|
||||||
'<=',
|
|
||||||
`'${dateString}'`
|
|
||||||
);
|
|
||||||
case 'is_after':
|
|
||||||
return this.buildAttributeFilterQuery(
|
|
||||||
'created_at',
|
|
||||||
'>',
|
|
||||||
`'${dateString}'`
|
|
||||||
);
|
|
||||||
case 'is_before':
|
|
||||||
return this.buildAttributeFilterQuery(
|
|
||||||
'created_at',
|
|
||||||
'<',
|
|
||||||
`'${dateString}'`
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildFieldFilterQuery = (
|
|
||||||
fieldId: string,
|
|
||||||
operator: SqliteOperator,
|
|
||||||
value: string
|
|
||||||
): string => {
|
|
||||||
return this.buildAttributeFilterQuery(
|
|
||||||
`fields.${fieldId}.value`,
|
|
||||||
operator,
|
|
||||||
value
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildAttributeFilterQuery = (
|
|
||||||
name: string,
|
|
||||||
operator: SqliteOperator,
|
|
||||||
value: string
|
|
||||||
): string => {
|
|
||||||
return `json_extract(n.attributes, '$.${name}') ${operator} ${value}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
private joinIds = (ids: string[]): string => {
|
|
||||||
return ids.map((id) => `'${id}'`).join(',');
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildSortOrdersQuery = (
|
|
||||||
sorts: DatabaseViewSortAttributes[],
|
|
||||||
fields: Record<string, FieldAttributes>
|
|
||||||
): string => {
|
|
||||||
return sorts
|
|
||||||
.map((sort) => this.buildSortOrderQuery(sort, fields))
|
|
||||||
.filter((query) => query !== null && query.length > 0)
|
|
||||||
.join(', ');
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildSortOrderQuery = (
|
|
||||||
sort: DatabaseViewSortAttributes,
|
|
||||||
fields: Record<string, FieldAttributes>
|
|
||||||
): string | null => {
|
|
||||||
if (sort.fieldId === SpecialId.Name) {
|
|
||||||
return `json_extract(n.attributes, '$.name') ${sort.direction}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const field = fields[sort.fieldId];
|
|
||||||
if (!field) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === 'created_at') {
|
|
||||||
return `n.created_at ${sort.direction}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === 'created_by') {
|
|
||||||
return `n.created_by_id ${sort.direction}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `json_extract(n.attributes, '$.fields.${field.id}.value') ${sort.direction}`;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
798
packages/client/src/lib/records.ts
Normal file
798
packages/client/src/lib/records.ts
Normal file
@@ -0,0 +1,798 @@
|
|||||||
|
import {
|
||||||
|
BooleanFieldAttributes,
|
||||||
|
CreatedAtFieldAttributes,
|
||||||
|
DateFieldAttributes,
|
||||||
|
EmailFieldAttributes,
|
||||||
|
FieldAttributes,
|
||||||
|
isStringArray,
|
||||||
|
NumberFieldAttributes,
|
||||||
|
PhoneFieldAttributes,
|
||||||
|
SelectFieldAttributes,
|
||||||
|
TextFieldAttributes,
|
||||||
|
UrlFieldAttributes,
|
||||||
|
DatabaseViewFieldFilterAttributes,
|
||||||
|
DatabaseViewFilterAttributes,
|
||||||
|
DatabaseViewSortAttributes,
|
||||||
|
MultiSelectFieldAttributes,
|
||||||
|
SpecialId,
|
||||||
|
CollaboratorFieldAttributes,
|
||||||
|
CreatedByFieldAttributes,
|
||||||
|
UpdatedAtFieldAttributes,
|
||||||
|
UpdatedByFieldAttributes,
|
||||||
|
} from '@colanode/core';
|
||||||
|
|
||||||
|
type SqliteOperator =
|
||||||
|
| '='
|
||||||
|
| '!='
|
||||||
|
| '>'
|
||||||
|
| '<'
|
||||||
|
| '>='
|
||||||
|
| '<='
|
||||||
|
| 'LIKE'
|
||||||
|
| 'NOT LIKE'
|
||||||
|
| 'IS'
|
||||||
|
| 'IS NOT'
|
||||||
|
| 'IN'
|
||||||
|
| 'NOT IN';
|
||||||
|
|
||||||
|
export const buildFiltersQuery = (
|
||||||
|
filters: DatabaseViewFilterAttributes[],
|
||||||
|
fields: Record<string, FieldAttributes>
|
||||||
|
): string => {
|
||||||
|
if (filters.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterQueries = filters
|
||||||
|
.map((filter) => buildFilterQuery(filter, fields))
|
||||||
|
.filter((query) => query !== null);
|
||||||
|
|
||||||
|
if (filterQueries.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `AND (${filterQueries.join(' AND ')})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFilterQuery = (
|
||||||
|
filter: DatabaseViewFilterAttributes,
|
||||||
|
fields: Record<string, FieldAttributes>
|
||||||
|
): string | null => {
|
||||||
|
if (filter.type === 'group') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.fieldId === SpecialId.Name) {
|
||||||
|
return buildNameFilterQuery(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = fields[filter.fieldId];
|
||||||
|
if (!field) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case 'boolean':
|
||||||
|
return buildBooleanFilterQuery(filter, field);
|
||||||
|
case 'collaborator':
|
||||||
|
return buildCollaboratorFilterQuery(filter, field);
|
||||||
|
case 'created_at':
|
||||||
|
return buildCreatedAtFilterQuery(filter, field);
|
||||||
|
case 'created_by':
|
||||||
|
return buildCreatedByFilterQuery(filter, field);
|
||||||
|
case 'date':
|
||||||
|
return buildDateFilterQuery(filter, field);
|
||||||
|
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);
|
||||||
|
case 'updated_at':
|
||||||
|
return buildUpdatedAtFilterQuery(filter, field);
|
||||||
|
case 'updated_by':
|
||||||
|
return buildUpdatedByFilterQuery(filter, field);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNameFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildAttributeFilterQuery('name', 'IS', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildAttributeFilterQuery('name', 'IS NOT', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filter.value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = filter.value as string;
|
||||||
|
if (!value || value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_equal_to':
|
||||||
|
return buildAttributeFilterQuery('name', '=', `'${value}'`);
|
||||||
|
case 'is_not_equal_to':
|
||||||
|
return buildAttributeFilterQuery('name', '!=', `'${value}'`);
|
||||||
|
case 'contains':
|
||||||
|
return buildAttributeFilterQuery('name', 'LIKE', `'%${value}%'`);
|
||||||
|
case 'does_not_contain':
|
||||||
|
return buildAttributeFilterQuery('name', 'NOT LIKE', `'%${value}%'`);
|
||||||
|
case 'starts_with':
|
||||||
|
return buildAttributeFilterQuery('name', 'LIKE', `'${value}%'`);
|
||||||
|
case 'ends_with':
|
||||||
|
return buildAttributeFilterQuery('name', 'LIKE', `'%${value}'`);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildBooleanFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
field: BooleanFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_true') {
|
||||||
|
return buildFieldFilterQuery(field.id, '=', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_false') {
|
||||||
|
return `(${buildFieldFilterQuery(field.id, '=', 'false')} OR ${buildFieldFilterQuery(field.id, 'IS', 'NULL')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCollaboratorFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
field: CollaboratorFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildFieldArrayIsEmptyFilterQuery(field.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildFieldArrayIsNotEmptyFilterQuery(field.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStringArray(filter.value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_in':
|
||||||
|
return buildArrayFieldContainsFilterQuery(field.id, filter.value);
|
||||||
|
case 'is_not_in':
|
||||||
|
return buildArrayFieldDoesNotContainFilterQuery(field.id, filter.value);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNumberFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
field: NumberFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filter.value !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = filter.value as number;
|
||||||
|
if (isNaN(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '=', value.toString());
|
||||||
|
case 'is_not_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '!=', value.toString());
|
||||||
|
case 'is_greater_than':
|
||||||
|
return buildFieldFilterQuery(field.id, '>', value.toString());
|
||||||
|
case 'is_less_than':
|
||||||
|
return buildFieldFilterQuery(field.id, '<', value.toString());
|
||||||
|
case 'is_greater_than_or_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '>=', value.toString());
|
||||||
|
case 'is_less_than_or_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '<=', value.toString());
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTextFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
field: TextFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filter.value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = filter.value as string;
|
||||||
|
if (!value || value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '=', `'${value}'`);
|
||||||
|
case 'is_not_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '!=', `'${value}'`);
|
||||||
|
case 'contains':
|
||||||
|
return buildFieldFilterQuery(field.id, 'LIKE', `'%${value}%'`);
|
||||||
|
case 'does_not_contain':
|
||||||
|
return buildFieldFilterQuery(field.id, 'NOT LIKE', `'%${value}%'`);
|
||||||
|
case 'starts_with':
|
||||||
|
return buildFieldFilterQuery(field.id, 'LIKE', `'${value}%'`);
|
||||||
|
case 'ends_with':
|
||||||
|
return buildFieldFilterQuery(field.id, 'LIKE', `'%${value}'`);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildEmailFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
field: EmailFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filter.value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = filter.value as string;
|
||||||
|
if (!value || value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '=', `'${value}'`);
|
||||||
|
case 'is_not_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '!=', `'${value}'`);
|
||||||
|
case 'contains':
|
||||||
|
return buildFieldFilterQuery(field.id, 'LIKE', `'%${value}%'`);
|
||||||
|
case 'does_not_contain':
|
||||||
|
return buildFieldFilterQuery(field.id, 'NOT LIKE', `'%${value}%'`);
|
||||||
|
case 'starts_with':
|
||||||
|
return buildFieldFilterQuery(field.id, 'LIKE', `'${value}%'`);
|
||||||
|
case 'ends_with':
|
||||||
|
return buildFieldFilterQuery(field.id, 'LIKE', `'%${value}'`);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPhoneFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
field: PhoneFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filter.value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = filter.value as string;
|
||||||
|
if (!value || value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '=', `'${value}'`);
|
||||||
|
case 'is_not_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '!=', `'${value}'`);
|
||||||
|
case 'contains':
|
||||||
|
return buildFieldFilterQuery(field.id, 'LIKE', `'%${value}%'`);
|
||||||
|
case 'does_not_contain':
|
||||||
|
return buildFieldFilterQuery(field.id, 'NOT LIKE', `'%${value}%'`);
|
||||||
|
case 'starts_with':
|
||||||
|
return buildFieldFilterQuery(field.id, 'LIKE', `'${value}%'`);
|
||||||
|
case 'ends_with':
|
||||||
|
return buildFieldFilterQuery(field.id, 'LIKE', `'%${value}'`);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUrlFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
field: UrlFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filter.value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = filter.value as string;
|
||||||
|
if (!value || value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '=', `'${value}'`);
|
||||||
|
case 'is_not_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '!=', `'${value}'`);
|
||||||
|
case 'contains':
|
||||||
|
return buildFieldFilterQuery(field.id, 'LIKE', `'%${value}%'`);
|
||||||
|
case 'does_not_contain':
|
||||||
|
return buildFieldFilterQuery(field.id, 'NOT LIKE', `'%${value}%'`);
|
||||||
|
case 'starts_with':
|
||||||
|
return buildFieldFilterQuery(field.id, 'LIKE', `'${value}%'`);
|
||||||
|
case 'ends_with':
|
||||||
|
return buildFieldFilterQuery(field.id, 'LIKE', `'%${value}'`);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSelectFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
field: SelectFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStringArray(filter.value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = joinIds(filter.value);
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_in':
|
||||||
|
return buildFieldFilterQuery(field.id, 'IN', `(${values})`);
|
||||||
|
case 'is_not_in':
|
||||||
|
return buildFieldFilterQuery(field.id, 'NOT IN', `(${values})`);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMultiSelectFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
field: MultiSelectFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildFieldArrayIsEmptyFilterQuery(field.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildFieldArrayIsNotEmptyFilterQuery(field.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStringArray(filter.value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_in':
|
||||||
|
return buildArrayFieldContainsFilterQuery(field.id, filter.value);
|
||||||
|
case 'is_not_in':
|
||||||
|
return buildArrayFieldDoesNotContainFilterQuery(field.id, filter.value);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDateFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
field: DateFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildFieldFilterQuery(field.id, 'IS NOT', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filter.value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(filter.value);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateString = date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '=', `'${dateString}'`);
|
||||||
|
case 'is_not_equal_to':
|
||||||
|
return buildFieldFilterQuery(field.id, '!=', `'${dateString}'`);
|
||||||
|
case 'is_on_or_after':
|
||||||
|
return buildFieldFilterQuery(field.id, '>=', `'${dateString}'`);
|
||||||
|
case 'is_on_or_before':
|
||||||
|
return buildFieldFilterQuery(field.id, '<=', `'${dateString}'`);
|
||||||
|
case 'is_after':
|
||||||
|
return buildFieldFilterQuery(field.id, '>', `'${dateString}'`);
|
||||||
|
case 'is_before':
|
||||||
|
return buildFieldFilterQuery(field.id, '<', `'${dateString}'`);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCreatedAtFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
_: CreatedAtFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildColumnFilterQuery('created_at', 'IS', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildColumnFilterQuery('created_at', 'IS NOT', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filter.value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(filter.value);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateString = date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_equal_to':
|
||||||
|
return buildColumnFilterQuery('created_at', '=', `'${dateString}'`);
|
||||||
|
case 'is_not_equal_to':
|
||||||
|
return buildColumnFilterQuery('created_at', '!=', `'${dateString}'`);
|
||||||
|
case 'is_on_or_after':
|
||||||
|
return buildColumnFilterQuery('created_at', '>=', `'${dateString}'`);
|
||||||
|
case 'is_on_or_before':
|
||||||
|
return buildColumnFilterQuery('created_at', '<=', `'${dateString}'`);
|
||||||
|
case 'is_after':
|
||||||
|
return buildColumnFilterQuery('created_at', '>', `'${dateString}'`);
|
||||||
|
case 'is_before':
|
||||||
|
return buildColumnFilterQuery('created_at', '<', `'${dateString}'`);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCreatedByFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
_: CreatedByFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildColumnFilterQuery('created_by', 'IS', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildColumnFilterQuery('created_by', 'IS NOT', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStringArray(filter.value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_in':
|
||||||
|
return buildColumnFilterQuery(
|
||||||
|
'created_by',
|
||||||
|
'IN',
|
||||||
|
`(${joinIds(filter.value)})`
|
||||||
|
);
|
||||||
|
case 'is_not_in':
|
||||||
|
return buildColumnFilterQuery(
|
||||||
|
'created_by',
|
||||||
|
'NOT IN',
|
||||||
|
`(${joinIds(filter.value)})`
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUpdatedAtFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
_: UpdatedAtFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildColumnFilterQuery('updated_at', 'IS', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildColumnFilterQuery('updated_at', 'IS NOT', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filter.value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(filter.value);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateString = date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_equal_to':
|
||||||
|
return buildColumnFilterQuery('updated_at', '=', `'${dateString}'`);
|
||||||
|
case 'is_not_equal_to':
|
||||||
|
return buildColumnFilterQuery('updated_at', '!=', `'${dateString}'`);
|
||||||
|
case 'is_on_or_after':
|
||||||
|
return buildColumnFilterQuery('updated_at', '>=', `'${dateString}'`);
|
||||||
|
case 'is_on_or_before':
|
||||||
|
return buildColumnFilterQuery('updated_at', '<=', `'${dateString}'`);
|
||||||
|
case 'is_after':
|
||||||
|
return buildColumnFilterQuery('updated_at', '>', `'${dateString}'`);
|
||||||
|
case 'is_before':
|
||||||
|
return buildColumnFilterQuery('updated_at', '<', `'${dateString}'`);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUpdatedByFilterQuery = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
_: UpdatedByFieldAttributes
|
||||||
|
): string | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildColumnFilterQuery('updated_by', 'IS', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildColumnFilterQuery('updated_by', 'IS NOT', 'NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStringArray(filter.value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_in':
|
||||||
|
return buildColumnFilterQuery(
|
||||||
|
'updated_by',
|
||||||
|
'IN',
|
||||||
|
`(${joinIds(filter.value)})`
|
||||||
|
);
|
||||||
|
case 'is_not_in':
|
||||||
|
return buildColumnFilterQuery(
|
||||||
|
'updated_by',
|
||||||
|
'NOT IN',
|
||||||
|
`(${joinIds(filter.value)})`
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFieldFilterQuery = (
|
||||||
|
fieldId: string,
|
||||||
|
operator: SqliteOperator,
|
||||||
|
value: string
|
||||||
|
): string => {
|
||||||
|
return buildAttributeFilterQuery(`fields.${fieldId}.value`, operator, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildAttributeFilterQuery = (
|
||||||
|
attribute: string,
|
||||||
|
operator: SqliteOperator,
|
||||||
|
value: string
|
||||||
|
): string => {
|
||||||
|
return `json_extract(n.attributes, '$.${attribute}') ${operator} ${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildColumnFilterQuery = (
|
||||||
|
column: string,
|
||||||
|
operator: SqliteOperator,
|
||||||
|
value: string
|
||||||
|
): string => {
|
||||||
|
return `n.${column} ${operator} ${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFieldArrayIsEmptyFilterQuery = (fieldId: string): string => {
|
||||||
|
return buildAttributeArrayIsEmptyFilterQuery(`fields.${fieldId}.value`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildAttributeArrayIsEmptyFilterQuery = (attribute: string): string => {
|
||||||
|
return `json_extract(n.attributes, '$.${attribute}') IS NULL OR json_array_length(json_extract(n.attributes, '$.${attribute}')) = 0`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFieldArrayIsNotEmptyFilterQuery = (fieldId: string): string => {
|
||||||
|
return buildAttributeArrayIsNotEmptyFilterQuery(`fields.${fieldId}.value`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildAttributeArrayIsNotEmptyFilterQuery = (
|
||||||
|
attribute: string
|
||||||
|
): string => {
|
||||||
|
return `json_extract(n.attributes, '$.${attribute}') IS NOT NULL AND json_array_length(json_extract(n.attributes, '$.${attribute}')) > 0`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildArrayFieldContainsFilterQuery = (
|
||||||
|
fieldId: string,
|
||||||
|
value: string[]
|
||||||
|
): string => {
|
||||||
|
return buildArrayAttributeContainsFilterQuery(
|
||||||
|
`fields.${fieldId}.value`,
|
||||||
|
value
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildArrayAttributeContainsFilterQuery = (
|
||||||
|
attribute: string,
|
||||||
|
value: string[]
|
||||||
|
): string => {
|
||||||
|
const ids = joinIds(value);
|
||||||
|
return `EXISTS (SELECT 1 FROM json_each(json_extract(n.attributes, '$.${attribute}')) WHERE json_each.value IN (${ids}))`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildArrayFieldDoesNotContainFilterQuery = (
|
||||||
|
fieldId: string,
|
||||||
|
value: string[]
|
||||||
|
): string => {
|
||||||
|
return buildArrayAttributeDoesNotContainFilterQuery(
|
||||||
|
`fields.${fieldId}.value`,
|
||||||
|
value
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildArrayAttributeDoesNotContainFilterQuery = (
|
||||||
|
attribute: string,
|
||||||
|
value: string[]
|
||||||
|
): string => {
|
||||||
|
const ids = joinIds(value);
|
||||||
|
return `NOT EXISTS (SELECT 1 FROM json_each(json_extract(n.attributes, '$.${attribute}')) WHERE json_each.value IN (${ids}))`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinIds = (ids: string[]): string => {
|
||||||
|
return ids.map((id) => `'${id}'`).join(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildSortOrdersQuery = (
|
||||||
|
sorts: DatabaseViewSortAttributes[],
|
||||||
|
fields: Record<string, FieldAttributes>
|
||||||
|
): string => {
|
||||||
|
return sorts
|
||||||
|
.map((sort) => buildSortOrderQuery(sort, fields))
|
||||||
|
.filter((query) => query !== null && query.length > 0)
|
||||||
|
.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSortOrderQuery = (
|
||||||
|
sort: DatabaseViewSortAttributes,
|
||||||
|
fields: Record<string, FieldAttributes>
|
||||||
|
): string | null => {
|
||||||
|
if (sort.fieldId === SpecialId.Name) {
|
||||||
|
return `json_extract(n.attributes, '$.name') ${sort.direction}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = fields[sort.fieldId];
|
||||||
|
if (!field) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'created_at') {
|
||||||
|
return `n.created_at ${sort.direction}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'created_by') {
|
||||||
|
return `n.created_by_id ${sort.direction}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `json_extract(n.attributes, '$.fields.${field.id}.value') ${sort.direction}`;
|
||||||
|
};
|
||||||
@@ -5,7 +5,6 @@ export type ViewCreateMutationInput = {
|
|||||||
databaseId: string;
|
databaseId: string;
|
||||||
viewType: 'table' | 'board' | 'calendar';
|
viewType: 'table' | 'board' | 'calendar';
|
||||||
name: string;
|
name: string;
|
||||||
groupBy: string | null | undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ViewCreateMutationOutput = {
|
export type ViewCreateMutationOutput = {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export * from './workspaces/workspace-get';
|
|||||||
export * from './workspaces/workspace-list';
|
export * from './workspaces/workspace-list';
|
||||||
export * from './workspaces/workspace-metadata-list';
|
export * from './workspaces/workspace-metadata-list';
|
||||||
export * from './avatars/avatar-url-get';
|
export * from './avatars/avatar-url-get';
|
||||||
|
export * from './records/record-field-value-count';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
export interface QueryMap {}
|
export interface QueryMap {}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { DatabaseViewFilterAttributes } from '@colanode/core';
|
||||||
|
|
||||||
|
export type RecordFieldValueCountQueryInput = {
|
||||||
|
type: 'record.field.value.count';
|
||||||
|
databaseId: string;
|
||||||
|
filters: DatabaseViewFilterAttributes[];
|
||||||
|
fieldId: string;
|
||||||
|
accountId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecordFieldValueCount = {
|
||||||
|
value: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecordFieldValueCountQueryOutput = {
|
||||||
|
items: RecordFieldValueCount[];
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module '@colanode/client/queries' {
|
||||||
|
interface QueryMap {
|
||||||
|
'record.field.value.count': {
|
||||||
|
input: RecordFieldValueCountQueryInput;
|
||||||
|
output: RecordFieldValueCountQueryOutput;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { SelectOptionAttributes } from '@colanode/core';
|
|
||||||
import { SelectOptionBadge } from '@colanode/ui/components/databases/fields/select-option-badge';
|
|
||||||
|
|
||||||
interface BoardViewColumnHeaderProps {
|
|
||||||
option: SelectOptionAttributes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BoardViewColumnHeader = ({
|
|
||||||
option,
|
|
||||||
}: BoardViewColumnHeaderProps) => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
|
||||||
<SelectOptionBadge name={option.name} color={option.color} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,39 +1,23 @@
|
|||||||
import {
|
import { extractNodeRole, DatabaseViewFilterAttributes } from '@colanode/core';
|
||||||
extractNodeRole,
|
|
||||||
SelectFieldAttributes,
|
|
||||||
SelectOptionAttributes,
|
|
||||||
DatabaseViewFilterAttributes,
|
|
||||||
} from '@colanode/core';
|
|
||||||
import { BoardViewRecordCard } from '@colanode/ui/components/databases/boards/board-view-record-card';
|
import { BoardViewRecordCard } from '@colanode/ui/components/databases/boards/board-view-record-card';
|
||||||
import { BoardViewRecordCreateCard } from '@colanode/ui/components/databases/boards/board-view-record-create-card';
|
import { BoardViewRecordCreateCard } from '@colanode/ui/components/databases/boards/board-view-record-create-card';
|
||||||
import { RecordProvider } from '@colanode/ui/components/records/record-provider';
|
import { RecordProvider } from '@colanode/ui/components/records/record-provider';
|
||||||
|
import { useBoardView } from '@colanode/ui/contexts/board-view';
|
||||||
import { useDatabase } from '@colanode/ui/contexts/database';
|
import { useDatabase } from '@colanode/ui/contexts/database';
|
||||||
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
|
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
|
||||||
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
import { useRecordsQuery } from '@colanode/ui/hooks/use-records-query';
|
import { useRecordsQuery } from '@colanode/ui/hooks/use-records-query';
|
||||||
|
|
||||||
interface BoardViewColumnRecordsProps {
|
export const BoardViewColumnRecords = () => {
|
||||||
field: SelectFieldAttributes;
|
|
||||||
option: SelectOptionAttributes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BoardViewColumnRecords = ({
|
|
||||||
field,
|
|
||||||
option,
|
|
||||||
}: BoardViewColumnRecordsProps) => {
|
|
||||||
const workspace = useWorkspace();
|
const workspace = useWorkspace();
|
||||||
const database = useDatabase();
|
const database = useDatabase();
|
||||||
const view = useDatabaseView();
|
const view = useDatabaseView();
|
||||||
|
const boardView = useBoardView();
|
||||||
|
|
||||||
const filter: DatabaseViewFilterAttributes = {
|
const filters: DatabaseViewFilterAttributes[] = [
|
||||||
id: '1',
|
...view.filters,
|
||||||
type: 'field',
|
boardView.filter,
|
||||||
fieldId: field.id,
|
];
|
||||||
operator: 'is_in',
|
|
||||||
value: [option.id],
|
|
||||||
};
|
|
||||||
|
|
||||||
const filters: DatabaseViewFilterAttributes[] = [...view.filters, filter];
|
|
||||||
|
|
||||||
const { records } = useRecordsQuery(filters, view.sorts);
|
const { records } = useRecordsQuery(filters, view.sorts);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,45 +1,43 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useDrop } from 'react-dnd';
|
import { useDrop } from 'react-dnd';
|
||||||
|
|
||||||
import { SelectFieldAttributes, SelectOptionAttributes } from '@colanode/core';
|
|
||||||
import { BoardViewColumnHeader } from '@colanode/ui/components/databases/boards/board-view-column-header';
|
|
||||||
import { BoardViewColumnRecords } from '@colanode/ui/components/databases/boards/board-view-column-records';
|
import { BoardViewColumnRecords } from '@colanode/ui/components/databases/boards/board-view-column-records';
|
||||||
import { getSelectOptionLightColorClass } from '@colanode/ui/lib/databases';
|
import { useBoardView } from '@colanode/ui/contexts/board-view';
|
||||||
import { cn } from '@colanode/ui/lib/utils';
|
import { cn } from '@colanode/ui/lib/utils';
|
||||||
|
|
||||||
interface BoardViewColumnProps {
|
export const BoardViewColumn = () => {
|
||||||
field: SelectFieldAttributes;
|
const boardView = useBoardView();
|
||||||
option: SelectOptionAttributes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BoardViewColumn = ({ field, option }: BoardViewColumnProps) => {
|
const [{ isOver }, drop] = useDrop({
|
||||||
const [{ isDragging }, drop] = useDrop({
|
|
||||||
accept: 'board-record',
|
accept: 'board-record',
|
||||||
drop: () => ({
|
drop: (item) => {
|
||||||
option: option,
|
const value = boardView.drop(item);
|
||||||
field: field,
|
return {
|
||||||
}),
|
value,
|
||||||
|
};
|
||||||
|
},
|
||||||
collect: (monitor) => ({
|
collect: (monitor) => ({
|
||||||
isDragging: monitor.isOver(),
|
isOver: monitor.isOver(),
|
||||||
}),
|
}),
|
||||||
|
canDrop: boardView.canDrop,
|
||||||
});
|
});
|
||||||
|
|
||||||
const divRef = useRef<HTMLDivElement>(null);
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
const dropRef = drop(divRef);
|
const dropRef = drop(divRef);
|
||||||
|
const dragOverClass = boardView.dragOverClass ?? 'bg-gray-50';
|
||||||
|
|
||||||
const lightClass = getSelectOptionLightColorClass(option.color ?? 'gray');
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={dropRef as React.LegacyRef<HTMLDivElement>}
|
ref={dropRef as React.Ref<HTMLDivElement>}
|
||||||
className={cn('min-h-[400px] border-r p-1', isDragging && lightClass)}
|
className={cn('min-h-[400px] border-r p-1', isOver ? dragOverClass : '')}
|
||||||
style={{
|
style={{
|
||||||
minWidth: '250px',
|
minWidth: '250px',
|
||||||
maxWidth: '250px',
|
maxWidth: '250px',
|
||||||
width: '250px',
|
width: '250px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BoardViewColumnHeader option={option} />
|
<div className="flex flex-row items-center gap-2">{boardView.header}</div>
|
||||||
<BoardViewColumnRecords field={field} option={option} />
|
<BoardViewColumnRecords />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { CircleAlert, CircleDashed } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CollaboratorFieldAttributes,
|
||||||
|
DatabaseViewFilterAttributes,
|
||||||
|
FieldValue,
|
||||||
|
} from '@colanode/core';
|
||||||
|
import { Avatar } from '@colanode/ui/components/avatars/avatar';
|
||||||
|
import { BoardViewColumn } from '@colanode/ui/components/databases/boards/board-view-column';
|
||||||
|
import { Spinner } from '@colanode/ui/components/ui/spinner';
|
||||||
|
import { BoardViewContext } from '@colanode/ui/contexts/board-view';
|
||||||
|
import { useDatabase } from '@colanode/ui/contexts/database';
|
||||||
|
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
|
||||||
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
|
import { useQuery } from '@colanode/ui/hooks/use-query';
|
||||||
|
|
||||||
|
interface BoardViewColumnsCollaboratorProps {
|
||||||
|
field: CollaboratorFieldAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardViewColumnsCollaborator = ({
|
||||||
|
field,
|
||||||
|
}: BoardViewColumnsCollaboratorProps) => {
|
||||||
|
const workspace = useWorkspace();
|
||||||
|
const database = useDatabase();
|
||||||
|
const view = useDatabaseView();
|
||||||
|
|
||||||
|
const collaboratorCountQuery = useQuery({
|
||||||
|
type: 'record.field.value.count',
|
||||||
|
databaseId: database.id,
|
||||||
|
filters: view.filters,
|
||||||
|
fieldId: field.id,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (collaboratorCountQuery.isPending) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collaborators = collaboratorCountQuery.data?.items ?? [];
|
||||||
|
const noValueFilter: DatabaseViewFilterAttributes = {
|
||||||
|
id: '1',
|
||||||
|
type: 'field',
|
||||||
|
fieldId: field.id,
|
||||||
|
operator: 'is_empty',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{collaborators.map((collaborator) => {
|
||||||
|
const filter: DatabaseViewFilterAttributes = {
|
||||||
|
id: '1',
|
||||||
|
type: 'field',
|
||||||
|
fieldId: field.id,
|
||||||
|
operator: 'is_in',
|
||||||
|
value: [collaborator.value],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BoardViewContext.Provider
|
||||||
|
key={collaborator.value}
|
||||||
|
value={{
|
||||||
|
field,
|
||||||
|
filter,
|
||||||
|
canDrop: () => true,
|
||||||
|
drop: () => {
|
||||||
|
return {
|
||||||
|
type: 'string_array',
|
||||||
|
value: [collaborator.value],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
header: (
|
||||||
|
<BoardViewColumnCollaboratorHeader
|
||||||
|
field={field}
|
||||||
|
collaborator={collaborator.value}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
canDrag: (record) => record.canEdit,
|
||||||
|
onDragEnd: async (record, value) => {
|
||||||
|
if (!value) {
|
||||||
|
const result = await window.colanode.executeMutation({
|
||||||
|
type: 'record.field.value.delete',
|
||||||
|
recordId: record.id,
|
||||||
|
fieldId: field.id,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (value.type !== 'string_array') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newValue: FieldValue = value;
|
||||||
|
const currentValue = record.fields[field.id];
|
||||||
|
if (currentValue && currentValue.type === 'string_array') {
|
||||||
|
const newOptions = [
|
||||||
|
...currentValue.value.filter(
|
||||||
|
(collaboratorId) =>
|
||||||
|
collaboratorId !== collaborator.value
|
||||||
|
),
|
||||||
|
...newValue.value,
|
||||||
|
];
|
||||||
|
|
||||||
|
newValue = {
|
||||||
|
type: 'string_array',
|
||||||
|
value: newOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await window.colanode.executeMutation({
|
||||||
|
type: 'record.field.value.set',
|
||||||
|
recordId: record.id,
|
||||||
|
fieldId: field.id,
|
||||||
|
value: newValue,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BoardViewColumn />
|
||||||
|
</BoardViewContext.Provider>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<BoardViewContext.Provider
|
||||||
|
value={{
|
||||||
|
field,
|
||||||
|
filter: noValueFilter,
|
||||||
|
canDrop: () => true,
|
||||||
|
drop: () => {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
header: (
|
||||||
|
<BoardViewColumnCollaboratorHeader
|
||||||
|
field={field}
|
||||||
|
collaborator={null}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
canDrag: () => true,
|
||||||
|
onDragEnd: async (record, value) => {
|
||||||
|
if (!value) {
|
||||||
|
const result = await window.colanode.executeMutation({
|
||||||
|
type: 'record.field.value.delete',
|
||||||
|
recordId: record.id,
|
||||||
|
fieldId: field.id,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await window.colanode.executeMutation({
|
||||||
|
type: 'record.field.value.set',
|
||||||
|
recordId: record.id,
|
||||||
|
fieldId: field.id,
|
||||||
|
value,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BoardViewColumn />
|
||||||
|
</BoardViewContext.Provider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BoardViewColumnCollaboratorHeaderProps {
|
||||||
|
field: CollaboratorFieldAttributes;
|
||||||
|
collaborator: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BoardViewColumnCollaboratorHeader = ({
|
||||||
|
field,
|
||||||
|
collaborator,
|
||||||
|
}: BoardViewColumnCollaboratorHeaderProps) => {
|
||||||
|
const workspace = useWorkspace();
|
||||||
|
|
||||||
|
const userQuery = useQuery(
|
||||||
|
{
|
||||||
|
type: 'user.get',
|
||||||
|
userId: collaborator ?? '',
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!collaborator,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!collaborator) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<CircleDashed className="size-5" />
|
||||||
|
<p className="text-muted-foreground">No {field.name}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userQuery.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userQuery.data;
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<CircleAlert className="size-5" />
|
||||||
|
<p className="text-muted-foreground">Unknown</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<Avatar
|
||||||
|
id={user.id}
|
||||||
|
name={user.name}
|
||||||
|
avatar={user.avatar}
|
||||||
|
className="size-5"
|
||||||
|
/>
|
||||||
|
<p>{user.name}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { CircleAlert } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CreatedByFieldAttributes,
|
||||||
|
DatabaseViewFilterAttributes,
|
||||||
|
} from '@colanode/core';
|
||||||
|
import { Avatar } from '@colanode/ui/components/avatars/avatar';
|
||||||
|
import { BoardViewColumn } from '@colanode/ui/components/databases/boards/board-view-column';
|
||||||
|
import { Spinner } from '@colanode/ui/components/ui/spinner';
|
||||||
|
import { BoardViewContext } from '@colanode/ui/contexts/board-view';
|
||||||
|
import { useDatabase } from '@colanode/ui/contexts/database';
|
||||||
|
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
|
||||||
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
|
import { useQuery } from '@colanode/ui/hooks/use-query';
|
||||||
|
|
||||||
|
interface BoardViewColumnsCreatedByProps {
|
||||||
|
field: CreatedByFieldAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardViewColumnsCreatedBy = ({
|
||||||
|
field,
|
||||||
|
}: BoardViewColumnsCreatedByProps) => {
|
||||||
|
const workspace = useWorkspace();
|
||||||
|
const database = useDatabase();
|
||||||
|
const view = useDatabaseView();
|
||||||
|
|
||||||
|
const createdByCountQuery = useQuery({
|
||||||
|
type: 'record.field.value.count',
|
||||||
|
databaseId: database.id,
|
||||||
|
filters: view.filters,
|
||||||
|
fieldId: field.id,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createdByCountQuery.isPending) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = createdByCountQuery.data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{users.map((user) => {
|
||||||
|
const filter: DatabaseViewFilterAttributes = {
|
||||||
|
id: '1',
|
||||||
|
type: 'field',
|
||||||
|
fieldId: field.id,
|
||||||
|
operator: 'is_in',
|
||||||
|
value: [user.value],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BoardViewContext.Provider
|
||||||
|
key={user.value}
|
||||||
|
value={{
|
||||||
|
field,
|
||||||
|
filter,
|
||||||
|
canDrop: () => false,
|
||||||
|
drop: () => null,
|
||||||
|
header: (
|
||||||
|
<BoardViewColumnCreatedByHeader
|
||||||
|
field={field}
|
||||||
|
createdBy={user.value}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
canDrag: () => false,
|
||||||
|
onDragEnd: () => {},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BoardViewColumn />
|
||||||
|
</BoardViewContext.Provider>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BoardViewColumnCreatedByHeaderProps {
|
||||||
|
field: CreatedByFieldAttributes;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BoardViewColumnCreatedByHeader = ({
|
||||||
|
createdBy,
|
||||||
|
}: BoardViewColumnCreatedByHeaderProps) => {
|
||||||
|
const workspace = useWorkspace();
|
||||||
|
|
||||||
|
const userQuery = useQuery({
|
||||||
|
type: 'user.get',
|
||||||
|
userId: createdBy,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userQuery.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userQuery.data;
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<CircleAlert className="size-5" />
|
||||||
|
<p className="text-muted-foreground">Unknown</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<Avatar
|
||||||
|
id={user.id}
|
||||||
|
name={user.name}
|
||||||
|
avatar={user.avatar}
|
||||||
|
className="size-5"
|
||||||
|
/>
|
||||||
|
<p>{user.name}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DatabaseViewFilterAttributes,
|
||||||
|
FieldValue,
|
||||||
|
MultiSelectFieldAttributes,
|
||||||
|
} from '@colanode/core';
|
||||||
|
import { BoardViewColumn } from '@colanode/ui/components/databases/boards/board-view-column';
|
||||||
|
import { SelectOptionBadge } from '@colanode/ui/components/databases/fields/select-option-badge';
|
||||||
|
import { BoardViewContext } from '@colanode/ui/contexts/board-view';
|
||||||
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
|
import { getSelectOptionLightColorClass } from '@colanode/ui/lib/databases';
|
||||||
|
|
||||||
|
interface BoardViewColumnsMultiSelectProps {
|
||||||
|
field: MultiSelectFieldAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardViewColumnsMultiSelect = ({
|
||||||
|
field,
|
||||||
|
}: BoardViewColumnsMultiSelectProps) => {
|
||||||
|
const workspace = useWorkspace();
|
||||||
|
const selectOptions = Object.values(field.options ?? {});
|
||||||
|
|
||||||
|
const noValueFilter: DatabaseViewFilterAttributes = {
|
||||||
|
id: '1',
|
||||||
|
type: 'field',
|
||||||
|
fieldId: field.id,
|
||||||
|
operator: 'is_empty',
|
||||||
|
};
|
||||||
|
|
||||||
|
const noValueDraggingClass = getSelectOptionLightColorClass('gray');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selectOptions.map((option) => {
|
||||||
|
const filter: DatabaseViewFilterAttributes = {
|
||||||
|
id: '1',
|
||||||
|
type: 'field',
|
||||||
|
fieldId: field.id,
|
||||||
|
operator: 'is_in',
|
||||||
|
value: [option.id],
|
||||||
|
};
|
||||||
|
|
||||||
|
const draggingClass = getSelectOptionLightColorClass(
|
||||||
|
option.color ?? 'gray'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BoardViewContext.Provider
|
||||||
|
key={option.id}
|
||||||
|
value={{
|
||||||
|
field,
|
||||||
|
filter,
|
||||||
|
canDrop: () => true,
|
||||||
|
drop: () => {
|
||||||
|
return {
|
||||||
|
type: 'string_array',
|
||||||
|
value: [option.id],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
dragOverClass: draggingClass,
|
||||||
|
header: (
|
||||||
|
<SelectOptionBadge name={option.name} color={option.color} />
|
||||||
|
),
|
||||||
|
canDrag: (record) => record.canEdit,
|
||||||
|
onDragEnd: async (record, value) => {
|
||||||
|
if (!value) {
|
||||||
|
const result = await window.colanode.executeMutation({
|
||||||
|
type: 'record.field.value.delete',
|
||||||
|
recordId: record.id,
|
||||||
|
fieldId: field.id,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (value.type !== 'string_array') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newValue: FieldValue = value;
|
||||||
|
const currentValue = record.fields[field.id];
|
||||||
|
if (currentValue && currentValue.type === 'string_array') {
|
||||||
|
const newOptions = [
|
||||||
|
...currentValue.value.filter(
|
||||||
|
(optionId) => optionId !== option.id
|
||||||
|
),
|
||||||
|
...newValue.value,
|
||||||
|
];
|
||||||
|
|
||||||
|
newValue = {
|
||||||
|
type: 'string_array',
|
||||||
|
value: newOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await window.colanode.executeMutation({
|
||||||
|
type: 'record.field.value.set',
|
||||||
|
recordId: record.id,
|
||||||
|
fieldId: field.id,
|
||||||
|
value: newValue,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BoardViewColumn />
|
||||||
|
</BoardViewContext.Provider>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<BoardViewContext.Provider
|
||||||
|
value={{
|
||||||
|
field,
|
||||||
|
filter: noValueFilter,
|
||||||
|
canDrop: () => true,
|
||||||
|
drop: () => {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
header: (
|
||||||
|
<p className="text-sm text-muted-foreground">No {field.name}</p>
|
||||||
|
),
|
||||||
|
dragOverClass: noValueDraggingClass,
|
||||||
|
canDrag: () => true,
|
||||||
|
onDragEnd: async (record, value) => {
|
||||||
|
if (!value) {
|
||||||
|
const result = await window.colanode.executeMutation({
|
||||||
|
type: 'record.field.value.delete',
|
||||||
|
recordId: record.id,
|
||||||
|
fieldId: field.id,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await window.colanode.executeMutation({
|
||||||
|
type: 'record.field.value.set',
|
||||||
|
recordId: record.id,
|
||||||
|
fieldId: field.id,
|
||||||
|
value,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BoardViewColumn />
|
||||||
|
</BoardViewContext.Provider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DatabaseViewFilterAttributes,
|
||||||
|
SelectFieldAttributes,
|
||||||
|
} from '@colanode/core';
|
||||||
|
import { BoardViewColumn } from '@colanode/ui/components/databases/boards/board-view-column';
|
||||||
|
import { SelectOptionBadge } from '@colanode/ui/components/databases/fields/select-option-badge';
|
||||||
|
import { BoardViewContext } from '@colanode/ui/contexts/board-view';
|
||||||
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
|
import { getSelectOptionLightColorClass } from '@colanode/ui/lib/databases';
|
||||||
|
|
||||||
|
interface BoardViewColumnsSelectProps {
|
||||||
|
field: SelectFieldAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardViewColumnsSelect = ({
|
||||||
|
field,
|
||||||
|
}: BoardViewColumnsSelectProps) => {
|
||||||
|
const workspace = useWorkspace();
|
||||||
|
const selectOptions = Object.values(field.options ?? {});
|
||||||
|
|
||||||
|
const noValueFilter: DatabaseViewFilterAttributes = {
|
||||||
|
id: '1',
|
||||||
|
type: 'field',
|
||||||
|
fieldId: field.id,
|
||||||
|
operator: 'is_empty',
|
||||||
|
};
|
||||||
|
|
||||||
|
const noValueDraggingClass = getSelectOptionLightColorClass('gray');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selectOptions.map((option) => {
|
||||||
|
const filter: DatabaseViewFilterAttributes = {
|
||||||
|
id: '1',
|
||||||
|
type: 'field',
|
||||||
|
fieldId: field.id,
|
||||||
|
operator: 'is_in',
|
||||||
|
value: [option.id],
|
||||||
|
};
|
||||||
|
|
||||||
|
const draggingClass = getSelectOptionLightColorClass(
|
||||||
|
option.color ?? 'gray'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BoardViewContext.Provider
|
||||||
|
key={option.id}
|
||||||
|
value={{
|
||||||
|
field,
|
||||||
|
filter,
|
||||||
|
canDrop: () => true,
|
||||||
|
drop: () => {
|
||||||
|
return {
|
||||||
|
type: 'string',
|
||||||
|
value: option.id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
dragOverClass: draggingClass,
|
||||||
|
header: (
|
||||||
|
<SelectOptionBadge name={option.name} color={option.color} />
|
||||||
|
),
|
||||||
|
canDrag: (record) => record.canEdit,
|
||||||
|
onDragEnd: async (record, value) => {
|
||||||
|
if (!value) {
|
||||||
|
const result = await window.colanode.executeMutation({
|
||||||
|
type: 'record.field.value.delete',
|
||||||
|
recordId: record.id,
|
||||||
|
fieldId: field.id,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await window.colanode.executeMutation({
|
||||||
|
type: 'record.field.value.set',
|
||||||
|
recordId: record.id,
|
||||||
|
fieldId: field.id,
|
||||||
|
value,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BoardViewColumn />
|
||||||
|
</BoardViewContext.Provider>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<BoardViewContext.Provider
|
||||||
|
value={{
|
||||||
|
field,
|
||||||
|
filter: noValueFilter,
|
||||||
|
canDrop: () => true,
|
||||||
|
drop: () => {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
header: (
|
||||||
|
<p className="text-sm text-muted-foreground">No {field.name}</p>
|
||||||
|
),
|
||||||
|
dragOverClass: noValueDraggingClass,
|
||||||
|
canDrag: () => true,
|
||||||
|
onDragEnd: async (record, value) => {
|
||||||
|
if (!value) {
|
||||||
|
const result = await window.colanode.executeMutation({
|
||||||
|
type: 'record.field.value.delete',
|
||||||
|
recordId: record.id,
|
||||||
|
fieldId: field.id,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await window.colanode.executeMutation({
|
||||||
|
type: 'record.field.value.set',
|
||||||
|
recordId: record.id,
|
||||||
|
fieldId: field.id,
|
||||||
|
value,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BoardViewColumn />
|
||||||
|
</BoardViewContext.Provider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { FieldAttributes } from '@colanode/core';
|
||||||
|
import { BoardViewColumnsCollaborator } from '@colanode/ui/components/databases/boards/board-view-columns-collaborator';
|
||||||
|
import { BoardViewColumnsCreatedBy } from '@colanode/ui/components/databases/boards/board-view-columns-created-by';
|
||||||
|
import { BoardViewColumnsMultiSelect } from '@colanode/ui/components/databases/boards/board-view-columns-multi-select';
|
||||||
|
import { BoardViewColumnsSelect } from '@colanode/ui/components/databases/boards/board-view-columns-select';
|
||||||
|
|
||||||
|
interface BoardViewColumnsProps {
|
||||||
|
field: FieldAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardViewColumns = ({ field }: BoardViewColumnsProps) => {
|
||||||
|
switch (field.type) {
|
||||||
|
case 'select':
|
||||||
|
return <BoardViewColumnsSelect field={field} />;
|
||||||
|
case 'multi_select':
|
||||||
|
return <BoardViewColumnsMultiSelect field={field} />;
|
||||||
|
case 'collaborator':
|
||||||
|
return <BoardViewColumnsCollaborator field={field} />;
|
||||||
|
case 'created_by':
|
||||||
|
return <BoardViewColumnsCreatedBy field={field} />;
|
||||||
|
default:
|
||||||
|
return <p>Unsupported field type</p>;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { FieldType } from '@colanode/core';
|
||||||
|
import { FieldCreatePopover } from '@colanode/ui/components/databases/fields/field-create-popover';
|
||||||
|
import { FieldSelect } from '@colanode/ui/components/databases/fields/field-select';
|
||||||
|
import { Button } from '@colanode/ui/components/ui/button';
|
||||||
|
import { useDatabase } from '@colanode/ui/contexts/database';
|
||||||
|
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
|
||||||
|
|
||||||
|
const boardGroupFields: FieldType[] = [
|
||||||
|
'select',
|
||||||
|
'multi_select',
|
||||||
|
'collaborator',
|
||||||
|
'created_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BoardViewNoGroup = () => {
|
||||||
|
const database = useDatabase();
|
||||||
|
const view = useDatabaseView();
|
||||||
|
|
||||||
|
const possibleGroupByFields = database.fields.filter((field) =>
|
||||||
|
boardGroupFields.includes(field.type)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col items-center justify-center pt-20">
|
||||||
|
{possibleGroupByFields.length > 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<p className="text-sm">
|
||||||
|
Please select a group you want to group the board by.
|
||||||
|
</p>
|
||||||
|
<div className="w-90">
|
||||||
|
<FieldSelect
|
||||||
|
fields={possibleGroupByFields}
|
||||||
|
value={view.groupBy ?? null}
|
||||||
|
onChange={(fieldId) => {
|
||||||
|
view.setGroupBy(fieldId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<p className="text-sm">
|
||||||
|
There is no field that can be used to group the board by. Please
|
||||||
|
create a new field that can be used to group the board by.
|
||||||
|
</p>
|
||||||
|
<FieldCreatePopover
|
||||||
|
button={
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Add field
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
types={boardGroupFields}
|
||||||
|
onSuccess={(fieldId) => {
|
||||||
|
view.setGroupBy(fieldId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,43 +1,26 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useDrag } from 'react-dnd';
|
import { useDrag } from 'react-dnd';
|
||||||
|
|
||||||
import { SelectFieldAttributes, SelectOptionAttributes } from '@colanode/core';
|
import { FieldValue } from '@colanode/core';
|
||||||
import { RecordFieldValue } from '@colanode/ui/components/records/record-field-value';
|
import { RecordFieldValue } from '@colanode/ui/components/records/record-field-value';
|
||||||
|
import { useBoardView } from '@colanode/ui/contexts/board-view';
|
||||||
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
|
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
|
||||||
import { useLayout } from '@colanode/ui/contexts/layout';
|
import { useLayout } from '@colanode/ui/contexts/layout';
|
||||||
import { useRecord } from '@colanode/ui/contexts/record';
|
import { useRecord } from '@colanode/ui/contexts/record';
|
||||||
|
|
||||||
interface DragResult {
|
|
||||||
option: SelectOptionAttributes;
|
|
||||||
field: SelectFieldAttributes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BoardViewRecordCard = () => {
|
export const BoardViewRecordCard = () => {
|
||||||
const layout = useLayout();
|
const layout = useLayout();
|
||||||
const view = useDatabaseView();
|
const view = useDatabaseView();
|
||||||
|
const boardView = useBoardView();
|
||||||
const record = useRecord();
|
const record = useRecord();
|
||||||
|
|
||||||
const [, drag] = useDrag({
|
const [, drag] = useDrag({
|
||||||
type: 'board-record',
|
type: 'board-record',
|
||||||
item: { id: record.id },
|
canDrag: () => boardView.canDrag(record),
|
||||||
canDrag: () => {
|
item: record,
|
||||||
return record.canEdit;
|
end: (item, monitor) => {
|
||||||
},
|
const value = monitor.getDropResult() as { value: FieldValue | null };
|
||||||
end: (_, monitor) => {
|
return boardView.onDragEnd(item, value.value);
|
||||||
const dropResult = monitor.getDropResult<DragResult>();
|
|
||||||
if (dropResult != null) {
|
|
||||||
const optionId = dropResult.option.id;
|
|
||||||
const currentFieldValue = record.getSelectValue(dropResult.field);
|
|
||||||
|
|
||||||
if (currentFieldValue === optionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
record.updateFieldValue(dropResult.field, {
|
|
||||||
type: 'string',
|
|
||||||
value: optionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +31,7 @@ export const BoardViewRecordCard = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={dragRef as React.LegacyRef<HTMLDivElement>}
|
ref={dragRef as React.Ref<HTMLDivElement>}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
key={record.id}
|
key={record.id}
|
||||||
className="animate-fade-in flex cursor-pointer flex-col gap-1 rounded-md border p-2 text-left hover:bg-gray-50"
|
className="animate-fade-in flex cursor-pointer flex-col gap-1 rounded-md border p-2 text-left hover:bg-gray-50"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const BoardViewSettings = () => {
|
|||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<ViewSettingsButton />
|
<ViewSettingsButton />
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="mr-4 flex w-[600px] flex-col gap-1.5 p-2">
|
<PopoverContent className="mr-4 flex w-90 flex-col gap-1.5 p-2">
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
{database.canEdit ? (
|
{database.canEdit ? (
|
||||||
<AvatarPopover
|
<AvatarPopover
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
|
|
||||||
import { BoardViewColumn } from '@colanode/ui/components/databases/boards/board-view-column';
|
import { BoardViewColumns } from '@colanode/ui/components/databases/boards/board-view-columns';
|
||||||
|
import { BoardViewNoGroup } from '@colanode/ui/components/databases/boards/board-view-no-group';
|
||||||
import { BoardViewSettings } from '@colanode/ui/components/databases/boards/board-view-settings';
|
import { BoardViewSettings } from '@colanode/ui/components/databases/boards/board-view-settings';
|
||||||
import { ViewFilterButton } from '@colanode/ui/components/databases/search/view-filter-button';
|
import { ViewFilterButton } from '@colanode/ui/components/databases/search/view-filter-button';
|
||||||
import { ViewSearchBar } from '@colanode/ui/components/databases/search/view-search-bar';
|
import { ViewSearchBar } from '@colanode/ui/components/databases/search/view-search-bar';
|
||||||
@@ -17,11 +18,6 @@ export const BoardView = () => {
|
|||||||
(field) => field.id === view.groupBy
|
(field) => field.id === view.groupBy
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectOptions =
|
|
||||||
groupByField && groupByField.type === 'select'
|
|
||||||
? Object.values(groupByField.options ?? {})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="flex flex-row justify-between border-b">
|
<div className="flex flex-row justify-between border-b">
|
||||||
@@ -34,17 +30,11 @@ export const BoardView = () => {
|
|||||||
</div>
|
</div>
|
||||||
<ViewSearchBar />
|
<ViewSearchBar />
|
||||||
<div className="mt-2 flex w-full min-w-full max-w-full flex-row gap-2 overflow-auto pr-5">
|
<div className="mt-2 flex w-full min-w-full max-w-full flex-row gap-2 overflow-auto pr-5">
|
||||||
{groupByField &&
|
{groupByField ? (
|
||||||
groupByField.type === 'select' &&
|
<BoardViewColumns field={groupByField} />
|
||||||
selectOptions.map((option) => {
|
) : (
|
||||||
return (
|
<BoardViewNoGroup />
|
||||||
<BoardViewColumn
|
)}
|
||||||
key={option.id}
|
|
||||||
field={groupByField}
|
|
||||||
option={option}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { FieldType } from '@colanode/core';
|
||||||
|
import { FieldCreatePopover } from '@colanode/ui/components/databases/fields/field-create-popover';
|
||||||
|
import { FieldSelect } from '@colanode/ui/components/databases/fields/field-select';
|
||||||
|
import { Button } from '@colanode/ui/components/ui/button';
|
||||||
|
import { useDatabase } from '@colanode/ui/contexts/database';
|
||||||
|
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
|
||||||
|
|
||||||
|
const calendarGroupFields: FieldType[] = ['date', 'created_at', 'updated_at'];
|
||||||
|
|
||||||
|
export const CalendarViewNoGroup = () => {
|
||||||
|
const database = useDatabase();
|
||||||
|
const view = useDatabaseView();
|
||||||
|
|
||||||
|
const possibleGroupByFields = database.fields.filter((field) =>
|
||||||
|
calendarGroupFields.includes(field.type)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col items-center justify-center pt-20">
|
||||||
|
{possibleGroupByFields.length > 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<p className="text-sm">
|
||||||
|
Please select a group you want to group the calendar by.
|
||||||
|
</p>
|
||||||
|
<div className="w-90">
|
||||||
|
<FieldSelect
|
||||||
|
fields={possibleGroupByFields}
|
||||||
|
value={view.groupBy ?? null}
|
||||||
|
onChange={(fieldId) => {
|
||||||
|
view.setGroupBy(fieldId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<p className="text-sm">
|
||||||
|
There is no field that can be used to group the calendar by. Please
|
||||||
|
create a new field that can be used to group the calendar by.
|
||||||
|
</p>
|
||||||
|
<FieldCreatePopover
|
||||||
|
button={
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Add field
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
types={calendarGroupFields}
|
||||||
|
onSuccess={(fieldId) => {
|
||||||
|
view.setGroupBy(fieldId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -38,7 +38,7 @@ export const CalendarViewSettings = () => {
|
|||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<ViewSettingsButton />
|
<ViewSettingsButton />
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="mr-4 flex w-[600px] flex-col gap-1.5 p-2">
|
<PopoverContent className="mr-4 flex w-90 flex-col gap-1.5 p-2">
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
{database.canEdit ? (
|
{database.canEdit ? (
|
||||||
<AvatarPopover
|
<AvatarPopover
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
|
|
||||||
import { CalendarViewGrid } from '@colanode/ui/components/databases/calendars/calendar-view-grid';
|
import { CalendarViewGrid } from '@colanode/ui/components/databases/calendars/calendar-view-grid';
|
||||||
|
import { CalendarViewNoGroup } from '@colanode/ui/components/databases/calendars/calendar-view-no-group';
|
||||||
import { CalendarViewSettings } from '@colanode/ui/components/databases/calendars/calendar-view-settings';
|
import { CalendarViewSettings } from '@colanode/ui/components/databases/calendars/calendar-view-settings';
|
||||||
import { ViewFilterButton } from '@colanode/ui/components/databases/search/view-filter-button';
|
import { ViewFilterButton } from '@colanode/ui/components/databases/search/view-filter-button';
|
||||||
import { ViewSearchBar } from '@colanode/ui/components/databases/search/view-search-bar';
|
import { ViewSearchBar } from '@colanode/ui/components/databases/search/view-search-bar';
|
||||||
@@ -17,10 +18,6 @@ export const CalendarView = () => {
|
|||||||
(field) => field.id === view.groupBy
|
(field) => field.id === view.groupBy
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!groupByField) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="flex flex-row justify-between border-b">
|
<div className="flex flex-row justify-between border-b">
|
||||||
@@ -33,7 +30,11 @@ export const CalendarView = () => {
|
|||||||
</div>
|
</div>
|
||||||
<ViewSearchBar />
|
<ViewSearchBar />
|
||||||
<div className="mt-2 w-full min-w-full max-w-full overflow-auto pr-5">
|
<div className="mt-2 w-full min-w-full max-w-full overflow-auto pr-5">
|
||||||
<CalendarViewGrid field={groupByField} />
|
{groupByField ? (
|
||||||
|
<CalendarViewGrid field={groupByField} />
|
||||||
|
) : (
|
||||||
|
<CalendarViewNoGroup />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Plus } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
|
import { FieldType } from '@colanode/core';
|
||||||
import { DatabaseSelect } from '@colanode/ui/components/databases/database-select';
|
import { DatabaseSelect } from '@colanode/ui/components/databases/database-select';
|
||||||
import { FieldTypeSelect } from '@colanode/ui/components/databases/fields/field-type-select';
|
import { FieldTypeSelect } from '@colanode/ui/components/databases/fields/field-type-select';
|
||||||
import { Button } from '@colanode/ui/components/ui/button';
|
import { Button } from '@colanode/ui/components/ui/button';
|
||||||
@@ -28,7 +28,7 @@ import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
|||||||
import { useMutation } from '@colanode/ui/hooks/use-mutation';
|
import { useMutation } from '@colanode/ui/hooks/use-mutation';
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string().min(1, { message: 'Name is required' }),
|
||||||
type: z.union([
|
type: z.union([
|
||||||
z.literal('boolean'),
|
z.literal('boolean'),
|
||||||
z.literal('collaborator'),
|
z.literal('collaborator'),
|
||||||
@@ -50,7 +50,17 @@ const formSchema = z.object({
|
|||||||
relationDatabaseId: z.string().optional().nullable(),
|
relationDatabaseId: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const FieldCreatePopover = () => {
|
interface FieldCreatePopoverProps {
|
||||||
|
button: React.ReactNode;
|
||||||
|
onSuccess?: (fieldId: string) => void;
|
||||||
|
types?: FieldType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FieldCreatePopover = ({
|
||||||
|
button,
|
||||||
|
onSuccess,
|
||||||
|
types,
|
||||||
|
}: FieldCreatePopoverProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const workspace = useWorkspace();
|
const workspace = useWorkspace();
|
||||||
const database = useDatabase();
|
const database = useDatabase();
|
||||||
@@ -83,9 +93,10 @@ export const FieldCreatePopover = () => {
|
|||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
relationDatabaseId: values.relationDatabaseId,
|
relationDatabaseId: values.relationDatabaseId,
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (output) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
|
onSuccess?.(output.id);
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
@@ -99,9 +110,7 @@ export const FieldCreatePopover = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen} modal={true}>
|
<Popover open={open} onOpenChange={setOpen} modal={true}>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger asChild>{button}</PopoverTrigger>
|
||||||
<Plus className="ml-2 size-4 cursor-pointer" />
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="mr-5 w-128" side="bottom">
|
<PopoverContent className="mr-5 w-128" side="bottom">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
@@ -130,8 +139,9 @@ export const FieldCreatePopover = () => {
|
|||||||
<FormLabel>Field type</FormLabel>
|
<FormLabel>Field type</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FieldTypeSelect
|
<FieldTypeSelect
|
||||||
type={field.value}
|
value={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
|
types={types}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -88,12 +88,20 @@ const fieldTypes: FieldTypeOption[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface FieldTypeSelectProps {
|
interface FieldTypeSelectProps {
|
||||||
type: string | null;
|
value: string | null;
|
||||||
onChange: (type: FieldType) => void;
|
onChange: (type: FieldType) => void;
|
||||||
|
types?: FieldType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FieldTypeSelect = ({ type, onChange }: FieldTypeSelectProps) => {
|
export const FieldTypeSelect = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
types,
|
||||||
|
}: FieldTypeSelectProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const filteredFieldTypes = fieldTypes.filter((fieldType) =>
|
||||||
|
types ? types.includes(fieldType.type) : true
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen} modal={true}>
|
<Popover open={open} onOpenChange={setOpen} modal={true}>
|
||||||
@@ -106,9 +114,9 @@ export const FieldTypeSelect = ({ type, onChange }: FieldTypeSelectProps) => {
|
|||||||
className="w-full justify-between p-2"
|
className="w-full justify-between p-2"
|
||||||
>
|
>
|
||||||
<span className="flex flex-row items-center gap-1">
|
<span className="flex flex-row items-center gap-1">
|
||||||
<FieldIcon type={type as FieldType} className="size-4" />
|
<FieldIcon type={value as FieldType} className="size-4" />
|
||||||
{type
|
{value
|
||||||
? fieldTypes.find((fieldType) => fieldType.type === type)?.name
|
? fieldTypes.find((fieldType) => fieldType.type === value)?.name
|
||||||
: 'Select field type...'}
|
: 'Select field type...'}
|
||||||
</span>
|
</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
@@ -120,7 +128,7 @@ export const FieldTypeSelect = ({ type, onChange }: FieldTypeSelectProps) => {
|
|||||||
<CommandEmpty>No field type found.</CommandEmpty>
|
<CommandEmpty>No field type found.</CommandEmpty>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandGroup className="h-min overflow-y-auto">
|
<CommandGroup className="h-min overflow-y-auto">
|
||||||
{fieldTypes.map((fieldType) => (
|
{filteredFieldTypes.map((fieldType) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={fieldType.type}
|
key={fieldType.type}
|
||||||
value={`${fieldType.type} - ${fieldType.name}`}
|
value={`${fieldType.type} - ${fieldType.name}`}
|
||||||
@@ -138,7 +146,7 @@ export const FieldTypeSelect = ({ type, onChange }: FieldTypeSelectProps) => {
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-auto size-4',
|
'ml-auto size-4',
|
||||||
type === fieldType.type ? 'opacity-100' : 'opacity-0'
|
value === fieldType.type ? 'opacity-100' : 'opacity-0'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { FieldCreatePopover } from '@colanode/ui/components/databases/fields/field-create-popover';
|
import { FieldCreatePopover } from '@colanode/ui/components/databases/fields/field-create-popover';
|
||||||
import { TableViewFieldHeader } from '@colanode/ui/components/databases/tables/table-view-field-header';
|
import { TableViewFieldHeader } from '@colanode/ui/components/databases/tables/table-view-field-header';
|
||||||
import { TableViewNameHeader } from '@colanode/ui/components/databases/tables/table-view-name-header';
|
import { TableViewNameHeader } from '@colanode/ui/components/databases/tables/table-view-name-header';
|
||||||
@@ -15,7 +17,11 @@ export const TableViewHeader = () => {
|
|||||||
{view.fields.map((field) => {
|
{view.fields.map((field) => {
|
||||||
return <TableViewFieldHeader viewField={field} key={field.field.id} />;
|
return <TableViewFieldHeader viewField={field} key={field.field.id} />;
|
||||||
})}
|
})}
|
||||||
{database.canEdit && <FieldCreatePopover />}
|
{database.canEdit && (
|
||||||
|
<FieldCreatePopover
|
||||||
|
button={<Plus className="ml-2 size-4 cursor-pointer" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const TableViewSettings = () => {
|
|||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<ViewSettingsButton />
|
<ViewSettingsButton />
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="mr-4 flex w-[600px] flex-col gap-1.5 p-2">
|
<PopoverContent className="mr-4 flex w-90 flex-col gap-1.5 p-2">
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
{database.canEdit ? (
|
{database.canEdit ? (
|
||||||
<AvatarPopover
|
<AvatarPopover
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
import { FieldAttributes, FieldType } from '@colanode/core';
|
|
||||||
import { FieldSelect } from '@colanode/ui/components/databases/fields/field-select';
|
|
||||||
import { Button } from '@colanode/ui/components/ui/button';
|
import { Button } from '@colanode/ui/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -32,9 +30,8 @@ import { useMutation } from '@colanode/ui/hooks/use-mutation';
|
|||||||
import { cn } from '@colanode/ui/lib/utils';
|
import { cn } from '@colanode/ui/lib/utils';
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(3, 'Name must be at least 3 characters long.'),
|
name: z.string(),
|
||||||
type: z.enum(['table', 'board', 'calendar']),
|
type: z.enum(['table', 'board', 'calendar']),
|
||||||
groupBy: z.string().optional().nullable(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ViewTypeOption {
|
interface ViewTypeOption {
|
||||||
@@ -61,9 +58,6 @@ const viewTypes: ViewTypeOption[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const boardGroupFields: FieldType[] = ['select'];
|
|
||||||
const calendarGroupFields: FieldType[] = ['date', 'created_at'];
|
|
||||||
|
|
||||||
interface ViewCreateDialogProps {
|
interface ViewCreateDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -84,18 +78,6 @@ export const ViewCreateDialog = ({
|
|||||||
type: 'table',
|
type: 'table',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const type = form.watch('type');
|
|
||||||
|
|
||||||
let groupByFields: FieldAttributes[] | null = null;
|
|
||||||
if (type === 'board') {
|
|
||||||
groupByFields = database.fields.filter((field) =>
|
|
||||||
boardGroupFields.includes(field.type)
|
|
||||||
);
|
|
||||||
} else if (type === 'calendar') {
|
|
||||||
groupByFields = database.fields.filter((field) =>
|
|
||||||
calendarGroupFields.includes(field.type)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -107,31 +89,24 @@ export const ViewCreateDialog = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.type === 'board') {
|
const type = viewTypes.find((viewType) => viewType.type === values.type);
|
||||||
if (!values.groupBy) {
|
if (!type) {
|
||||||
toast.error(
|
return;
|
||||||
'You need to specify a group by field to create a board view'
|
}
|
||||||
);
|
|
||||||
return;
|
let name = values.name;
|
||||||
}
|
if (name === '') {
|
||||||
} else if (values.type === 'calendar') {
|
name = type.name;
|
||||||
if (!values.groupBy) {
|
|
||||||
toast.error(
|
|
||||||
'You need to specify a group by field to create a calendar view'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mutate({
|
mutate({
|
||||||
input: {
|
input: {
|
||||||
type: 'view.create',
|
type: 'view.create',
|
||||||
viewType: values.type,
|
viewType: type.type,
|
||||||
databaseId: database.id,
|
databaseId: database.id,
|
||||||
name: values.name,
|
name: name,
|
||||||
accountId: workspace.accountId,
|
accountId: workspace.accountId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
groupBy: values.groupBy ?? null,
|
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -193,7 +168,6 @@ export const ViewCreateDialog = ({
|
|||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
field.onChange(viewType.type);
|
field.onChange(viewType.type);
|
||||||
form.setValue('groupBy', null);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<viewType.icon />
|
<viewType.icon />
|
||||||
@@ -203,25 +177,6 @@ export const ViewCreateDialog = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{groupByFields && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="groupBy"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Group by</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<FieldSelect
|
|
||||||
fields={groupByFields}
|
|
||||||
value={field.value ?? null}
|
|
||||||
onChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
|
|||||||
@@ -192,6 +192,26 @@ export const View = ({ view }: ViewProps) => {
|
|||||||
toast.error(result.error.message);
|
toast.error(result.error.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setGroupBy: async (fieldId: string | null) => {
|
||||||
|
if (!database.canEdit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewAttributes = { ...view.attributes };
|
||||||
|
viewAttributes.groupBy = fieldId;
|
||||||
|
|
||||||
|
const result = await window.colanode.executeMutation({
|
||||||
|
type: 'view.update',
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
viewId: view.id,
|
||||||
|
view: viewAttributes,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
moveField: async (id: string, after: string) => {
|
moveField: async (id: string, after: string) => {
|
||||||
if (!database.canEdit) {
|
if (!database.canEdit) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
31
packages/ui/src/contexts/board-view.ts
Normal file
31
packages/ui/src/contexts/board-view.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DatabaseViewFilterAttributes,
|
||||||
|
FieldAttributes,
|
||||||
|
FieldValue,
|
||||||
|
} from '@colanode/core';
|
||||||
|
|
||||||
|
interface RecordItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fields: Record<string, FieldValue>;
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoardViewContext {
|
||||||
|
field: FieldAttributes;
|
||||||
|
filter: DatabaseViewFilterAttributes;
|
||||||
|
canDrop: (record: RecordItem) => boolean;
|
||||||
|
drop: (record: RecordItem) => FieldValue | null;
|
||||||
|
dragOverClass?: string;
|
||||||
|
header: React.ReactNode;
|
||||||
|
canDrag: (record: RecordItem) => boolean;
|
||||||
|
onDragEnd: (item: RecordItem, value: FieldValue | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardViewContext = createContext<BoardViewContext>(
|
||||||
|
{} as BoardViewContext
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useBoardView = () => useContext(BoardViewContext);
|
||||||
@@ -25,6 +25,7 @@ interface DatabaseViewContext {
|
|||||||
setFieldDisplay: (id: string, display: boolean) => void;
|
setFieldDisplay: (id: string, display: boolean) => void;
|
||||||
resizeField: (id: string, width: number) => void;
|
resizeField: (id: string, width: number) => void;
|
||||||
resizeName: (width: number) => void;
|
resizeName: (width: number) => void;
|
||||||
|
setGroupBy: (fieldId: string | null) => void;
|
||||||
moveField: (id: string, after: string) => void;
|
moveField: (id: string, after: string) => void;
|
||||||
isFieldFilterOpened: (fieldId: string) => boolean;
|
isFieldFilterOpened: (fieldId: string) => boolean;
|
||||||
initFieldFilter: (fieldId: string) => void;
|
initFieldFilter: (fieldId: string) => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user