From 2a382cbabad0a23afa4622d70ed93553b2567a67 Mon Sep 17 00:00:00 2001 From: teable-bot Date: Fri, 23 Jan 2026 10:52:49 +0000 Subject: [PATCH] [sync] fix: ai task queue missing generation (#1106) Synced from teableio/teable-ee@82f47fb --- .../src/features/field/field.service.ts | 3 +- .../test/base-duplicate.e2e-spec.ts | 54 ++++--- .../test/lin-field-not-null.e2e-spec.ts | 139 ++++++++++++++++++ apps/nextjs-app/src/lib/i18n/helper.ts | 28 ++++ .../src/lib/i18n/staticPageLocale.ts | 30 ++++ apps/nextjs-app/src/pages/402.tsx | 13 +- apps/nextjs-app/src/pages/403.tsx | 13 +- apps/nextjs-app/src/pages/404.tsx | 61 ++++++-- apps/nextjs-app/src/pages/_error.tsx | 43 +++--- .../grid/components/LoadingIndicator.tsx | 17 ++- 10 files changed, 318 insertions(+), 83 deletions(-) create mode 100644 apps/nestjs-backend/test/lin-field-not-null.e2e-spec.ts create mode 100644 apps/nextjs-app/src/lib/i18n/helper.ts create mode 100644 apps/nextjs-app/src/lib/i18n/staticPageLocale.ts diff --git a/apps/nestjs-backend/src/features/field/field.service.ts b/apps/nestjs-backend/src/features/field/field.service.ts index b7c5473891..ced32ba35d 100644 --- a/apps/nestjs-backend/src/features/field/field.service.ts +++ b/apps/nestjs-backend/src/features/field/field.service.ts @@ -751,8 +751,7 @@ export class FieldService implements IReadonlyAdapterService { : matchedIndexes.forEach((indexName) => table.dropUnique([dbFieldName], indexName)); } - // TODO: add to db provider - if (key === 'notNull' && type !== FieldType.Link) { + if (key === 'notNull') { newValue ? table.dropNullable(dbFieldName) : table.setNullable(dbFieldName); } }) diff --git a/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts b/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts index 72a95cf45d..98f4171edc 100644 --- a/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts +++ b/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts @@ -4,6 +4,7 @@ import type { IFieldRo, ILinkFieldOptions, ILookupOptionsRo } from '@teable/core import { DriverClient, FieldAIActionType, + FieldKeyType, FieldType, Relationship, Role, @@ -20,6 +21,7 @@ import { createPluginPanel, createSpace, deleteBase, + deleteRecords, deleteSpace, duplicateBase, EMAIL_SPACE_INVITATION, @@ -735,7 +737,14 @@ describe('OpenAPI Base Duplicate (e2e)', () => { it('should duplicate base with bidirectional link field', async () => { const table1 = await createTable(base.id, { name: 'table1' }); const table2 = await createTable(base.id, { name: 'table2' }); - + await deleteRecords( + table1.id, + table1.records.map((r) => r.id) + ); + await deleteRecords( + table2.id, + table2.records.map((r) => r.id) + ); // Create bidirectional link field with dbFieldName 'link' const linkFieldRo: IFieldRo = { name: 'link field', @@ -758,34 +767,31 @@ describe('OpenAPI Base Duplicate (e2e)', () => { ...linkFieldRo, notNull: true, }); - + await createRecords(table2.id, { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: {} }, { fields: {} }, { fields: {} }], + }); // Get records - const table1Records = await getRecords(table1.id); const table2Records = await getRecords(table2.id); - - // Fill all link relationships - await updateRecord(table1.id, table1Records.records[0].id, { - record: { - fields: { - [linkField.name]: [{ id: table2Records.records[0].id }], + await createRecords(table1.id, { + fieldKeyType: FieldKeyType.Name, + records: [ + { + fields: { + [linkField.name]: [{ id: table2Records.records[0].id }], + }, }, - }, - }); - - await updateRecord(table1.id, table1Records.records[1].id, { - record: { - fields: { - [linkField.name]: [{ id: table2Records.records[1].id }], + { + fields: { + [linkField.name]: [{ id: table2Records.records[1].id }], + }, }, - }, - }); - - await updateRecord(table1.id, table1Records.records[2].id, { - record: { - fields: { - [linkField.name]: [{ id: table2Records.records[2].id }], + { + fields: { + [linkField.name]: [{ id: table2Records.records[2].id }], + }, }, - }, + ], }); // Duplicate base with records diff --git a/apps/nestjs-backend/test/lin-field-not-null.e2e-spec.ts b/apps/nestjs-backend/test/lin-field-not-null.e2e-spec.ts new file mode 100644 index 0000000000..474c675734 --- /dev/null +++ b/apps/nestjs-backend/test/lin-field-not-null.e2e-spec.ts @@ -0,0 +1,139 @@ +/** + * T1756: Link field NOT NULL constraint sync bug + * + * Steps to reproduce: + * 1. Create a Number field + * 2. Set notNull=true on the Number field + * 3. Convert it to a Link field + * 4. Edit the Link field and turn off notNull + * 5. Try to create a record with empty Link value - FAILS because DB constraint still exists + */ +import type { INestApplication } from '@nestjs/common'; +import { FieldKeyType, FieldType, Relationship } from '@teable/core'; +import type { ITableFullVo } from '@teable/openapi'; +import { + createField, + createTable, + convertField, + createRecords, + getField, + initApp, + permanentDeleteTable, + deleteRecords, + getRecords, +} from './utils/init-app'; + +describe('T1756: Link field NOT NULL constraint sync bug', () => { + let app: INestApplication; + const baseId = globalThis.testConfig.baseId; + + beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; + }); + + afterAll(async () => { + await app.close(); + }); + + describe('bug reproduction', () => { + let table1: ITableFullVo; + let table2: ITableFullVo; + + beforeEach(async () => { + table1 = await createTable(baseId, { name: `table1-${Date.now()}` }); + table2 = await createTable(baseId, { name: `table2-${Date.now()}` }); + + // Clear default records + const records1 = await getRecords(table1.id); + const records2 = await getRecords(table2.id); + if (records1.records.length) { + await deleteRecords( + table1.id, + records1.records.map((r) => r.id) + ); + } + if (records2.records.length) { + await deleteRecords( + table2.id, + records2.records.map((r) => r.id) + ); + } + }); + + afterEach(async () => { + await permanentDeleteTable(baseId, table1.id); + await permanentDeleteTable(baseId, table2.id); + }); + + it('should allow creating record with empty Link after removing notNull constraint', async () => { + // Step 1: Create a Number field + const numberField = await createField(table1.id, { + name: 'TestField', + type: FieldType.Number, + }); + + // Step 2: Set notNull=true on the Number field + await convertField(table1.id, numberField.id, { + ...numberField, + notNull: true, + }); + + // Step 3: Convert to Link field + const linkField = await convertField(table1.id, numberField.id, { + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: table2.id, + }, + }); + + // Step 4: Turn off notNull on the Link field + const linkFieldFull = await getField(table1.id, linkField.id); + const updatedLinkField = await convertField(table1.id, linkField.id, { + ...linkFieldFull, + notNull: false, + }); + + // Verify metadata shows notNull is false + expect(updatedLinkField.notNull).toBeFalsy(); + + // Step 5: Try to create a record with empty Link value + // BUG: This should succeed since notNull is false in metadata + // But it fails because DB still has NOT NULL constraint + const result = await createRecords( + table1.id, + { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: {} }], // Empty record, no Link value + }, + 201 // Expect success (201), but will get 500 due to DB constraint + ); + + expect(result.records).toHaveLength(1); + }); + + it('should not allow creating record with empty Link after setting notNull constraint', async () => { + const linkField = await createField(table1.id, { + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: table2.id, + }, + }); + const linkFieldFull = await getField(table1.id, linkField.id); + await convertField(table1.id, linkField.id, { + ...linkFieldFull, + notNull: true, + }); + await createRecords( + table1.id, + { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: {} }], // Empty record, no Link value + }, + 400 // Expect success (201), but will get 500 due to DB constraint + ); + }); + }); +}); diff --git a/apps/nextjs-app/src/lib/i18n/helper.ts b/apps/nextjs-app/src/lib/i18n/helper.ts new file mode 100644 index 0000000000..b22935fa6c --- /dev/null +++ b/apps/nextjs-app/src/lib/i18n/helper.ts @@ -0,0 +1,28 @@ +import { acceptLanguage } from './acceptHeader'; + +export const getLocaleFromCookie = (cookie: string): string | null => { + if (!cookie) return null; + const match = cookie.match(/NEXT_LOCALE=([^;]+)/); + return match?.[1] || null; +}; + +export const getLocaleFromBrowser = (): string => { + if (typeof navigator === 'undefined') return 'en'; + const browserLang = navigator.language || (navigator as { userLanguage?: string }).userLanguage; + if (!browserLang) return 'en'; + // Extract primary language code (e.g., 'zh-CN' -> 'zh', 'en-US' -> 'en') + return browserLang.split('-')[0]; +}; + +export const getLocaleFromAcceptLanguage = ( + acceptLanguageHeader: string | undefined, + supportedLocales: string[] +): string | null => { + if (!acceptLanguageHeader) return null; + try { + const locale = acceptLanguage(acceptLanguageHeader, supportedLocales); + return locale || null; + } catch { + return null; + } +}; diff --git a/apps/nextjs-app/src/lib/i18n/staticPageLocale.ts b/apps/nextjs-app/src/lib/i18n/staticPageLocale.ts new file mode 100644 index 0000000000..c5b5686051 --- /dev/null +++ b/apps/nextjs-app/src/lib/i18n/staticPageLocale.ts @@ -0,0 +1,30 @@ +import { getLocaleFromBrowser, getLocaleFromCookie } from './helper'; +export * from './helper'; + +type LocaleLoader = () => Promise<{ default: Record }>; + +export const detectStaticLocale = (cookie: string): string => { + return getLocaleFromCookie(cookie) ?? getLocaleFromBrowser(); +}; + +export const systemLocaleLoaders: Record = { + en: () => import('@teable/common-i18n/src/locales/en/system.json'), + it: () => import('@teable/common-i18n/src/locales/it/system.json'), + zh: () => import('@teable/common-i18n/src/locales/zh/system.json'), + fr: () => import('@teable/common-i18n/src/locales/fr/system.json'), + ja: () => import('@teable/common-i18n/src/locales/ja/system.json'), + ru: () => import('@teable/common-i18n/src/locales/ru/system.json'), + de: () => import('@teable/common-i18n/src/locales/de/system.json'), + uk: () => import('@teable/common-i18n/src/locales/uk/system.json'), + tr: () => import('@teable/common-i18n/src/locales/tr/system.json'), + es: () => import('@teable/common-i18n/src/locales/es/system.json'), +}; + +export const loadSystemTranslations = async (locale: string) => { + try { + const loader = systemLocaleLoaders[locale] ?? systemLocaleLoaders.en; + return (await loader()).default; + } catch { + return (await systemLocaleLoaders.en()).default; + } +}; diff --git a/apps/nextjs-app/src/pages/402.tsx b/apps/nextjs-app/src/pages/402.tsx index 1ada9effee..2389cae69b 100644 --- a/apps/nextjs-app/src/pages/402.tsx +++ b/apps/nextjs-app/src/pages/402.tsx @@ -1,17 +1,12 @@ -import type { GetStaticPropsContext } from 'next'; +import type { GetServerSideProps } from 'next'; import { systemConfig } from '@/features/i18n/system.config'; import { PaymentRequiredPage } from '@/features/system/pages'; -import { getServerSideTranslations } from '@/lib/i18n'; - -export const getStaticProps = async (context: GetStaticPropsContext) => { - const { locale = 'en' } = context; - - const inlinedTranslation = await getServerSideTranslations(locale, systemConfig.i18nNamespaces); +import { getTranslationsProps } from '@/lib/i18n'; +export const getServerSideProps: GetServerSideProps = async (context) => { return { props: { - locale: locale, - ...inlinedTranslation, + ...(await getTranslationsProps(context, systemConfig.i18nNamespaces)), }, }; }; diff --git a/apps/nextjs-app/src/pages/403.tsx b/apps/nextjs-app/src/pages/403.tsx index 56bc9c5fb6..10967e5de5 100644 --- a/apps/nextjs-app/src/pages/403.tsx +++ b/apps/nextjs-app/src/pages/403.tsx @@ -1,17 +1,12 @@ -import type { GetStaticPropsContext } from 'next'; +import type { GetServerSideProps } from 'next'; import { systemConfig } from '@/features/i18n/system.config'; import { ForbiddenPage } from '@/features/system/pages'; -import { getServerSideTranslations } from '@/lib/i18n'; - -export const getStaticProps = async (context: GetStaticPropsContext) => { - const { locale = 'en' } = context; - - const inlinedTranslation = await getServerSideTranslations(locale, systemConfig.i18nNamespaces); +import { getTranslationsProps } from '@/lib/i18n'; +export const getServerSideProps: GetServerSideProps = async (context) => { return { props: { - locale: locale, - ...inlinedTranslation, + ...(await getTranslationsProps(context, systemConfig.i18nNamespaces)), }, }; }; diff --git a/apps/nextjs-app/src/pages/404.tsx b/apps/nextjs-app/src/pages/404.tsx index eb8c982b21..b42ba634fe 100644 --- a/apps/nextjs-app/src/pages/404.tsx +++ b/apps/nextjs-app/src/pages/404.tsx @@ -1,21 +1,60 @@ -import type { GetStaticPropsContext, InferGetStaticPropsType } from 'next'; +import type { GetStaticProps } from 'next'; +import { useTranslation } from 'next-i18next'; +import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; +import { useEffect, useState } from 'react'; import { systemConfig } from '@/features/i18n/system.config'; import { NotFoundPage } from '@/features/system/pages'; -import { getServerSideTranslations } from '@/lib/i18n'; - -export const getStaticProps = async (context: GetStaticPropsContext) => { - const { locale = 'en' } = context; - - const inlinedTranslation = await getServerSideTranslations(locale, systemConfig.i18nNamespaces); +import { + detectStaticLocale, + loadSystemTranslations, + systemLocaleLoaders, +} from '@/lib/i18n/staticPageLocale'; +export const getStaticProps: GetStaticProps = async () => { return { props: { - locale: locale, - ...inlinedTranslation, + ...(await serverSideTranslations('en', systemConfig.i18nNamespaces)), }, }; }; -export default function Custom404(_props: InferGetStaticPropsType) { - return ; +export default function Custom404() { + const { i18n } = useTranslation(); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + const detectedLocale = detectStaticLocale(document.cookie); + const validLocale = systemLocaleLoaders[detectedLocale] ? detectedLocale : 'en'; + + // If locale matches current i18n locale, ready immediately + if (validLocale === i18n.language) { + setIsReady(true); + return; + } + + // Load translations for detected locale and update i18n + loadSystemTranslations(validLocale) + .then((translations) => { + i18n.addResourceBundle(validLocale, 'system', translations, true, true); + return i18n.changeLanguage(validLocale); + }) + .catch((error) => { + // Ensure UI remains usable even if translation loading or language change fails + console.error('Failed to load translations or change language for 404 page:', error); + }) + .finally(() => { + setIsReady(true); + }); + }, [i18n]); + + return ( +
+ +
+ ); } diff --git a/apps/nextjs-app/src/pages/_error.tsx b/apps/nextjs-app/src/pages/_error.tsx index f86b004c62..a7e76d10bc 100644 --- a/apps/nextjs-app/src/pages/_error.tsx +++ b/apps/nextjs-app/src/pages/_error.tsx @@ -8,6 +8,12 @@ import type { NextPage, NextPageContext } from 'next'; import NextErrorComponent from 'next/error'; import type { ErrorProps } from 'next/error'; import { ErrorPage } from '@/features/system/pages'; +import { + systemLocaleLoaders, + loadSystemTranslations, + getLocaleFromCookie, + getLocaleFromAcceptLanguage, +} from '@/lib/i18n/staticPageLocale'; const sentryIgnoredStatusCodes: number[] = [404, 410]; @@ -57,30 +63,6 @@ const sentryFlushServerSide = async (flushAfter: number) => { } }; -type LocaleLoader = () => Promise<{ default: Record }>; - -const localeLoaders: Record = { - en: () => import('@teable/common-i18n/src/locales/en/system.json'), - it: () => import('@teable/common-i18n/src/locales/it/system.json'), - zh: () => import('@teable/common-i18n/src/locales/zh/system.json'), - fr: () => import('@teable/common-i18n/src/locales/fr/system.json'), - ja: () => import('@teable/common-i18n/src/locales/ja/system.json'), - ru: () => import('@teable/common-i18n/src/locales/ru/system.json'), - de: () => import('@teable/common-i18n/src/locales/de/system.json'), - uk: () => import('@teable/common-i18n/src/locales/uk/system.json'), - tr: () => import('@teable/common-i18n/src/locales/tr/system.json'), - es: () => import('@teable/common-i18n/src/locales/es/system.json'), -}; - -const loadSystemTranslations = async (locale: string) => { - try { - const loader = localeLoaders[locale] ?? localeLoaders.en; - return (await loader()).default; - } catch { - return (await localeLoaders.en()).default; - } -}; - const CustomError: NextPage = (props) => { const { statusCode, err, hasGetInitialPropsRun, sentryErrorId, message } = props; @@ -103,8 +85,17 @@ const CustomError: NextPage = (props) => { }; CustomError.getInitialProps = async (context: AugmentedNextPageContext) => { - const { res, err, asPath } = context; - const locale = localeLoaders[context.locale ?? ''] ? (context.locale as string) : 'en'; + const { res, err, asPath, req } = context; + + const supportedLocales = Object.keys(systemLocaleLoaders); + // Detect locale: prefer context.locale, fallback to cookie, then Accept-Language header, default to 'en' + const cookieLocale = getLocaleFromCookie(req?.headers?.cookie ?? ''); + const acceptLangLocale = getLocaleFromAcceptLanguage( + req?.headers?.['accept-language'], + supportedLocales + ); + const detectedLocale = context.locale || cookieLocale || acceptLangLocale || 'en'; + const locale = systemLocaleLoaders[detectedLocale] ? detectedLocale : 'en'; const errorInitialProps = (await NextErrorComponent.getInitialProps({ res, diff --git a/packages/sdk/src/components/grid/components/LoadingIndicator.tsx b/packages/sdk/src/components/grid/components/LoadingIndicator.tsx index e978063d5c..e1149f51bc 100644 --- a/packages/sdk/src/components/grid/components/LoadingIndicator.tsx +++ b/packages/sdk/src/components/grid/components/LoadingIndicator.tsx @@ -1,5 +1,6 @@ /* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ import { MagicAi, Square } from '@teable/icons'; +import { useMemo } from 'react'; import type { ICellItem, IColumnLoading, IScrollState } from '../interface'; import type { CoordinateManager } from '../managers'; @@ -14,7 +15,19 @@ export interface ILoadingIndicatorProps { export const LoadingIndicator = (props: ILoadingIndicatorProps) => { const { cellLoadings, columnLoadings, coordInstance, scrollState, real2RowIndex } = props; - if (!cellLoadings.length && !columnLoadings.length) return null; + // Deduplicate cellLoadings to prevent duplicate React keys + // This can happen when backend returns duplicate {recordId, fieldId} entries + const uniqueCellLoadings = useMemo(() => { + const seen = new Set(); + return cellLoadings.filter(([columnIndex, realRowIndex]) => { + const key = `${columnIndex}-${realRowIndex}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }, [cellLoadings]); + + if (!uniqueCellLoadings.length && !columnLoadings.length) return null; const { scrollLeft, scrollTop } = scrollState; const { rowInitSize, freezeColumnCount, freezeRegionWidth, containerWidth, containerHeight } = @@ -64,7 +77,7 @@ export const LoadingIndicator = (props: ILoadingIndicatorProps) => { ); })} - {cellLoadings.map(([columnIndex, realRowIndex]) => { + {uniqueCellLoadings.map(([columnIndex, realRowIndex]) => { const rowIndex = real2RowIndex(realRowIndex); const rowHeight = coordInstance.getRowHeight(rowIndex); const rowOffset = coordInstance.getRowOffset(rowIndex);