Skip to content

Commit f9a467d

Browse files
authored
Merge pull request #2097 from teableio/fix/link-date-formattin
fix: fix link date formatting
2 parents 1649609 + 9903eb5 commit f9a467d

File tree

5 files changed

+313
-9
lines changed

5 files changed

+313
-9
lines changed

apps/nestjs-backend/src/features/record/query-builder/field-formatting-visitor.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
type LastModifiedByFieldCore,
2323
type ButtonFieldCore,
2424
type INumberFormatting,
25+
type IDatetimeFormatting,
2526
} from '@teable/core';
2627
import { match, P } from 'ts-pattern';
2728
import type { IRecordQueryDialectProvider } from './record-query-dialect.interface';
@@ -64,6 +65,20 @@ export class FieldFormattingVisitor implements IFieldVisitor<string> {
6465
return this.dialect.formatNumberArray(this.fieldExpression, formatting);
6566
}
6667

68+
/**
69+
* Apply date/time formatting to field expression
70+
*/
71+
private applyDateFormatting(formatting: IDatetimeFormatting): string {
72+
return this.dialect.formatDate(this.fieldExpression, formatting);
73+
}
74+
75+
/**
76+
* Format multiple datetime values contained in a JSON array
77+
*/
78+
private formatMultipleDateValues(formatting: IDatetimeFormatting): string {
79+
return this.dialect.formatDateArray(this.fieldExpression, formatting);
80+
}
81+
6782
/**
6883
* Format multiple string values (like multiple select) to comma-separated string
6984
* Also handles link field arrays with objects containing id and title
@@ -96,7 +111,12 @@ export class FieldFormattingVisitor implements IFieldVisitor<string> {
96111
}
97112

98113
visitDateField(_field: DateFieldCore): string {
99-
// Date fields are stored as ISO strings, return as-is
114+
if (_field.options?.formatting) {
115+
if (_field.isMultipleCellValue) {
116+
return this.formatMultipleDateValues(_field.options.formatting);
117+
}
118+
return this.applyDateFormatting(_field.options.formatting);
119+
}
100120
return this.fieldExpression;
101121
}
102122

@@ -159,11 +179,16 @@ export class FieldFormattingVisitor implements IFieldVisitor<string> {
159179
{ cellValueType: CellValueType.Number, formatting: P.not(P.nullish) },
160180
({ formatting }) => this.applyNumberFormatting(formatting as INumberFormatting)
161181
)
162-
.with({ cellValueType: CellValueType.DateTime, formatting: P.not(P.nullish) }, () => {
163-
// For datetime formatting, we would need to implement date formatting logic
164-
// For now, return as-is since datetime fields are typically stored as ISO strings
165-
return this.fieldExpression;
166-
})
182+
.with(
183+
{ cellValueType: CellValueType.DateTime, formatting: P.not(P.nullish) },
184+
({ formatting, isMultipleCellValue }) => {
185+
const datetimeFormatting = formatting as IDatetimeFormatting;
186+
if (isMultipleCellValue) {
187+
return this.formatMultipleDateValues(datetimeFormatting);
188+
}
189+
return this.applyDateFormatting(datetimeFormatting);
190+
}
191+
)
167192
.with({ cellValueType: CellValueType.String, isMultipleCellValue: true }, () => {
168193
// For multiple-value string fields (like multiple select), convert array to comma-separated string
169194
return this.formatMultipleStringValues();

apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.ts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1-
import { DriverClient, FieldType, CellValueType, DbFieldType } from '@teable/core';
2-
import type { INumberFormatting, ICurrencyFormatting, Relationship, FieldCore } from '@teable/core';
1+
import {
2+
DriverClient,
3+
FieldType,
4+
CellValueType,
5+
DbFieldType,
6+
DateFormattingPreset,
7+
TimeFormatting,
8+
} from '@teable/core';
9+
import type {
10+
INumberFormatting,
11+
ICurrencyFormatting,
12+
Relationship,
13+
FieldCore,
14+
IDatetimeFormatting,
15+
} from '@teable/core';
316
import type { Knex } from 'knex';
417
import type { IRecordQueryDialectProvider } from '../record-query-dialect.interface';
518

@@ -92,6 +105,78 @@ export class PgRecordQueryDialect implements IRecordQueryDialectProvider {
92105
return `CASE WHEN (${expr} = ROUND(${expr})) THEN ROUND(${expr})::TEXT ELSE (${expr})::TEXT END`;
93106
}
94107

108+
private escapeLiteral(value: string): string {
109+
return value.replace(/'/g, "''");
110+
}
111+
112+
private getDatePattern(date: string): string {
113+
switch (date as DateFormattingPreset) {
114+
case DateFormattingPreset.US:
115+
return 'FMMM/FMDD/YYYY';
116+
case DateFormattingPreset.European:
117+
return 'FMDD/FMMM/YYYY';
118+
case DateFormattingPreset.Asian:
119+
return 'YYYY/MM/DD';
120+
case DateFormattingPreset.ISO:
121+
return 'YYYY-MM-DD';
122+
case DateFormattingPreset.YM:
123+
return 'YYYY-MM';
124+
case DateFormattingPreset.MD:
125+
return 'MM-DD';
126+
case DateFormattingPreset.Y:
127+
return 'YYYY';
128+
case DateFormattingPreset.M:
129+
return 'MM';
130+
case DateFormattingPreset.D:
131+
return 'DD';
132+
default:
133+
return 'YYYY-MM-DD';
134+
}
135+
}
136+
137+
private getTimePattern(time: TimeFormatting | undefined): string | null {
138+
switch (time) {
139+
case TimeFormatting.Hour24:
140+
return 'HH24:MI';
141+
case TimeFormatting.Hour12:
142+
return 'HH12:MI AM';
143+
default:
144+
return null;
145+
}
146+
}
147+
148+
private buildDateFormattingExpression(
149+
valueExpression: string,
150+
formatting: IDatetimeFormatting
151+
): string {
152+
const { date, time, timeZone } = formatting;
153+
const timePattern = this.getTimePattern(time ?? TimeFormatting.None);
154+
const datePattern = this.getDatePattern(date);
155+
const pattern = timePattern ? `${datePattern} ${timePattern}` : datePattern;
156+
const tz = this.escapeLiteral(timeZone ?? 'UTC');
157+
const patternLiteral = this.escapeLiteral(pattern);
158+
return `TO_CHAR(TIMEZONE('${tz}', (${valueExpression})::timestamptz), '${patternLiteral}')`;
159+
}
160+
161+
formatDate(expr: string, formatting: IDatetimeFormatting): string {
162+
return this.buildDateFormattingExpression(expr, formatting);
163+
}
164+
165+
formatDateArray(expr: string, formatting: IDatetimeFormatting): string {
166+
const elementExpr = this.buildDateFormattingExpression("(elem #>> '{}')", formatting);
167+
return `(
168+
SELECT string_agg(
169+
CASE
170+
WHEN (elem #>> '{}') IS NULL THEN NULL
171+
ELSE ${elementExpr}
172+
END,
173+
', '
174+
ORDER BY ord
175+
)
176+
FROM jsonb_array_elements(COALESCE((${expr})::jsonb, '[]'::jsonb)) WITH ORDINALITY AS t(elem, ord)
177+
)`;
178+
}
179+
95180
private hasWrappingParentheses(expr: string): boolean {
96181
if (!expr.startsWith('(') || !expr.endsWith(')')) {
97182
return false;

apps/nestjs-backend/src/features/record/query-builder/providers/sqlite-record-query-dialect.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { INumberFormatting, ICurrencyFormatting, FieldCore } from '@teable/core';
1+
import type {
2+
INumberFormatting,
3+
ICurrencyFormatting,
4+
FieldCore,
5+
IDatetimeFormatting,
6+
} from '@teable/core';
27
import { DriverClient, FieldType, Relationship, DbFieldType } from '@teable/core';
38
import type { Knex } from 'knex';
49
import type { IRecordQueryDialectProvider } from '../record-query-dialect.interface';
@@ -65,6 +70,14 @@ export class SqliteRecordQueryDialect implements IRecordQueryDialectProvider {
6570
return `CASE WHEN (${expr} = CAST(${expr} AS INTEGER)) THEN CAST(CAST(${expr} AS INTEGER) AS TEXT) ELSE CAST(${expr} AS TEXT) END`;
6671
}
6772

73+
formatDate(expr: string, _formatting: IDatetimeFormatting): string {
74+
return `CAST(${expr} AS TEXT)`;
75+
}
76+
77+
formatDateArray(expr: string, _formatting: IDatetimeFormatting): string {
78+
return this.formatStringArray(expr);
79+
}
80+
6881
coerceToNumericForCompare(expr: string): string {
6982
return `CAST(${expr} AS NUMERIC)`;
7083
}

apps/nestjs-backend/src/features/record/query-builder/record-query-dialect.interface.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
INumberFormatting,
55
Relationship,
66
DbFieldType,
7+
IDatetimeFormatting,
78
} from '@teable/core';
89
import type { Knex } from 'knex';
910

@@ -91,6 +92,17 @@ export interface IRecordQueryDialectProvider {
9192
*/
9293
formatRating(expr: string): string;
9394

