Skip to content

Commit ef337d7

Browse files
author
teable-bot
committed
[sync] feat: usage limit modal T1715 (#1087)
Synced from teableio/teable-ee@7b8b93a
1 parent 6bacc71 commit ef337d7

File tree

32 files changed

+373
-103
lines changed

32 files changed

+373
-103
lines changed

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -751,8 +751,7 @@ export class FieldService implements IReadonlyAdapterService {
751751
: matchedIndexes.forEach((indexName) => table.dropUnique([dbFieldName], indexName));
752752
}
753753

754-
// TODO: add to db provider
755-
if (key === 'notNull' && type !== FieldType.Link) {
754+
if (key === 'notNull') {
756755
newValue ? table.dropNullable(dbFieldName) : table.setNullable(dbFieldName);
757756
}
758757
})

apps/nestjs-backend/src/types/i18n.generated.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ export type I18nTranslations = {
251251
"useTemplate": string;
252252
"backToSpace": string;
253253
"switchBase": string;
254+
"getMore": string;
254255
"retry": string;
255256
"collapse": string;
256257
"viewDetails": string;
@@ -681,6 +682,7 @@ export type I18nTranslations = {
681682
"paused": string;
682683
"seatLimitExceeded": string;
683684
};
685+
"contactAdminToUpgrade": string;
684686
};
685687
"admin": {
686688
"setting": {
@@ -4345,6 +4347,7 @@ export type I18nTranslations = {
43454347
"timeCostDescription": string;
43464348
"creditDescription": string;
43474349
"tokenDescription": string;
4350+
"taskCompleted": string;
43484351
"input": string;
43494352
"output": string;
43504353
"tokens": string;

apps/nestjs-backend/test/base-duplicate.e2e-spec.ts

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { IFieldRo, ILinkFieldOptions, ILookupOptionsRo } from '@teable/core
44
import {
55
DriverClient,
66
FieldAIActionType,
7+
FieldKeyType,
78
FieldType,
89
Relationship,
910
Role,
@@ -20,6 +21,7 @@ import {
2021
createPluginPanel,
2122
createSpace,
2223
deleteBase,
24+
deleteRecords,
2325
deleteSpace,
2426
duplicateBase,
2527
EMAIL_SPACE_INVITATION,
@@ -735,7 +737,14 @@ describe('OpenAPI Base Duplicate (e2e)', () => {
735737
it('should duplicate base with bidirectional link field', async () => {
736738
const table1 = await createTable(base.id, { name: 'table1' });
737739
const table2 = await createTable(base.id, { name: 'table2' });
738-
740+
await deleteRecords(
741+
table1.id,
742+
table1.records.map((r) => r.id)
743+
);
744+
await deleteRecords(
745+
table2.id,
746+
table2.records.map((r) => r.id)
747+
);
739748
// Create bidirectional link field with dbFieldName 'link'
740749
const linkFieldRo: IFieldRo = {
741750
name: 'link field',
@@ -758,34 +767,31 @@ describe('OpenAPI Base Duplicate (e2e)', () => {
758767
...linkFieldRo,
759768
notNull: true,
760769
});
761-
770+
await createRecords(table2.id, {
771+
fieldKeyType: FieldKeyType.Id,
772+
records: [{ fields: {} }, { fields: {} }, { fields: {} }],
773+
});
762774
// Get records
763-
const table1Records = await getRecords(table1.id);
764775
const table2Records = await getRecords(table2.id);
765-
766-
// Fill all link relationships
767-
await updateRecord(table1.id, table1Records.records[0].id, {
768-
record: {
769-
fields: {
770-
[linkField.name]: [{ id: table2Records.records[0].id }],
776+
await createRecords(table1.id, {
777+
fieldKeyType: FieldKeyType.Name,
778+
records: [
779+
{
780+
fields: {
781+
[linkField.name]: [{ id: table2Records.records[0].id }],
782+
},
771783
},
772-
},
773-
});
774-
775-
await updateRecord(table1.id, table1Records.records[1].id, {
776-
record: {
777-
fields: {
778-
[linkField.name]: [{ id: table2Records.records[1].id }],
784+
{
785+
fields: {
786+
[linkField.name]: [{ id: table2Records.records[1].id }],
787+
},
779788
},
780-
},
781-
});
782-
783-
await updateRecord(table1.id, table1Records.records[2].id, {
784-
record: {
785-
fields: {
786-
[linkField.name]: [{ id: table2Records.records[2].id }],
789+
{
790+
fields: {
791+
[linkField.name]: [{ id: table2Records.records[2].id }],
792+
},
787793
},
788-
},
794+
],
789795
});
790796

791797
// Duplicate base with records
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* T1756: Link field NOT NULL constraint sync bug
3+
*
4+
* Steps to reproduce:
5+
* 1. Create a Number field
6+
* 2. Set notNull=true on the Number field
7+
* 3. Convert it to a Link field
8+
* 4. Edit the Link field and turn off notNull
9+
* 5. Try to create a record with empty Link value - FAILS because DB constraint still exists
10+
*/
11+
import type { INestApplication } from '@nestjs/common';
12+
import { FieldKeyType, FieldType, Relationship } from '@teable/core';
13+
import type { ITableFullVo } from '@teable/openapi';
14+
import {
15+
createField,
16+
createTable,
17+
convertField,
18+
createRecords,
19+
getField,
20+
initApp,
21+
permanentDeleteTable,
22+
deleteRecords,
23+
getRecords,
24+
} from './utils/init-app';
25+
26+
describe('T1756: Link field NOT NULL constraint sync bug', () => {
27+
let app: INestApplication;
28+
const baseId = globalThis.testConfig.baseId;
29+
30+
beforeAll(async () => {
31+
const appCtx = await initApp();
32+
app = appCtx.app;
33+
});
34+
35+
afterAll(async () => {
36+
await app.close();
37+
});
38+
39+
describe('bug reproduction', () => {
40+
let table1: ITableFullVo;
41+
let table2: ITableFullVo;
42+
43+
beforeEach(async () => {
44+
table1 = await createTable(baseId, { name: `table1-${Date.now()}` });
45+
table2 = await createTable(baseId, { name: `table2-${Date.now()}` });
46+
47+
// Clear default records
48+
const records1 = await getRecords(table1.id);
49+
const records2 = await getRecords(table2.id);
50+
if (records1.records.length) {
51+
await deleteRecords(
52+
table1.id,
53+
records1.records.map((r) => r.id)
54+
);
55+
}
56+
if (records2.records.length) {
57+
await deleteRecords(
58+
table2.id,
59+
records2.records.map((r) => r.id)
60+
);
61+
}
62+
});
63+
64+
afterEach(async () => {
65+
await permanentDeleteTable(baseId, table1.id);
66+
await permanentDeleteTable(baseId, table2.id);
67+
});
68+
69+
it('should allow creating record with empty Link after removing notNull constraint', async () => {
70+
// Step 1: Create a Number field
71+
const numberField = await createField(table1.id, {
72+
name: 'TestField',
73+
type: FieldType.Number,
74+
});
75+
76+
// Step 2: Set notNull=true on the Number field
77+
await convertField(table1.id, numberField.id, {
78+
...numberField,
79+
notNull: true,
80+
});
81+
82+
// Step 3: Convert to Link field
83+
const linkField = await convertField(table1.id, numberField.id, {
84+
type: FieldType.Link,
85+
options: {
86+
relationship: Relationship.ManyOne,
87+
foreignTableId: table2.id,
88+
},
89+
});
90+
91+
// Step 4: Turn off notNull on the Link field
92+
const linkFieldFull = await getField(table1.id, linkField.id);
93+
const updatedLinkField = await convertField(table1.id, linkField.id, {
94+
...linkFieldFull,
95+
notNull: false,
96+
});
97+
98+
// Verify metadata shows notNull is false
99+
expect(updatedLinkField.notNull).toBeFalsy();
100+
101+
// Step 5: Try to create a record with empty Link value
102+
// BUG: This should succeed since notNull is false in metadata
103+
// But it fails because DB still has NOT NULL constraint
104+
const result = await createRecords(
105+
table1.id,
106+
{
107+
fieldKeyType: FieldKeyType.Id,
108+
records: [{ fields: {} }], // Empty record, no Link value
109+
},
110+
201 // Expect success (201), but will get 500 due to DB constraint
111+
);
112+
113+
expect(result.records).toHaveLength(1);
114+
});
115+
116+
it('should not allow creating record with empty Link after setting notNull constraint', async () => {
117+
const linkField = await createField(table1.id, {
118+
type: FieldType.Link,
119+
options: {
120+
relationship: Relationship.ManyOne,
121+
foreignTableId: table2.id,
122+
},
123+
});
124+
const linkFieldFull = await getField(table1.id, linkField.id);
125+
await convertField(table1.id, linkField.id, {
126+
...linkFieldFull,
127+
notNull: true,
128+
});
129+
await createRecords(
130+
table1.id,
131+
{
132+
fieldKeyType: FieldKeyType.Id,
133+
records: [{ fields: {} }], // Empty record, no Link value
134+
},
135+
400 // Expect success (201), but will get 500 due to DB constraint
136+
);
137+
});
138+
});
139+
});

apps/nextjs-app/src/features/app/components/notifications/NotificationsManage.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export const NotificationsManage: React.FC = () => {
4848
if (notification?.notification == null) return;
4949
if (notification.notification.isRead) return;
5050

51+
// Use a stable toast id for credit-related notifications to prevent stacking
52+
// Covers both AI task (creditExhausted) and automation (insufficientCredit) notifications
53+
const isCreditNotification =
54+
notification.notification.messageI18n?.includes('creditExhausted') ||
55+
notification.notification.messageI18n?.includes('insufficientCredit');
56+
const toastId = isCreditNotification ? 'credit-exhausted-notification' : undefined;
57+
5158
toast.info(
5259
<div className="flex items-center">
5360
<NotificationIcon
@@ -60,6 +67,7 @@ export const NotificationsManage: React.FC = () => {
6067
/>
6168
</div>,
6269
{
70+
id: toastId,
6371
position: 'top-center',
6472
duration: 1000 * 3,
6573
closeButton: true,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { acceptLanguage } from './acceptHeader';
2+
3+
export const getLocaleFromCookie = (cookie: string): string | null => {
4+
if (!cookie) return null;
5+
const match = cookie.match(/NEXT_LOCALE=([^;]+)/);
6+
return match?.[1] || null;
7+
};
8+
9+
export const getLocaleFromBrowser = (): string => {
10+
if (typeof navigator === 'undefined') return 'en';
11+
const browserLang = navigator.language || (navigator as { userLanguage?: string }).userLanguage;
12+
if (!browserLang) return 'en';
13+
// Extract primary language code (e.g., 'zh-CN' -> 'zh', 'en-US' -> 'en')
14+
return browserLang.split('-')[0];
15+
};
16+
17+
export const getLocaleFromAcceptLanguage = (
18+
acceptLanguageHeader: string | undefined,
19+
supportedLocales: string[]
20+
): string | null => {
21+
if (!acceptLanguageHeader) return null;
22+
try {
23+
const locale = acceptLanguage(acceptLanguageHeader, supportedLocales);
24+
return locale || null;
25+
} catch {
26+
return null;
27+
}
28+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { getLocaleFromBrowser, getLocaleFromCookie } from './helper';
2+
export * from './helper';
3+
4+
type LocaleLoader = () => Promise<{ default: Record<string, unknown> }>;
5+
6+
export const detectStaticLocale = (cookie: string): string => {
7+
return getLocaleFromCookie(cookie) ?? getLocaleFromBrowser();
8+
};
9+
10+
export const systemLocaleLoaders: Record<string, LocaleLoader> = {
11+
en: () => import('@teable/common-i18n/src/locales/en/system.json'),
12+
it: () => import('@teable/common-i18n/src/locales/it/system.json'),
13+
zh: () => import('@teable/common-i18n/src/locales/zh/system.json'),
14+
fr: () => import('@teable/common-i18n/src/locales/fr/system.json'),
15+
ja: () => import('@teable/common-i18n/src/locales/ja/system.json'),
16+
ru: () => import('@teable/common-i18n/src/locales/ru/system.json'),
17+
de: () => import('@teable/common-i18n/src/locales/de/system.json'),
18+
uk: () => import('@teable/common-i18n/src/locales/uk/system.json'),
19+
tr: () => import('@teable/common-i18n/src/locales/tr/system.json'),
20+
es: () => import('@teable/common-i18n/src/locales/es/system.json'),
21+
};
22+
23+
export const loadSystemTranslations = async (locale: string) => {
24+
try {
25+
const loader = systemLocaleLoaders[locale] ?? systemLocaleLoaders.en;
26+
return (await loader()).default;
27+
} catch {
28+
return (await systemLocaleLoaders.en()).default;
29+
}
30+
};

apps/nextjs-app/src/pages/402.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
1-
import type { GetStaticPropsContext } from 'next';
1+
import type { GetServerSideProps } from 'next';
22
import { systemConfig } from '@/features/i18n/system.config';
33
import { PaymentRequiredPage } from '@/features/system/pages';
4-
import { getServerSideTranslations } from '@/lib/i18n';
5-
6-
export const getStaticProps = async (context: GetStaticPropsContext) => {
7-
const { locale = 'en' } = context;
8-
9-
const inlinedTranslation = await getServerSideTranslations(locale, systemConfig.i18nNamespaces);
4+
import { getTranslationsProps } from '@/lib/i18n';
105

6+
export const getServerSideProps: GetServerSideProps = async (context) => {
117
return {
128
props: {
13-
locale: locale,
14-
...inlinedTranslation,
9+
...(await getTranslationsProps(context, systemConfig.i18nNamespaces)),
1510
},
1611
};
1712
};

apps/nextjs-app/src/pages/403.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
1-
import type { GetStaticPropsContext } from 'next';
1+
import type { GetServerSideProps } from 'next';
22
import { systemConfig } from '@/features/i18n/system.config';
33
import { ForbiddenPage } from '@/features/system/pages';
4-
import { getServerSideTranslations } from '@/lib/i18n';
5-
6-
export const getStaticProps = async (context: GetStaticPropsContext) => {
7-
const { locale = 'en' } = context;
8-
9-
const inlinedTranslation = await getServerSideTranslations(locale, systemConfig.i18nNamespaces);
4+
import { getTranslationsProps } from '@/lib/i18n';
105

6+
export const getServerSideProps: GetServerSideProps = async (context) => {
117
return {
128
props: {
13-
locale: locale,
14-
...inlinedTranslation,
9+
...(await getTranslationsProps(context, systemConfig.i18nNamespaces)),
1510
},
1611
};
1712
};

0 commit comments

Comments
 (0)