Skip to content

Commit 9a8402a

Browse files
authored
feat: calendar view (#1103)
* feat: calendar view * chore: update lock file * feat: adapt to missing start or end dates * chore: update e2e testing * feat: permissions for calendar events * feat: calendar adapted to mobile * fix: the add button for calendar date cells is not displayed * feat: add a date field dialog when there is no date field in calendar * fix: clear calendar daily cache when switching views * fix: incorrect calendar style when refreshing * fix: calendar title rendering * fix: calendar daily collection e2e testing in sqlite * fix: menu rendering in edge cases
1 parent 23986e8 commit 9a8402a

80 files changed

Lines changed: 4373 additions & 1797 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/nestjs-backend/src/db-provider/db.provider.interface.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Prisma } from '@teable/db-main-prisma';
33
import type { IAggregationField } from '@teable/openapi';
44
import type { Knex } from 'knex';
55
import type { IFieldInstance } from '../features/field/model/factory';
6+
import type { DateFieldDto } from '../features/field/model/field-dto/date-field.dto';
67
import type { SchemaType } from '../features/field/util';
78
import type { IAggregationQueryInterface } from './aggregation-query/aggregation-query.interface';
89
import type { BaseQueryAbstract } from './base-query/abstract';
@@ -22,6 +23,13 @@ export type ISortQueryExtra = {
2223

2324
export type IAggregationQueryExtra = { filter?: IFilter; groupBy?: string[] } & IFilterQueryExtra;
2425

26+
export type ICalendarDailyCollectionQueryProps = {
27+
startDate: string;
28+
endDate: string;
29+
startField: DateFieldDto;
30+
endField: DateFieldDto;
31+
};
32+
2533
export interface IDbProvider {
2634
driver: DriverClient;
2735

@@ -146,4 +154,9 @@ export interface IDbProvider {
146154
): void;
147155

148156
baseQuery(): BaseQueryAbstract;
157+
158+
calendarDailyCollectionQuery(
159+
qb: Knex.QueryBuilder,
160+
props: ICalendarDailyCollectionQueryProps
161+
): Knex.QueryBuilder;
149162
}

apps/nestjs-backend/src/db-provider/postgres.provider.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable sonarjs/no-duplicate-string */
12
import { Logger } from '@nestjs/common';
23
import type { IFilter, ISortItem } from '@teable/core';
34
import { DriverClient } from '@teable/core';
@@ -12,6 +13,7 @@ import type { BaseQueryAbstract } from './base-query/abstract';
1213
import { BaseQueryPostgres } from './base-query/base-query.postgres';
1314
import type {
1415
IAggregationQueryExtra,
16+
ICalendarDailyCollectionQueryProps,
1517
IDbProvider,
1618
IFilterQueryExtra,
1719
ISortQueryExtra,
@@ -361,4 +363,54 @@ export class PostgresProvider implements IDbProvider {
361363
baseQuery(): BaseQueryAbstract {
362364
return new BaseQueryPostgres(this.knex);
363365
}
366+
367+
calendarDailyCollectionQuery(
368+
qb: Knex.QueryBuilder,
369+
props: ICalendarDailyCollectionQueryProps
370+
): Knex.QueryBuilder {
371+
const { startDate, endDate, startField, endField } = props;
372+
const timezone = startField.options.formatting.timeZone;
373+
374+
return qb
375+
.select([
376+
this.knex.raw('dates.date'),
377+
this.knex.raw('COUNT(*) as count'),
378+
this.knex.raw(`(array_agg(?? ORDER BY ??))[1:10] as ids`, ['__id', startField.dbFieldName]),
379+
])
380+
.crossJoin(
381+
this.knex.raw(
382+
`(SELECT date::date as date
383+
FROM generate_series(
384+
(?::timestamptz AT TIME ZONE ?)::date,
385+
(?::timestamptz AT TIME ZONE ?)::date,
386+
'1 day'::interval
387+
) AS date) as dates`,
388+
[startDate, timezone, endDate, timezone]
389+
)
390+
)
391+
.where((builder) => {
392+
builder
393+
.where(startField.dbFieldName, '<', endDate)
394+
.andWhere(
395+
this.knex.raw(`COALESCE(??::timestamptz, ??)::timestamptz >= ?::timestamptz`, [
396+
endField.dbFieldName,
397+
startField.dbFieldName,
398+
startDate,
399+
])
400+
)
401+
.andWhere((subBuilder) => {
402+
subBuilder
403+
.whereRaw(`(??::timestamptz AT TIME ZONE ?)::date <= dates.date`, [
404+
startField.dbFieldName,
405+
timezone,
406+
])
407+
.andWhereRaw(
408+
`(COALESCE(??::timestamptz, ??)::timestamptz AT TIME ZONE ?)::date >= dates.date`,
409+
[endField.dbFieldName, startField.dbFieldName, timezone]
410+
);
411+
});
412+
})
413+
.groupBy('dates.date')
414+
.orderBy('dates.date', 'asc');
415+
}
364416
}

apps/nestjs-backend/src/db-provider/sqlite.provider.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { BaseQueryAbstract } from './base-query/abstract';
1313
import { BaseQuerySqlite } from './base-query/base-query.sqlite';
1414
import type {
1515
IAggregationQueryExtra,
16+
ICalendarDailyCollectionQueryProps,
1617
IDbProvider,
1718
IFilterQueryExtra,
1819
ISortQueryExtra,
@@ -22,6 +23,7 @@ import { FilterQuerySqlite } from './filter-query/sqlite/filter-query.sqlite';
2223
import type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface';
2324
import { GroupQuerySqlite } from './group-query/group-query.sqlite';
2425
import { SearchQueryAbstract } from './search-query/abstract';
26+
import { getOffset } from './search-query/get-offset';
2527
import { SearchQuerySqlite } from './search-query/search-query.sqlite';
2628
import type { ISortQueryInterface } from './sort-query/sort-query.interface';
2729
import { SortQuerySqlite } from './sort-query/sqlite/sort-query.sqlite';
@@ -313,4 +315,60 @@ export class SqliteProvider implements IDbProvider {
313315
baseQuery(): BaseQueryAbstract {
314316
return new BaseQuerySqlite(this.knex);
315317
}
318+
319+
calendarDailyCollectionQuery(
320+
qb: Knex.QueryBuilder,
321+
props: ICalendarDailyCollectionQueryProps
322+
): Knex.QueryBuilder {
323+
const { startDate, endDate, startField, endField } = props;
324+
const timezone = startField.options.formatting.timeZone;
325+
const offsetStr = `${getOffset(timezone)} hour`;
326+
327+
const datesSubquery = this.knex.raw(
328+
`WITH RECURSIVE dates(date) AS (
329+
SELECT date(datetime(?, ?)) as date
330+
UNION ALL
331+
SELECT date(datetime(date, ?))
332+
FROM dates
333+
WHERE date < date(datetime(?, ?))
334+
)
335+
SELECT date FROM dates`,
336+
[startDate, offsetStr, '+1 day', endDate, offsetStr]
337+
);
338+
339+
return qb
340+
.select([
341+
this.knex.raw('d.date'),
342+
this.knex.raw('COUNT(*) as count'),
343+
this.knex.raw('GROUP_CONCAT(??) as ids', ['__id']),
344+
])
345+
.crossJoin(datesSubquery.wrap('(', ') as d'))
346+
.where((builder) => {
347+
builder
348+
.where(this.knex.raw(`datetime(??, ?)`, [endField.dbFieldName, offsetStr]), '<', endDate)
349+
.andWhere(
350+
this.knex.raw(`datetime(COALESCE(??, ??), ?)`, [
351+
endField.dbFieldName,
352+
startField.dbFieldName,
353+
offsetStr,
354+
]),
355+
'>=',
356+
startDate
357+
);
358+
})
359+
.andWhere((builder) => {
360+
builder.whereRaw(
361+
`date(datetime(??, ?)) <= d.date AND date(datetime(COALESCE(??, ??), ?)) >= d.date`,
362+
[
363+
startField.dbFieldName,
364+
offsetStr,
365+
endField.dbFieldName,
366+
startField.dbFieldName,
367+
offsetStr,
368+
]
369+
);
370+
})
371+
.groupBy('d.date')
372+
.orderBy('d.date', 'asc');
373+
}
316374
}

apps/nestjs-backend/src/features/aggregation/aggregation.service.ts

Lines changed: 132 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1-
import { Injectable, Logger, BadRequestException, BadGatewayException } from '@nestjs/common';
1+
/* eslint-disable sonarjs/no-duplicate-string */
2+
import {
3+
BadGatewayException,
4+
BadRequestException,
5+
Injectable,
6+
InternalServerErrorException,
7+
Logger,
8+
} from '@nestjs/common';
29
import type { IGridColumnMeta, IFilter, IGroup } from '@teable/core';
310
import {
4-
FieldType,
11+
CellValueType,
12+
identify,
13+
IdPrefix,
514
mergeWithDefaultFilter,
615
nullsToUndefined,
716
StatisticsFunc,
@@ -17,12 +26,14 @@ import type {
1726
IRawAggregationValue,
1827
IRawRowCountValue,
1928
IGroupPointsRo,
29+
ICalendarDailyCollectionRo,
30+
ICalendarDailyCollectionVo,
2031
ISearchIndexByQueryRo,
2132
ISearchCountRo,
2233
} from '@teable/openapi';
2334
import dayjs from 'dayjs';
2435
import { Knex } from 'knex';
25-
import { get, groupBy, isDate, isEmpty, keyBy, orderBy } from 'lodash';
36+
import { get, groupBy, isDate, isEmpty, isString, keyBy } from 'lodash';
2637
import { InjectModel } from 'nest-knexjs';
2738
import { ClsService } from 'nestjs-cls';
2839
import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';
@@ -32,6 +43,7 @@ import type { IClsStore } from '../../types/cls';
3243
import { convertValueToStringify, string2Hash } from '../../utils';
3344
import type { IFieldInstance } from '../field/model/factory';
3445
import { createFieldInstanceByRaw } from '../field/model/factory';
46+
import type { DateFieldDto } from '../field/model/field-dto/date-field.dto';
3547
import { RecordService } from '../record/record.service';
3648

3749
export type IWithView = {
@@ -288,11 +300,26 @@ export class AggregationService {
288300

289301
return nullsToUndefined(
290302
await this.prisma.view.findFirst({
291-
select: { id: true, columnMeta: true, filter: true, group: true },
303+
select: {
304+
id: true,
305+
type: true,
306+
filter: true,
307+
group: true,
308+
options: true,
309+
columnMeta: true,
310+
},
292311
where: {
293312
tableId,
294313
...(withView?.viewId ? { id: withView.viewId } : {}),
295-
type: { in: [ViewType.Grid, ViewType.Gantt, ViewType.Kanban, ViewType.Gallery] },
314+
type: {
315+
in: [
316+
ViewType.Grid,
317+
ViewType.Gantt,
318+
ViewType.Kanban,
319+
ViewType.Gallery,
320+
ViewType.Calendar,
321+
],
322+
},
296323
deletedTime: null,
297324
},
298325
})
@@ -735,4 +762,104 @@ export class AggregationService {
735762
};
736763
});
737764
}
765+
766+
public async getCalendarDailyCollection(
767+
tableId: string,
768+
query: ICalendarDailyCollectionRo
769+
): Promise<ICalendarDailyCollectionVo> {
770+
const { startDate, endDate, startDateFieldId, endDateFieldId, viewId, filter, search } = query;
771+
772+
if (identify(tableId) !== IdPrefix.Table) {
773+
throw new InternalServerErrorException('query collection must be table id');
774+
}
775+
776+
const dbTableName = await this.getDbTableName(this.prisma, tableId);
777+
const fields = await this.recordService.getFieldsByProjection(tableId);
778+
const fieldMap = fields.reduce(
779+
(map, field) => {
780+
map[field.id] = field;
781+
return map;
782+
},
783+
{} as Record<string, IFieldInstance>
784+
);
785+
786+
const startField = fieldMap[startDateFieldId];
787+
788+
if (
789+
!startField ||
790+
startField.cellValueType !== CellValueType.DateTime ||
791+
startField.isMultipleCellValue
792+
) {
793+
throw new BadRequestException('Invalid start date field id');
794+
}
795+
796+
const endField = endDateFieldId ? fieldMap[endDateFieldId] : startField;
797+
798+
if (
799+
!endField ||
800+
endField.cellValueType !== CellValueType.DateTime ||
801+
endField.isMultipleCellValue
802+
) {
803+
throw new BadRequestException('Invalid end date field id');
804+
}
805+
806+
const queryBuilder = this.knex(dbTableName);
807+
const viewRaw = await this.findView(tableId, { viewId });
808+
const filterStr = viewRaw?.filter;
809+
const mergedFilter = mergeWithDefaultFilter(filterStr, filter);
810+
const currentUserId = this.cls.get('user.id');
811+
812+
if (mergedFilter) {
813+
this.dbProvider
814+
.filterQuery(queryBuilder, fieldMap, mergedFilter, { withUserId: currentUserId })
815+
.appendQueryBuilder();
816+
}
817+
818+
if (search) {
819+
const handledSearch = search ? this.recordService.parseSearch(search, fieldMap) : undefined;
820+
queryBuilder.where((builder) => {
821+
this.dbProvider.searchQuery(builder, fieldMap, handledSearch);
822+
});
823+
}
824+
825+
this.dbProvider.calendarDailyCollectionQuery(queryBuilder, {
826+
startDate,
827+
endDate,
828+
startField: startField as DateFieldDto,
829+
endField: endField as DateFieldDto,
830+
});
831+
832+
const result = await this.prisma
833+
.txClient()
834+
.$queryRawUnsafe<
835+
{ date: Date | string; count: number; ids: string[] | string }[]
836+
>(queryBuilder.toQuery());
837+
838+
const countMap = result.reduce(
839+
(map, item) => {
840+
const key = isString(item.date) ? item.date : item.date.toISOString().split('T')[0];
841+
map[key] = Number(item.count);
842+
return map;
843+
},
844+
{} as Record<string, number>
845+
);
846+
let recordIds = result
847+
.map((item) => (isString(item.ids) ? item.ids.split(',') : item.ids))
848+
.flat();
849+
recordIds = Array.from(new Set(recordIds));
850+
851+
if (!recordIds.length) {
852+
return {
853+
countMap,
854+
records: [],
855+
};
856+
}
857+
858+
const { records } = await this.recordService.getRecordsById(tableId, recordIds);
859+
860+
return {
861+
countMap,
862+
records,
863+
};
864+
}
738865
}

apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,23 @@
22
import { Controller, Get, Param, Query } from '@nestjs/common';
33
import type {
44
IAggregationVo,
5+
ICalendarDailyCollectionVo,
56
IGroupPointsVo,
67
IRowCountVo,
78
ISearchCountVo,
89
ISearchIndexVo,
910
} from '@teable/openapi';
1011
import {
1112
aggregationRoSchema,
13+
calendarDailyCollectionRoSchema,
1214
groupPointsRoSchema,
1315
IAggregationRo,
1416
IGroupPointsRo,
1517
IQueryBaseRo,
1618
searchCountRoSchema,
1719
ISearchCountRo,
1820
queryBaseSchema,
21+
ICalendarDailyCollectionRo,
1922
ISearchIndexByQueryRo,
2023
searchIndexByQueryRoSchema,
2124
} from '@teable/openapi';
@@ -72,4 +75,14 @@ export class AggregationOpenApiController {
7275
): Promise<IGroupPointsVo> {
7376
return await this.aggregationOpenApiService.getGroupPoints(tableId, query);
7477
}
78+
79+
@Get('/calendar-daily-collection')
80+
@Permissions('table|read')
81+
async getCalendarDailyCollection(
82+
@Param('tableId') tableId: string,
83+
@Query(new ZodValidationPipe(calendarDailyCollectionRoSchema), TqlPipe)
84+
query: ICalendarDailyCollectionRo
85+
): Promise<ICalendarDailyCollectionVo> {
86+
return await this.aggregationOpenApiService.getCalendarDailyCollection(tableId, query);
87+
}
7588
}

0 commit comments

Comments
 (0)