95+
/**
96+
* Format a datetime SQL expression according to field formatting (date preset, time preset, timezone).
97+
* Implementations should mirror {@link formatDateToString} semantics.
98+
*/
99+
formatDate(expr: string, formatting: IDatetimeFormatting): string;
100+
101+
/**
102+
* Format each element of a JSON array of datetimes according to field formatting and join with comma + space.
103+
*/
104+
formatDateArray(expr: string, formatting: IDatetimeFormatting): string;
105+
94106
// Safe coercions used in comparisons
95107

96108
/**

apps/nestjs-backend/test/formula.e2e-spec.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3897,6 +3897,175 @@ describe('OpenAPI formula (e2e)', () => {
38973897
expect(record1.fields[formulaField.name]).toEqual('text');
38983898
});
38993899

3900+
it('should format link titles using foreign field formatting', async () => {
3901+
const foreignDate = await createTable(baseId, {
3902+
name: 'link-format-date-foreign',
3903+
fields: [
3904+
{
3905+
name: 'Due Date',
3906+
type: FieldType.Date,
3907+
options: {
3908+
formatting: {
3909+
date: DateFormattingPreset.Asian,
3910+
time: TimeFormatting.None,
3911+
timeZone: 'UTC',
3912+
},
3913+
},
3914+
} as IFieldRo,
3915+
],
3916+
records: [
3917+
{
3918+
fields: {
3919+
'Due Date': '2024-05-06T01:23:45.000Z',
3920+
},
3921+
},
3922+
{
3923+
fields: {
3924+
'Due Date': '2024-05-07T09:00:00.000Z',
3925+
},
3926+
},
3927+
],
3928+
});
3929+
3930+
const foreignNumber = await createTable(baseId, {
3931+
name: 'link-format-number-foreign',
3932+
fields: [
3933+
{
3934+
name: 'Completion',
3935+
type: FieldType.Number,
3936+
options: {
3937+
formatting: {
3938+
type: NumberFormattingType.Percent,
3939+
precision: 1,
3940+
},
3941+
},
3942+
} as IFieldRo,
3943+
],
3944+
records: [
3945+
{
3946+
fields: {
3947+
Completion: 0.321,
3948+
},
3949+
},
3950+
{
3951+
fields: {
3952+
Completion: 0.875,
3953+
},
3954+
},
3955+
],
3956+
});
3957+
3958+
let host: ITableFullVo | undefined;
3959+
try {
3960+
host = await createTable(baseId, {
3961+
name: 'link-format-host',
3962+
fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo],
3963+
records: [{ fields: { Label: 'host row' } }],
3964+
});
3965+
3966+
const dateLinkField = await createField(host.id, {
3967+
name: 'Date Link',
3968+
type: FieldType.Link,
3969+
options: {
3970+
foreignTableId: foreignDate.id,
3971+
relationship: Relationship.ManyOne,
3972+
} as ILinkFieldOptionsRo,
3973+
} as IFieldRo);
3974+
3975+
const dateMultiLinkField = await createField(host.id, {
3976+
name: 'Date Links',
3977+
type: FieldType.Link,
3978+
options: {
3979+
foreignTableId: foreignDate.id,
3980+
relationship: Relationship.ManyMany,
3981+
} as ILinkFieldOptionsRo,
3982+
} as IFieldRo);
3983+
3984+
const numberLinkField = await createField(host.id, {
3985+
name: 'Number Link',
3986+
type: FieldType.Link,
3987+
options: {
3988+
foreignTableId: foreignNumber.id,
3989+
relationship: Relationship.ManyOne,
3990+
} as ILinkFieldOptionsRo,
3991+
} as IFieldRo);
3992+
3993+
const numberMultiLinkField = await createField(host.id, {
3994+
name: 'Number Links',
3995+
type: FieldType.Link,
3996+
options: {
3997+
foreignTableId: foreignNumber.id,
3998+
relationship: Relationship.ManyMany,
3999+
} as ILinkFieldOptionsRo,
4000+
} as IFieldRo);
4001+
4002+
const hostRecordId = host.records[0].id;
4003+
4004+
await updateRecordByApi(host.id, hostRecordId, dateLinkField.id, {
4005+
id: foreignDate.records[0].id,
4006+
});
4007+
4008+
await updateRecordByApi(
4009+
host.id,
4010+
hostRecordId,
4011+
dateMultiLinkField.id,
4012+
foreignDate.records.map((record) => ({ id: record.id }))
4013+
);
4014+
4015+
await updateRecordByApi(host.id, hostRecordId, numberLinkField.id, {
4016+
id: foreignNumber.records[0].id,
4017+
});
4018+
4019+
await updateRecordByApi(
4020+
host.id,
4021+
hostRecordId,
4022+
numberMultiLinkField.id,
4023+
foreignNumber.records.map((record) => ({ id: record.id }))
4024+
);
4025+
4026+
const record = await getRecord(host.id, hostRecordId);
4027+
const dateLink = record.data.fields[dateLinkField.name] as {
4028+
id: string;
4029+
title: string;
4030+
} | null;
4031+
expect(dateLink).toBeDefined();
4032+
expect(dateLink?.id).toBe(foreignDate.records[0].id);
4033+
expect(dateLink?.title).toBe('2024/05/06');
4034+
4035+
const numberLink = record.data.fields[numberLinkField.name] as {
4036+
id: string;
4037+
title: string;
4038+
} | null;
4039+
expect(numberLink).toBeDefined();
4040+
expect(numberLink?.id).toBe(foreignNumber.records[0].id);
4041+
expect(numberLink?.title).toBe('32.1%');
4042+
4043+
const dateMultiLink = record.data.fields[dateMultiLinkField.name] as Array<{
4044+
id: string;
4045+
title: string;
4046+
}> | null;
4047+
expect(Array.isArray(dateMultiLink)).toBe(true);
4048+
expect(dateMultiLink?.length).toBe(2);
4049+
const dateMultiTitles = dateMultiLink?.map((item) => item.title);
4050+
expect(dateMultiTitles).toEqual(['2024/05/06', '2024/05/07']);
4051+
4052+
const numberMultiLink = record.data.fields[numberMultiLinkField.name] as Array<{
4053+
id: string;
4054+
title: string;
4055+
}> | null;
4056+
expect(Array.isArray(numberMultiLink)).toBe(true);
4057+
expect(numberMultiLink?.length).toBe(2);
4058+
const numberMultiTitles = numberMultiLink?.map((item) => item.title);
4059+
expect(numberMultiTitles).toEqual(['32.1%', '87.5%']);
4060+
} finally {
4061+
if (host) {
4062+
await permanentDeleteTable(baseId, host.id);
4063+
}
4064+
await permanentDeleteTable(baseId, foreignDate.id);
4065+
await permanentDeleteTable(baseId, foreignNumber.id);
4066+
}
4067+
});
4068+
39004069
describe('safe calculate', () => {
39014070
let table: ITableFullVo;
39024071
beforeEach(async () => {

0 commit comments

Comments
 (0)