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:
Hakan Shehu
2025-07-08 23:47:30 +02:00
committed by GitHub
parent dc1f5b6a2c
commit 4469301f38
31 changed files with 2076 additions and 797 deletions

View File

@@ -48,7 +48,6 @@ export class ViewCreateMutationHandler
index: generateFractionalIndex(maxIndex, null),
layout: input.viewType,
parentId: input.databaseId,
groupBy: input.groupBy,
};
await workspace.nodes.createNode({

View File

@@ -32,6 +32,7 @@ import { NodeGetQueryHandler } from './nodes/node-get';
import { NodeReactionsListQueryHandler } from './nodes/node-reaction-list';
import { NodeReactionsAggregateQueryHandler } from './nodes/node-reactions-aggregate';
import { NodeTreeGetQueryHandler } from './nodes/node-tree-get';
import { RecordFieldValueCountQueryHandler } from './records/record-field-value-count';
import { RecordListQueryHandler } from './records/record-list';
import { RecordSearchQueryHandler } from './records/record-search';
import { ServerListQueryHandler } from './servers/server-list';
@@ -58,6 +59,7 @@ export const buildQueryHandlerMap = (app: AppService): QueryHandlerMap => {
'node.get': new NodeGetQueryHandler(app),
'node.tree.get': new NodeTreeGetQueryHandler(app),
'record.list': new RecordListQueryHandler(app),
'record.field.value.count': new RecordFieldValueCountQueryHandler(app),
'server.list': new ServerListQueryHandler(app),
'user.search': new UserSearchQueryHandler(app),
'workspace.list': new WorkspaceListQueryHandler(app),

View File

@@ -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;
}
}

View File

@@ -3,43 +3,15 @@ import { sql } from 'kysely';
import { SelectNode } from '@colanode/client/databases/workspace';
import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base';
import { mapNode } from '@colanode/client/lib/mappers';
import {
buildFiltersQuery,
buildSortOrdersQuery,
} from '@colanode/client/lib/records';
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
import { RecordListQueryInput } from '@colanode/client/queries/records/record-list';
import { Event } from '@colanode/client/types/events';
import { LocalRecordNode } from '@colanode/client/types/nodes';
import {
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';
import { DatabaseNode } from '@colanode/core';
export class RecordListQueryHandler
extends WorkspaceQueryHandlerBase
@@ -162,13 +134,13 @@ export class RecordListQueryHandler
input: RecordListQueryInput
): Promise<SelectNode[]> {
const database = await this.fetchDatabase(input);
const filterQuery = this.buildFiltersQuery(
const filterQuery = buildFiltersQuery(
input.filters,
database.attributes.fields
);
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 query = sql<SelectNode>`
SELECT n.*
@@ -201,582 +173,4 @@ export class RecordListQueryHandler
const database = mapNode(row) as DatabaseNode;
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}`;
};
}

View 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}`;
};

View File

@@ -5,7 +5,6 @@ export type ViewCreateMutationInput = {
databaseId: string;
viewType: 'table' | 'board' | 'calendar';
name: string;
groupBy: string | null | undefined;
};
export type ViewCreateMutationOutput = {

View File

@@ -38,6 +38,7 @@ export * from './workspaces/workspace-get';
export * from './workspaces/workspace-list';
export * from './workspaces/workspace-metadata-list';
export * from './avatars/avatar-url-get';
export * from './records/record-field-value-count';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface QueryMap {}

View File

@@ -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;
};
}
}

View File

@@ -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>
);
};

View File

@@ -1,39 +1,23 @@
import {
extractNodeRole,
SelectFieldAttributes,
SelectOptionAttributes,
DatabaseViewFilterAttributes,
} from '@colanode/core';
import { extractNodeRole, DatabaseViewFilterAttributes } from '@colanode/core';
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 { RecordProvider } from '@colanode/ui/components/records/record-provider';
import { useBoardView } 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 { useRecordsQuery } from '@colanode/ui/hooks/use-records-query';
interface BoardViewColumnRecordsProps {
field: SelectFieldAttributes;
option: SelectOptionAttributes;
}
export const BoardViewColumnRecords = ({
field,
option,
}: BoardViewColumnRecordsProps) => {
export const BoardViewColumnRecords = () => {
const workspace = useWorkspace();
const database = useDatabase();
const view = useDatabaseView();
const boardView = useBoardView();
const filter: DatabaseViewFilterAttributes = {
id: '1',
type: 'field',
fieldId: field.id,
operator: 'is_in',
value: [option.id],
};
const filters: DatabaseViewFilterAttributes[] = [...view.filters, filter];
const filters: DatabaseViewFilterAttributes[] = [
...view.filters,
boardView.filter,
];
const { records } = useRecordsQuery(filters, view.sorts);
return (

View File

@@ -1,45 +1,43 @@
import { useRef } from 'react';
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 { getSelectOptionLightColorClass } from '@colanode/ui/lib/databases';
import { useBoardView } from '@colanode/ui/contexts/board-view';
import { cn } from '@colanode/ui/lib/utils';
interface BoardViewColumnProps {
field: SelectFieldAttributes;
option: SelectOptionAttributes;
}
export const BoardViewColumn = () => {
const boardView = useBoardView();
export const BoardViewColumn = ({ field, option }: BoardViewColumnProps) => {
const [{ isDragging }, drop] = useDrop({
const [{ isOver }, drop] = useDrop({
accept: 'board-record',
drop: () => ({
option: option,
field: field,
}),
drop: (item) => {
const value = boardView.drop(item);
return {
value,
};
},
collect: (monitor) => ({
isDragging: monitor.isOver(),
isOver: monitor.isOver(),
}),
canDrop: boardView.canDrop,
});
const divRef = useRef<HTMLDivElement>(null);
const dropRef = drop(divRef);
const dragOverClass = boardView.dragOverClass ?? 'bg-gray-50';
const lightClass = getSelectOptionLightColorClass(option.color ?? 'gray');
return (
<div
ref={dropRef as React.LegacyRef<HTMLDivElement>}
className={cn('min-h-[400px] border-r p-1', isDragging && lightClass)}
ref={dropRef as React.Ref<HTMLDivElement>}
className={cn('min-h-[400px] border-r p-1', isOver ? dragOverClass : '')}
style={{
minWidth: '250px',
maxWidth: '250px',
width: '250px',
}}
>
<BoardViewColumnHeader option={option} />
<BoardViewColumnRecords field={field} option={option} />
<div className="flex flex-row items-center gap-2">{boardView.header}</div>
<BoardViewColumnRecords />
</div>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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>;
}
};

View File

@@ -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>
);
};

View File

@@ -1,43 +1,26 @@
import { useRef } from 'react';
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 { useBoardView } from '@colanode/ui/contexts/board-view';
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
import { useLayout } from '@colanode/ui/contexts/layout';
import { useRecord } from '@colanode/ui/contexts/record';
interface DragResult {
option: SelectOptionAttributes;
field: SelectFieldAttributes;
}
export const BoardViewRecordCard = () => {
const layout = useLayout();
const view = useDatabaseView();
const boardView = useBoardView();
const record = useRecord();
const [, drag] = useDrag({
type: 'board-record',
item: { id: record.id },
canDrag: () => {
return record.canEdit;
},
end: (_, monitor) => {
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,
});
}
canDrag: () => boardView.canDrag(record),
item: record,
end: (item, monitor) => {
const value = monitor.getDropResult() as { value: FieldValue | null };
return boardView.onDragEnd(item, value.value);
},
});
@@ -48,7 +31,7 @@ export const BoardViewRecordCard = () => {
return (
<div
ref={dragRef as React.LegacyRef<HTMLDivElement>}
ref={dragRef as React.Ref<HTMLDivElement>}
role="presentation"
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"

View File

@@ -38,7 +38,7 @@ export const BoardViewSettings = () => {
<PopoverTrigger>
<ViewSettingsButton />
</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">
{database.canEdit ? (
<AvatarPopover

View File

@@ -1,6 +1,7 @@
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 { ViewFilterButton } from '@colanode/ui/components/databases/search/view-filter-button';
import { ViewSearchBar } from '@colanode/ui/components/databases/search/view-search-bar';
@@ -17,11 +18,6 @@ export const BoardView = () => {
(field) => field.id === view.groupBy
);
const selectOptions =
groupByField && groupByField.type === 'select'
? Object.values(groupByField.options ?? {})
: [];
return (
<Fragment>
<div className="flex flex-row justify-between border-b">
@@ -34,17 +30,11 @@ export const BoardView = () => {
</div>
<ViewSearchBar />
<div className="mt-2 flex w-full min-w-full max-w-full flex-row gap-2 overflow-auto pr-5">
{groupByField &&
groupByField.type === 'select' &&
selectOptions.map((option) => {
return (
<BoardViewColumn
key={option.id}
field={groupByField}
option={option}
/>
);
})}
{groupByField ? (
<BoardViewColumns field={groupByField} />
) : (
<BoardViewNoGroup />
)}
</div>
</Fragment>
);

View File

@@ -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>
);
};

View File

@@ -38,7 +38,7 @@ export const CalendarViewSettings = () => {
<PopoverTrigger>
<ViewSettingsButton />
</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">
{database.canEdit ? (
<AvatarPopover

View File

@@ -1,6 +1,7 @@
import { Fragment } from 'react';
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 { ViewFilterButton } from '@colanode/ui/components/databases/search/view-filter-button';
import { ViewSearchBar } from '@colanode/ui/components/databases/search/view-search-bar';
@@ -17,10 +18,6 @@ export const CalendarView = () => {
(field) => field.id === view.groupBy
);
if (!groupByField) {
return null;
}
return (
<Fragment>
<div className="flex flex-row justify-between border-b">
@@ -33,7 +30,11 @@ export const CalendarView = () => {
</div>
<ViewSearchBar />
<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>
</Fragment>
);

View File

@@ -1,10 +1,10 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod/v4';
import { FieldType } from '@colanode/core';
import { DatabaseSelect } from '@colanode/ui/components/databases/database-select';
import { FieldTypeSelect } from '@colanode/ui/components/databases/fields/field-type-select';
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';
const formSchema = z.object({
name: z.string(),
name: z.string().min(1, { message: 'Name is required' }),
type: z.union([
z.literal('boolean'),
z.literal('collaborator'),
@@ -50,7 +50,17 @@ const formSchema = z.object({
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 workspace = useWorkspace();
const database = useDatabase();
@@ -83,9 +93,10 @@ export const FieldCreatePopover = () => {
workspaceId: workspace.id,
relationDatabaseId: values.relationDatabaseId,
},
onSuccess: () => {
onSuccess: (output) => {
setOpen(false);
form.reset();
onSuccess?.(output.id);
},
onError(error) {
toast.error(error.message);
@@ -99,9 +110,7 @@ export const FieldCreatePopover = () => {
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger>
<Plus className="ml-2 size-4 cursor-pointer" />
</PopoverTrigger>
<PopoverTrigger asChild>{button}</PopoverTrigger>
<PopoverContent className="mr-5 w-128" side="bottom">
<Form {...form}>
<form
@@ -130,8 +139,9 @@ export const FieldCreatePopover = () => {
<FormLabel>Field type</FormLabel>
<FormControl>
<FieldTypeSelect
type={field.value}
value={field.value}
onChange={field.onChange}
types={types}
/>
</FormControl>
</FormItem>

View File

@@ -88,12 +88,20 @@ const fieldTypes: FieldTypeOption[] = [
];
interface FieldTypeSelectProps {
type: string | null;
value: string | null;
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 filteredFieldTypes = fieldTypes.filter((fieldType) =>
types ? types.includes(fieldType.type) : true
);
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
@@ -106,9 +114,9 @@ export const FieldTypeSelect = ({ type, onChange }: FieldTypeSelectProps) => {
className="w-full justify-between p-2"
>
<span className="flex flex-row items-center gap-1">
<FieldIcon type={type as FieldType} className="size-4" />
{type
? fieldTypes.find((fieldType) => fieldType.type === type)?.name
<FieldIcon type={value as FieldType} className="size-4" />
{value
? fieldTypes.find((fieldType) => fieldType.type === value)?.name
: 'Select field type...'}
</span>
<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>
<CommandList>
<CommandGroup className="h-min overflow-y-auto">
{fieldTypes.map((fieldType) => (
{filteredFieldTypes.map((fieldType) => (
<CommandItem
key={fieldType.type}
value={`${fieldType.type} - ${fieldType.name}`}
@@ -138,7 +146,7 @@ export const FieldTypeSelect = ({ type, onChange }: FieldTypeSelectProps) => {
<Check
className={cn(
'ml-auto size-4',
type === fieldType.type ? 'opacity-100' : 'opacity-0'
value === fieldType.type ? 'opacity-100' : 'opacity-0'
)}
/>
</div>

View File

@@ -1,3 +1,5 @@
import { Plus } from 'lucide-react';
import { FieldCreatePopover } from '@colanode/ui/components/databases/fields/field-create-popover';
import { TableViewFieldHeader } from '@colanode/ui/components/databases/tables/table-view-field-header';
import { TableViewNameHeader } from '@colanode/ui/components/databases/tables/table-view-name-header';
@@ -15,7 +17,11 @@ export const TableViewHeader = () => {
{view.fields.map((field) => {
return <TableViewFieldHeader viewField={field} key={field.field.id} />;
})}
{database.canEdit && <FieldCreatePopover />}
{database.canEdit && (
<FieldCreatePopover
button={<Plus className="ml-2 size-4 cursor-pointer" />}
/>
)}
</div>
);
};

View File

@@ -38,7 +38,7 @@ export const TableViewSettings = () => {
<PopoverTrigger>
<ViewSettingsButton />
</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">
{database.canEdit ? (
<AvatarPopover

View File

@@ -5,8 +5,6 @@ import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
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 {
Dialog,
@@ -32,9 +30,8 @@ import { useMutation } from '@colanode/ui/hooks/use-mutation';
import { cn } from '@colanode/ui/lib/utils';
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']),
groupBy: z.string().optional().nullable(),
});
interface ViewTypeOption {
@@ -61,9 +58,6 @@ const viewTypes: ViewTypeOption[] = [
},
];
const boardGroupFields: FieldType[] = ['select'];
const calendarGroupFields: FieldType[] = ['date', 'created_at'];
interface ViewCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -84,18 +78,6 @@ export const ViewCreateDialog = ({
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 = () => {
form.reset();
@@ -107,31 +89,24 @@ export const ViewCreateDialog = ({
return;
}
if (values.type === 'board') {
if (!values.groupBy) {
toast.error(
'You need to specify a group by field to create a board view'
);
return;
}
} else if (values.type === 'calendar') {
if (!values.groupBy) {
toast.error(
'You need to specify a group by field to create a calendar view'
);
return;
}
const type = viewTypes.find((viewType) => viewType.type === values.type);
if (!type) {
return;
}
let name = values.name;
if (name === '') {
name = type.name;
}
mutate({
input: {
type: 'view.create',
viewType: values.type,
viewType: type.type,
databaseId: database.id,
name: values.name,
name: name,
accountId: workspace.accountId,
workspaceId: workspace.id,
groupBy: values.groupBy ?? null,
},
onSuccess() {
form.reset();
@@ -193,7 +168,6 @@ export const ViewCreateDialog = ({
)}
onClick={() => {
field.onChange(viewType.type);
form.setValue('groupBy', null);
}}
>
<viewType.icon />
@@ -203,25 +177,6 @@ export const ViewCreateDialog = ({
</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>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleCancel}>

View File

@@ -192,6 +192,26 @@ export const View = ({ view }: ViewProps) => {
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) => {
if (!database.canEdit) {
return;

View 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);

View File

@@ -25,6 +25,7 @@ interface DatabaseViewContext {
setFieldDisplay: (id: string, display: boolean) => void;
resizeField: (id: string, width: number) => void;
resizeName: (width: number) => void;
setGroupBy: (fieldId: string | null) => void;
moveField: (id: string, after: string) => void;
isFieldFilterOpened: (fieldId: string) => boolean;
initFieldFilter: (fieldId: string) => void;