mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 03:37:51 +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),
|
||||
layout: input.viewType,
|
||||
parentId: input.databaseId,
|
||||
groupBy: input.groupBy,
|
||||
};
|
||||
|
||||
await workspace.nodes.createNode({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 { 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}`;
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
viewType: 'table' | 'board' | 'calendar';
|
||||
name: string;
|
||||
groupBy: string | null | undefined;
|
||||
};
|
||||
|
||||
export type ViewCreateMutationOutput = {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {
|
||||
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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 { 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
<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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user