diff --git a/apps/nestjs-backend/src/features/ai/ai.service.ts b/apps/nestjs-backend/src/features/ai/ai.service.ts index 562d0a48cf..975b2379c6 100644 --- a/apps/nestjs-backend/src/features/ai/ai.service.ts +++ b/apps/nestjs-backend/src/features/ai/ai.service.ts @@ -63,6 +63,27 @@ export class AiService { return { type, model, name }; } + /** + * Resolve the model key by matching a body model ID against chatModel lg/md/sm values. + * Model keys are in format type@modelId@name — we compare the modelId segment. + * Falls back to lg if no match is found. + */ + public resolveModelKeyFromBody( + chatModel: { lg?: string; md?: string; sm?: string } | undefined, + bodyModel?: string + ): string | undefined { + if (bodyModel) { + const sizes = ['lg', 'md', 'sm'] as const; + for (const size of sizes) { + const key = chatModel?.[size]; + if (key && this.parseModelKey(key).model === bodyModel) { + return key; + } + } + } + return chatModel?.lg; + } + /** * Check if modelKey is an AI Gateway model * Format: aiGateway@@teable @@ -554,6 +575,8 @@ export class AiService { ability: chatModel?.ability, isInstance, lgModelKey: chatModel.lg, + mdModelKey: chatModel.md, + smModelKey: chatModel.sm, }; } diff --git a/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts b/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts index 177a76fe00..b940472820 100644 --- a/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts +++ b/apps/nestjs-backend/src/features/auth/guard/permission.guard.ts @@ -155,12 +155,15 @@ export class PermissionGuard { resourceId, permissions ); - // Set user to anonymous for share context - this.cls.set('user', { - id: ANONYMOUS_USER_ID, - name: ANONYMOUS_USER_ID, - email: '', - }); + // Preserve logged-in user identity for allowEdit; fall back to anonymous + const currentUserId = this.cls.get('user.id'); + if (!currentUserId || isAnonymous(currentUserId)) { + this.cls.set('user', { + id: ANONYMOUS_USER_ID, + name: ANONYMOUS_USER_ID, + email: '', + }); + } this.cls.set('permissions', ownPermissions); return true; } @@ -297,6 +300,15 @@ export class PermissionGuard { if (!shareId) { return undefined; } + // Skip share path for endpoints without @Permissions (e.g. /user/me), + // otherwise baseSharePermissionCheck throws ForbiddenException. + const permissions = this.reflector.getAllAndOverride(PERMISSIONS_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!permissions?.length) { + return undefined; + } return await this.baseSharePermissionCheck(context, shareId); } @@ -383,9 +395,10 @@ export class PermissionGuard { * * Priority flow: * 1. RESOURCE-level: exclusively use resource-specific auth (base share > template) - * 2. Early base share check for PUBLIC or anonymous requests when header is present + * 2. Share link check — when share header is present, share permissions are the ceiling + * for ALL users (anonymous or authenticated), so personal role never exceeds the link * 3. Anonymous user handling (template / USER-level) - * 4. Authenticated user: standard check, with fallback for PUBLIC endpoints + * 4. Authenticated user: standard check, with PUBLIC fallback */ protected async permissionCheckWithPublicFallback( context: ExecutionContext, @@ -406,10 +419,8 @@ export class PermissionGuard { // No valid resource auth header — fall through to normal checks } - // 2. Early base share check for PUBLIC or anonymous requests - const shouldTryBaseShareEarly = - baseShareHeader && (allowAnonymousType === AllowAnonymousType.PUBLIC || this.isAnonymous()); - if (shouldTryBaseShareEarly) { + // 2. Share link — permissions are bounded by the link, regardless of user role + if (baseShareHeader) { const result = await this.tryBaseSharePermissionCheck(context, baseShareHeader); if (result !== undefined) return result; } @@ -419,7 +430,7 @@ export class PermissionGuard { return this.resolveAnonymousPermission(context, allowAnonymousType); } - // 4. Authenticated user: standard check, with fallback for PUBLIC endpoints + // 4. Authenticated user: standard check, with PUBLIC fallback try { return await permissionCheck(); } catch (error) { diff --git a/apps/nestjs-backend/src/features/auth/permission.service.ts b/apps/nestjs-backend/src/features/auth/permission.service.ts index 9c899d1d01..325113feeb 100644 --- a/apps/nestjs-backend/src/features/auth/permission.service.ts +++ b/apps/nestjs-backend/src/features/auth/permission.service.ts @@ -4,6 +4,7 @@ import type { IBaseRole, Action } from '@teable/core'; import { HttpErrorCode, IdPrefix, + Role, TemplatePermissions, getPermissions, isAnonymous, @@ -27,6 +28,18 @@ interface IBaseNodeCacheItem { const notAllowedOperationI18nKey = 'httpErrors.permission.notAllowedOperation'; +/** + * Permissions that must never be granted via share links, + * even when allowEdit is enabled with a logged-in user. + */ +const SHARE_EXCLUDED_PERMISSIONS = new Set([ + 'view|share', + 'space|invite_email', + 'base|invite_email', + 'user|email_read', + 'user|integrations', +]); + @Injectable() export class PermissionService { private readonly logger = new Logger(PermissionService.name); @@ -627,7 +640,13 @@ export class PermissionService { // Set base share in cls for downstream services to use this.cls.set('baseShare', { baseId, nodeId }); - // Return template permissions (read-only), with record|copy if allowCopy is enabled + // When allowEdit is enabled and user is logged in, grant editor-level permissions + // excluding invite/share/privacy-sensitive actions + if (baseShare.allowEdit && !this.isAnonymous()) { + return getPermissions(Role.Editor).filter((p) => !SHARE_EXCLUDED_PERMISSIONS.has(p)); + } + + // Otherwise return template permissions (read-only), with record|copy if allowCopy is enabled const permissions = [...TemplatePermissions]; if (baseShare.allowCopy) { permissions.push('record|copy'); diff --git a/apps/nestjs-backend/src/features/base-share/base-share-auth.service.ts b/apps/nestjs-backend/src/features/base-share/base-share-auth.service.ts index f218bc0ed5..7796b2fc4f 100644 --- a/apps/nestjs-backend/src/features/base-share/base-share-auth.service.ts +++ b/apps/nestjs-backend/src/features/base-share/base-share-auth.service.ts @@ -10,6 +10,7 @@ export interface IBaseShareInfo { nodeId: string; allowSave: boolean | null; allowCopy: boolean | null; + allowEdit: boolean | null; } export interface IJwtBaseShareInfo { @@ -84,6 +85,7 @@ export class BaseShareAuthService { nodeId: share.nodeId, allowSave: share.allowSave, allowCopy: share.allowCopy, + allowEdit: share.allowEdit, }; } diff --git a/apps/nestjs-backend/src/features/base-share/base-share-open.controller.ts b/apps/nestjs-backend/src/features/base-share/base-share-open.controller.ts index 2cbc512f93..8e4a685ee7 100644 --- a/apps/nestjs-backend/src/features/base-share/base-share-open.controller.ts +++ b/apps/nestjs-backend/src/features/base-share/base-share-open.controller.ts @@ -61,7 +61,7 @@ export class BaseShareOpenController { @Request() req: Express.Request & { baseShareInfo: IBaseShareInfo } ): Promise { const shareInfo = req.baseShareInfo; - const { baseId, nodeId, allowSave, allowCopy } = shareInfo; + const { baseId, nodeId, allowSave, allowCopy, allowEdit } = shareInfo; // Build default URL for redirect const defaultUrl = await this.buildDefaultUrl(baseId, nodeId); @@ -73,6 +73,7 @@ export class BaseShareOpenController { nodeId, allowSave, allowCopy, + allowEdit, }, defaultUrl, }; diff --git a/apps/nestjs-backend/src/features/base-share/base-share.service.ts b/apps/nestjs-backend/src/features/base-share/base-share.service.ts index 066e55bde2..c426623ab7 100644 --- a/apps/nestjs-backend/src/features/base-share/base-share.service.ts +++ b/apps/nestjs-backend/src/features/base-share/base-share.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { generateShareId, HttpErrorCode } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { ICreateBaseShareRo, IUpdateBaseShareRo, IBaseShareVo } from '@teable/openapi'; +import { BaseNodeResourceType } from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; import { CustomHttpException } from '../../custom.exception'; import { PerformanceCache, PerformanceCacheService } from '../../performance-cache'; @@ -24,6 +25,30 @@ export class BaseShareService { await this.performanceCacheService.del(generateBaseShareListCacheKey(baseId)); } + private async isTableNode(nodeId: string): Promise { + const node = await this.prismaService.baseNode.findFirst({ + where: { id: nodeId }, + select: { resourceType: true }, + }); + return node?.resourceType === BaseNodeResourceType.Table; + } + + /** + * allowEdit and allowSave are mutually exclusive: + * allowEdit=true → allowSave must be false + * allowSave=true → allowEdit must be false + */ + private resolveEditSaveFlags( + allowEdit: boolean | null | undefined, + allowSave: boolean | null | undefined + ): { allowEdit: boolean | null; allowSave: boolean | null } { + const edit = allowEdit ?? null; + const save = allowSave ?? null; + if (edit) return { allowEdit: true, allowSave: false }; + if (save) return { allowEdit: false, allowSave: true }; + return { allowEdit: edit, allowSave: save }; + } + private formatBaseShareVo(share: { baseId: string; shareId: string; @@ -31,6 +56,7 @@ export class BaseShareService { nodeId: string; allowSave: boolean | null; allowCopy: boolean | null; + allowEdit: boolean | null; enabled: boolean; }): IBaseShareVo { return { @@ -40,11 +66,20 @@ export class BaseShareService { nodeId: share.nodeId, allowSave: share.allowSave, allowCopy: share.allowCopy, + allowEdit: share.allowEdit, enabled: share.enabled, }; } async createBaseShare(baseId: string, data: ICreateBaseShareRo): Promise { + // allowEdit is only valid for table nodes + if (data.allowEdit && !(await this.isTableNode(data.nodeId))) { + throw new CustomHttpException( + 'allowEdit is only supported for table nodes', + HttpErrorCode.VALIDATION_ERROR + ); + } + const userId = this.cls.get('user.id'); // Check if a share already exists for this node @@ -54,13 +89,25 @@ export class BaseShareService { if (existingShare) { // If existing share is disabled, re-enable it if (!existingShare.enabled) { + const resolvedEdit = data.allowEdit ?? existingShare.allowEdit; + if (resolvedEdit && !(await this.isTableNode(data.nodeId))) { + throw new CustomHttpException( + 'allowEdit is only supported for table nodes', + HttpErrorCode.VALIDATION_ERROR + ); + } + const { allowEdit, allowSave } = this.resolveEditSaveFlags( + resolvedEdit, + data.allowSave ?? existingShare.allowSave + ); const updated = await this.prismaService.baseShare.update({ where: { id: existingShare.id }, data: { enabled: true, password: data.password || existingShare.password, - allowSave: data.allowSave ?? existingShare.allowSave, + allowSave, allowCopy: data.allowCopy ?? existingShare.allowCopy, + allowEdit, }, }); // Invalidate cache when re-enabling share @@ -79,14 +126,16 @@ export class BaseShareService { } const shareId = generateShareId(); + const { allowEdit, allowSave } = this.resolveEditSaveFlags(data.allowEdit, data.allowSave); const share = await this.prismaService.baseShare.create({ data: { baseId, shareId, password: data.password || null, nodeId: data.nodeId, - allowSave: data.allowSave, + allowSave, allowCopy: data.allowCopy, + allowEdit, createdBy: userId, }, }); @@ -144,12 +193,25 @@ export class BaseShareService { }); } + if (data.allowEdit && !(await this.isTableNode(share.nodeId))) { + throw new CustomHttpException( + 'allowEdit is only supported for table nodes', + HttpErrorCode.VALIDATION_ERROR + ); + } + + const { allowEdit, allowSave } = this.resolveEditSaveFlags( + data.allowEdit !== undefined ? data.allowEdit : share.allowEdit, + data.allowSave !== undefined ? data.allowSave : share.allowSave + ); + const updated = await this.prismaService.baseShare.update({ where: { id: share.id }, data: { password: data.password !== undefined ? data.password : share.password, - allowSave: data.allowSave !== undefined ? data.allowSave : share.allowSave, + allowSave, allowCopy: data.allowCopy !== undefined ? data.allowCopy : share.allowCopy, + allowEdit, enabled: data.enabled !== undefined ? data.enabled : share.enabled, }, }); diff --git a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts index e08145bb77..be5cfbe6a6 100644 --- a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts +++ b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts @@ -3,7 +3,11 @@ import type { IV2BaseSchemaIntegrityRepairRo, IV2SchemaIntegrityFilterStatus, IV2SchemaIntegrityCheckResult, + IV2SchemaIntegrityI18nMessage, + IV2SchemaIntegrityManualRepairSchema, + IV2SchemaIntegrityManualRepairSchemaProperty, IV2SchemaIntegrityRepairResult, + IV2SchemaIntegrityRepairCapability, IV2SchemaIntegrityRepairRo, } from '@teable/openapi'; import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; @@ -13,6 +17,7 @@ import { PostgresSchemaIntrospector, type SchemaCheckResult, type SchemaRepairResult, + type SchemaRuleRepairHint, } from '@teable/v2-adapter-table-repository-postgres'; import { BaseId, @@ -67,6 +72,7 @@ export class IntegrityV2Service { table, repairer.repairRule(table, repairRo.fieldId, repairRo.ruleId, { dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, }), repairRo.statuses ); @@ -77,6 +83,7 @@ export class IntegrityV2Service { table, repairer.repairField(table, repairRo.fieldId, { dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, }), repairRo.statuses ); @@ -86,6 +93,7 @@ export class IntegrityV2Service { table, repairer.repairTable(table, { dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, }), repairRo.statuses ); @@ -206,7 +214,10 @@ export class IntegrityV2Service { for (const table of tables) { yield* this.decorateRepairStream( table, - repairer.repairTable(table, { dryRun: repairRo.dryRun }), + repairer.repairTable(table, { + dryRun: repairRo.dryRun, + targetStatuses: repairRo.targetStatuses, + }), repairRo.statuses ); } @@ -261,9 +272,12 @@ export class IntegrityV2Service { details: result.details ? { missing: this.toMutableArray(result.details.missing), + missingItems: this.toMutableDetailItems(result.details.missingItems), extra: this.toMutableArray(result.details.extra), + extraItems: this.toMutableDetailItems(result.details.extraItems), } : undefined, + repair: result.repair ? this.toMutableRepairHint(result.repair) : undefined, required: result.required, timestamp: result.timestamp, dependencies: result.dependencies.map((depId) => this.createScopedResultId(table, depId)), @@ -289,10 +303,13 @@ export class IntegrityV2Service { details: result.details ? { missing: this.toMutableArray(result.details.missing), + missingItems: this.toMutableDetailItems(result.details.missingItems), extra: this.toMutableArray(result.details.extra), + extraItems: this.toMutableDetailItems(result.details.extraItems), statementCount: result.details.statementCount, } : undefined, + repair: result.repair ? this.toMutableRepairHint(result.repair) : undefined, required: result.required, timestamp: result.timestamp, dependencies: result.dependencies.map((depId) => this.createScopedResultId(table, depId)), @@ -308,6 +325,127 @@ export class IntegrityV2Service { return values ? [...values] : undefined; } + private toMutableDetailItems( + items?: ReadonlyArray<{ + code?: string; + message: { + key?: string; + values?: Readonly>; + fallback?: string; + }; + description?: { + key?: string; + values?: Readonly>; + fallback?: string; + }; + }> + ) { + return items?.map((item) => ({ + code: item.code, + message: { + key: item.message.key, + values: item.message.values ? { ...item.message.values } : undefined, + fallback: item.message.fallback, + }, + description: item.description + ? { + key: item.description.key, + values: item.description.values ? { ...item.description.values } : undefined, + fallback: item.description.fallback, + } + : undefined, + })); + } + + private toMutableRepairHint(result: SchemaRuleRepairHint) { + const toMutableMessage = (message?: { + key?: string; + values?: Readonly>; + fallback?: string; + }): IV2SchemaIntegrityI18nMessage | undefined => { + if (!message) { + return undefined; + } + + return { + key: message.key, + values: message.values ? { ...message.values } : undefined, + fallback: message.fallback, + }; + }; + + const toMutableManualRepairProperty = (property: { + type: 'string' | 'boolean'; + widget?: 'select' | 'text' | 'textarea' | 'checkbox'; + title?: { + key?: string; + values?: Readonly>; + fallback?: string; + }; + description?: { + key?: string; + values?: Readonly>; + fallback?: string; + }; + options?: ReadonlyArray<{ + value: string; + label: { + key?: string; + values?: Readonly>; + fallback?: string; + }; + description?: { + key?: string; + values?: Readonly>; + fallback?: string; + }; + }>; + defaultValue?: string | boolean; + }): IV2SchemaIntegrityManualRepairSchemaProperty => ({ + type: property.type, + widget: property.widget, + title: toMutableMessage(property.title), + description: toMutableMessage(property.description), + options: property.options?.map((option) => ({ + value: option.value, + label: { + key: option.label.key, + values: option.label.values ? { ...option.label.values } : undefined, + fallback: option.label.fallback, + }, + description: toMutableMessage(option.description), + })), + defaultValue: property.defaultValue, + }); + + const manualRepairSchema: IV2SchemaIntegrityManualRepairSchema | undefined = + result.manualRepairSchema + ? { + type: result.manualRepairSchema.type, + title: toMutableMessage(result.manualRepairSchema.title), + description: toMutableMessage(result.manualRepairSchema.description), + submitLabel: toMutableMessage(result.manualRepairSchema.submitLabel), + required: result.manualRepairSchema.required + ? [...result.manualRepairSchema.required] + : undefined, + properties: Object.fromEntries( + Object.entries(result.manualRepairSchema.properties).map(([key, property]) => [ + key, + toMutableManualRepairProperty(property), + ]) + ), + } + : undefined; + + return { + available: result.available, + mode: result.mode, + reason: toMutableMessage(result.reason), + description: toMutableMessage(result.description), + manualRepairSchema, + } satisfies IV2SchemaIntegrityRepairCapability; + } + private createStatusFilterSet(statuses?: IV2SchemaIntegrityFilterStatus[]) { return statuses?.length ? new Set(statuses) : undefined; } diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts index 7184013da9..80f2af26d9 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts @@ -1856,7 +1856,7 @@ export class RecordOpenApiV2Service { tableId, [duplicatedRecordId], undefined, - FieldKeyType.Name, + FieldKeyType.Id, undefined, true ); diff --git a/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.service.ts b/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.service.ts index ab1a29daba..53736f6bff 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.service.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/record-query-builder.service.ts @@ -325,9 +325,9 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { const rawChoices = (field.options as { choices?: { name: string }[] } | undefined)?.choices; const choices = Array.isArray(rawChoices) ? rawChoices : []; if (choices.length) { - const arrayLiteral = `ARRAY[${choices - .map(({ name }) => this.knex.raw('?', [name]).toQuery()) - .join(', ')}]`; + const choiceNames = choices.map(({ name }) => name); + const placeholders = choiceNames.map(() => '?').join(', '); + const arrayLiteral = `ARRAY[${placeholders}]`; if (field.type === FieldType.MultipleSelect) { const firstIndexExpr = `CASE @@ -336,7 +336,11 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { THEN ARRAY_POSITION(${arrayLiteral}, jsonb_path_query_first(${orderableSelection}::jsonb, '$[0]') #>> '{}') ELSE ARRAY_POSITION(${arrayLiteral}, ${orderableSelection}::text) END`; - qb.orderByRaw(`${firstIndexExpr} ${direction} ${nullOrdering}`); + // arrayLiteral appears twice in firstIndexExpr, so duplicate bindings + qb.orderByRaw(`${firstIndexExpr} ${direction} ${nullOrdering}`, [ + ...choiceNames, + ...choiceNames, + ]); qb.orderByRaw(`${orderableSelection}::jsonb::text ${direction} ${nullOrdering}`); return; } else { @@ -345,7 +349,7 @@ export class RecordQueryBuilderService implements IRecordQueryBuilder { field.dbFieldType ); const arrayPositionExpr = `ARRAY_POSITION(${arrayLiteral}, ${normalizedExpr})`; - qb.orderByRaw(`${arrayPositionExpr} ${direction} ${nullOrdering}`); + qb.orderByRaw(`${arrayPositionExpr} ${direction} ${nullOrdering}`, choiceNames); return; } } diff --git a/apps/nestjs-backend/src/features/record/record.service.ts b/apps/nestjs-backend/src/features/record/record.service.ts index 58cacebb34..ef308691fa 100644 --- a/apps/nestjs-backend/src/features/record/record.service.ts +++ b/apps/nestjs-backend/src/features/record/record.service.ts @@ -2120,20 +2120,19 @@ export class RecordService { return searchArr.includes(field.id); }) .filter((field) => { - if ( - [CellValueType.Boolean, CellValueType.DateTime].includes(field.cellValueType) && - isSearchAllFields - ) { + if (field.type === FieldType.Button) { return false; } if (field.cellValueType === CellValueType.Boolean) { return false; } - return true; - }) - .filter((field) => { - if (field.type === FieldType.Button) { - return false; + if (isSearchAllFields) { + if (field.cellValueType === CellValueType.DateTime) { + return false; + } + if (field.cellValueType === CellValueType.Number && isNaN(Number(search[0]))) { + return false; + } } return true; }) diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts index 0de407f41a..cb26e83b34 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts @@ -763,15 +763,22 @@ export class TableOpenApiService { async getPermission(baseId: string, tableId: string): Promise { const baseShare = this.cls.get('baseShare'); - if ( - this.cls.get('template') || - this.cls.get('template.baseId') === baseId || - baseShare?.baseId === baseId - ) { + if (this.cls.get('template') || this.cls.get('template.baseId') === baseId) { return this.getPermissionByPermissionMap( TemplateRolePermission as Record ); } + if (baseShare?.baseId === baseId) { + const clsPermissions = new Set(this.cls.get('permissions')); + // Build permission map from CLS permissions (already curated by permission service) + const permissionMap = { ...TemplateRolePermission } as Record; + for (const perm of Object.keys(permissionMap) as BasePermission[]) { + if (clsPermissions.has(perm)) { + permissionMap[perm as BasePermission] = true; + } + } + return this.getPermissionByPermissionMap(permissionMap); + } let role: IRole | null = await this.permissionService.getRoleByBaseId(baseId); if (!role) { const { spaceId } = await this.permissionService.getUpperIdByBaseId(baseId); diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts index 846d16e18a..072b9f9ef5 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -773,6 +773,15 @@ export type I18nTranslations = { "chatModels": { "lg": string; "lgDescription": string; + "md": string; + "mdDescription": string; + "sm": string; + "smDescription": string; + "inheritHint": string; + "modelTiers": string; + "modelTiersDescription": string; + "allInheriting": string; + "customized": string; }; "actions": { "title": string; @@ -4136,6 +4145,18 @@ export type I18nTranslations = { "runCheck": string; "recheck": string; "repair": string; + "repairWarnings": string; + "repairWarningsAndErrors": string; + "repairRule": string; + "repairUnavailable": string; + "manual": string; + "manualRepairNotice": string; + "manualRepairNoticeWithCount": string; + "manualRepairDialogTitle": string; + "manualRepairDialogDescription": string; + "manualRepairDialogReason": string; + "manualRepairDialogHint": string; + "manualRepairDialogClose": string; "checking": string; "repairing": string; "streamError": string; @@ -4165,6 +4186,7 @@ export type I18nTranslations = { "baseCheckCompleted": string; "repairCompleted": string; "baseRepairCompleted": string; + "skippedStatusNotSelected": string; }; "rule": { "column": string; @@ -4204,6 +4226,21 @@ export type I18nTranslations = { "systemColumnUnique": string; "systemColumnPrimaryKey": string; "systemColumnDefault": string; + "columnUniqueMissing": string; + "columnUniqueMissingDescription": string; + "columnUniqueIndexMismatch": string; + "columnUniqueIndexMismatchDescription": string; + "foreignKeyMissing": string; + "foreignKeyMissingDescription": string; + "referenceMissing": string; + "referenceMissingDescription": string; + "symmetricFieldTargetMissing": string; + "symmetricFieldWrongType": string; + "symmetricFieldInvalidOptions": string; + "symmetricFieldMissingBackReference": string; + "symmetricFieldWrongBackReference": string; + "symmetricFieldDuplicateUsage": string; + "symmetricFieldDuplicateUsageDescription": string; }; "phase": { "check": string; @@ -4233,6 +4270,34 @@ export type I18nTranslations = { "manual": string; "skipped": string; }; + "manualRepairPreview": string; + "manualRepairPreviewTip": string; + "repairMeta": { + "reason": { + "alreadyValid": string; + "manualRule": string; + "statementGenerationFailed": string; + "noStatements": string; + "symmetricFieldConflict": string; + }; + "description": { + "symmetricFieldConflict": string; + }; + "manual": { + "apply": string; + "symmetricField": { + "title": string; + "description": string; + "resolutionLabel": string; + "resolutionDescription": string; + "option": { + "keepCurrent": string; + "keepDuplicate": string; + "convertDuplicate": string; + }; + }; + }; + }; }; "type": string; "message": string; @@ -4535,8 +4600,12 @@ export type I18nTranslations = { "shareLink": string; "linkHolderLabel": string; "linkHolderCanView": string; + "linkHolderCanViewDesc": string; "linkHolderCanEdit": string; + "linkHolderCanEditDesc": string; "linkHolderCanCopyAndSave": string; + "linkHolderCanCopyAndSaveDesc": string; + "editRequiresLogin": string; "passwordProtection": string; "enterPassword": string; "selectNodes": string; @@ -5155,6 +5224,13 @@ export type I18nTranslations = { "queueFull": string; "messageQueued": string; }; + "effort": { + "title": string; + "low": string; + "medium": string; + "high": string; + "max": string; + }; }; "download": { "allAttachments": { diff --git a/apps/nestjs-backend/test/base-share.e2e-spec.ts b/apps/nestjs-backend/test/base-share.e2e-spec.ts index bfd548a307..978cc04d63 100644 --- a/apps/nestjs-backend/test/base-share.e2e-spec.ts +++ b/apps/nestjs-backend/test/base-share.e2e-spec.ts @@ -1,7 +1,7 @@ import type { INestApplication } from '@nestjs/common'; import type { IFieldRo, ILookupOptionsRo } from '@teable/core'; import { FieldType, Relationship } from '@teable/core'; -import type { IBaseNodeVo, IGetBaseShareVo } from '@teable/openapi'; +import type { IBaseNodeVo, IGetBaseShareVo, ITablePermissionVo } from '@teable/openapi'; import { BASE_SHARE_AUTH, BASE_SHARE_ID_HEADER, @@ -10,13 +10,16 @@ import { createBase, createBaseNode, createBaseShare, + CREATE_RECORD, createField, createSpace, + DELETE_RECORD_URL, deleteBaseShare, deleteSpace, GET_BASE_NODE_LIST, GET_BASE_NODE_TREE, GET_BASE_SHARE, + GET_TABLE_PERMISSION, getBaseNodeList, getBaseShareByNodeId, getFields, @@ -24,10 +27,13 @@ import { listBaseShare, moveBaseNode, refreshBaseShare, + UPDATE_RECORD, updateBaseShare, urlBuilder, } from '@teable/openapi'; +import type { AxiosInstance } from 'axios'; import { createAnonymousUserAxios } from './utils/axios-instance/anonymous-user'; +import { createNewUserAxios } from './utils/axios-instance/new-user'; import { getError } from './utils/get-error'; import { createTable, @@ -1279,4 +1285,253 @@ describe('BaseShareController (e2e)', () => { expect(nodeIds.has(rootTableNodeId)).toBe(false); }); }); + + describe('BaseShare - allowEdit permission', () => { + let editBaseId: string; + let editTableId: string; + let editTableNodeId: string; + let editFolderNodeId: string; + let loggedInUser: AxiosInstance; + const createdShareIds: string[] = []; + + beforeAll(async () => { + const base = await createBase({ + name: 'allowEdit-e2e', + spaceId: globalThis.testConfig.spaceId, + }).then((res) => res.data); + editBaseId = base.id; + + const table = await createTable(editBaseId, { name: 'edit-table' }); + editTableId = table.id; + + const folder = await createBaseNode(editBaseId, { + resourceType: BaseNodeResourceType.Folder, + name: 'edit-folder', + }); + editFolderNodeId = folder.data.id; + + const nodeList = await getBaseNodeList(editBaseId); + const tableNode = nodeList.data.find((n) => n.resourceId === editTableId); + if (!tableNode) throw new Error('Table node not found'); + editTableNodeId = tableNode.id; + + loggedInUser = await createNewUserAxios({ + email: 'allow-edit-e2e@test.com', + password: 'TestPassword123!', + }); + }); + + afterAll(async () => { + await permanentDeleteBase(editBaseId); + }); + + afterEach(async () => { + for (const shareId of createdShareIds) { + await deleteBaseShare(editBaseId, shareId).catch(() => undefined); + } + createdShareIds.length = 0; + }); + + it('should create share with allowEdit for table node', async () => { + const res = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(res.data.shareId); + expect(res.status).toEqual(201); + expect(res.data.allowEdit).toBe(true); + // allowEdit implies allowSave is false (mutually exclusive) + expect(res.data.allowSave).toBe(false); + }); + + it('should reject allowEdit for folder node', async () => { + const error = await getError(() => + createBaseShare(editBaseId, { + nodeId: editFolderNodeId, + allowEdit: true, + }) + ); + expect(error?.status).toEqual(400); + }); + + it('should enforce allowEdit/allowSave mutual exclusivity on create', async () => { + const res = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + allowSave: true, + }); + createdShareIds.push(res.data.shareId); + // allowEdit takes precedence + expect(res.data.allowEdit).toBe(true); + expect(res.data.allowSave).toBe(false); + }); + + it('should enforce allowEdit/allowSave mutual exclusivity on update', async () => { + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: false, + allowSave: true, + }); + createdShareIds.push(share.data.shareId); + expect(share.data.allowSave).toBe(true); + expect(share.data.allowEdit).toBe(false); + + // Switch to allowEdit + const updated = await updateBaseShare(editBaseId, share.data.shareId, { + allowEdit: true, + }); + expect(updated.data.allowEdit).toBe(true); + expect(updated.data.allowSave).toBe(false); + }); + + it('should re-enable soft-deleted share and inherit old settings', async () => { + // Create a share with specific settings + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + allowCopy: true, + }); + createdShareIds.push(share.data.shareId); + expect(share.data.allowEdit).toBe(true); + expect(share.data.allowCopy).toBe(true); + + // Soft-delete it + await deleteBaseShare(editBaseId, share.data.shareId); + + // Re-create with same nodeId — should re-enable and inherit old settings + const reEnabled = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + }); + createdShareIds.push(reEnabled.data.shareId); + expect(reEnabled.data.enabled).toBe(true); + expect(reEnabled.data.allowEdit).toBe(true); + expect(reEnabled.data.allowCopy).toBe(true); + }); + + it('should grant editor-level permissions to logged-in user with allowEdit', async () => { + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(share.data.shareId); + + const permRes = await loggedInUser.get( + urlBuilder(GET_TABLE_PERMISSION, { baseId: editBaseId, tableId: editTableId }), + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ); + expect(permRes.status).toEqual(200); + // Editor-level: can create/update/delete records + expect(permRes.data.record['record|create']).toBe(true); + expect(permRes.data.record['record|update']).toBe(true); + expect(permRes.data.record['record|delete']).toBe(true); + // Excluded: view|share must be denied + expect(permRes.data.view['view|share']).toBeFalsy(); + }); + + it('should only grant read-only permissions to anonymous user even with allowEdit', async () => { + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(share.data.shareId); + + const permRes = await anonymousUser.get( + urlBuilder(GET_TABLE_PERMISSION, { baseId: editBaseId, tableId: editTableId }), + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ); + expect(permRes.status).toEqual(200); + // Anonymous user should NOT have write permissions + expect(permRes.data.record['record|create']).toBeFalsy(); + expect(permRes.data.record['record|update']).toBeFalsy(); + expect(permRes.data.record['record|delete']).toBeFalsy(); + }); + + it('should allow logged-in user to create records via allowEdit share', async () => { + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(share.data.shareId); + + const fields = await getFields(editTableId); + const firstField = fields.data[0]; + + const createRes = await loggedInUser.post( + urlBuilder(CREATE_RECORD, { tableId: editTableId }), + { records: [{ fields: { [firstField.id]: 'share-edit-test' } }], fieldKeyType: 'id' }, + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ); + expect(createRes.status).toEqual(201); + expect(createRes.data.records).toHaveLength(1); + + const recordId = createRes.data.records[0].id; + + // Update the record + const updateRes = await loggedInUser.patch( + urlBuilder(UPDATE_RECORD, { tableId: editTableId, recordId }), + { record: { fields: { [firstField.id]: 'updated-via-share' } }, fieldKeyType: 'id' }, + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ); + expect(updateRes.status).toEqual(200); + + // Delete the record + const deleteRes = await loggedInUser.delete( + urlBuilder(DELETE_RECORD_URL, { tableId: editTableId, recordId }), + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ); + expect(deleteRes.status).toEqual(200); + }); + + it('should deny anonymous user record creation even with allowEdit', async () => { + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(share.data.shareId); + + const fields = await getFields(editTableId); + const firstField = fields.data[0]; + + const error = await getError(() => + anonymousUser.post( + urlBuilder(CREATE_RECORD, { tableId: editTableId }), + { records: [{ fields: { [firstField.id]: 'should-fail' } }], fieldKeyType: 'id' }, + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ) + ); + expect(error?.status).toEqual(403); + }); + + it('should cap permissions at share level even for base owner', async () => { + // The default test user is the base owner + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(share.data.shareId); + + // Access via share header — should get editor-level, not owner-level + const permRes = await loggedInUser.get( + urlBuilder(GET_TABLE_PERMISSION, { baseId: editBaseId, tableId: editTableId }), + { headers: { [BASE_SHARE_ID_HEADER]: share.data.shareId } } + ); + expect(permRes.status).toEqual(200); + // view|share is excluded from share permissions, even though owner normally has it + expect(permRes.data.view['view|share']).toBeFalsy(); + }); + + it('should include allowEdit in shareMeta via public API', async () => { + const share = await createBaseShare(editBaseId, { + nodeId: editTableNodeId, + allowEdit: true, + }); + createdShareIds.push(share.data.shareId); + + const res = await anonymousUser.get( + urlBuilder(GET_BASE_SHARE, { shareId: share.data.shareId }) + ); + expect(res.status).toEqual(200); + expect(res.data.shareMeta.allowEdit).toBe(true); + }); + }); }); diff --git a/apps/nestjs-backend/test/conditional-rollup.e2e-spec.ts b/apps/nestjs-backend/test/conditional-rollup.e2e-spec.ts index 9907fbcfe7..4356001c48 100644 --- a/apps/nestjs-backend/test/conditional-rollup.e2e-spec.ts +++ b/apps/nestjs-backend/test/conditional-rollup.e2e-spec.ts @@ -4366,138 +4366,4 @@ describe('OpenAPI Conditional Rollup field (e2e)', () => { expect(bobSummary.fields[sumWithoutExcludedField.id]).toEqual(7); }); }); - - describe('v2 update field hasError propagation', () => { - const isForceV2 = process.env.FORCE_V2_ALL === 'true'; - const itV2Only = isForceV2 ? it : it.skip; - - itV2Only('marks conditional rollup as errored when filter field is deleted', async () => { - const foreign = await createTable(baseId, { - name: 'V2CondRollupFilterDel_Foreign', - fields: [ - { name: 'Title', type: FieldType.SingleLineText } as IFieldRo, - { name: 'Amount', type: FieldType.Number } as IFieldRo, - { name: 'Status', type: FieldType.SingleLineText } as IFieldRo, - ], - records: [ - { fields: { Title: 'Alpha', Amount: 2, Status: 'Active' } }, - { fields: { Title: 'Beta', Amount: 4, Status: 'Active' } }, - { fields: { Title: 'Gamma', Amount: 6, Status: 'Inactive' } }, - ], - }); - const host = await createTable(baseId, { - name: 'V2CondRollupFilterDel_Host', - fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo], - records: [{ fields: { Label: 'Row 1' } }], - }); - const amountId = foreign.fields.find((f) => f.name === 'Amount')!.id; - const statusId = foreign.fields.find((f) => f.name === 'Status')!.id; - - try { - // Create conditional rollup without filter - let rollupField = await createField(host.id, { - name: 'Filtered Sum', - type: FieldType.ConditionalRollup, - options: { - foreignTableId: foreign.id, - lookupFieldId: amountId, - expression: 'sum({values})', - }, - } as IFieldRo); - - // Convert to add a filter referencing statusId - rollupField = await convertField(host.id, rollupField.id, { - name: 'Filtered Sum', - type: FieldType.ConditionalRollup, - options: { - foreignTableId: foreign.id, - lookupFieldId: amountId, - expression: 'sum({values})', - filter: { - conjunction: 'and', - filterSet: [{ fieldId: statusId, operator: 'is', value: 'Active' }], - }, - }, - } as IFieldRo); - - const hostRecord = await getRecord(host.id, host.records[0].id); - expect(hostRecord.fields[rollupField.id]).toEqual(6); - - // Delete the filter field from the foreign table - await deleteField(foreign.id, statusId); - - const hostFields = await getFields(host.id); - const erroredField = hostFields.find((f) => f.id === rollupField.id)!; - expect(erroredField.hasError).toBe(true); - } finally { - await permanentDeleteTable(baseId, host.id); - await permanentDeleteTable(baseId, foreign.id); - } - }); - - itV2Only( - 'marks conditional rollup as errored when lookup field type becomes incompatible', - async () => { - const foreign = await createTable(baseId, { - name: 'V2CondRollupTypeErr_Foreign', - fields: [ - { name: 'Title', type: FieldType.SingleLineText } as IFieldRo, - { name: 'Amount', type: FieldType.Number } as IFieldRo, - ], - records: [ - { fields: { Title: 'Alpha', Amount: 2 } }, - { fields: { Title: 'Beta', Amount: 4 } }, - { fields: { Title: 'Gamma', Amount: 6 } }, - ], - }); - const host = await createTable(baseId, { - name: 'V2CondRollupTypeErr_Host', - fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo], - records: [{ fields: { Label: 'Row 1' } }], - }); - const amountId = foreign.fields.find((f) => f.name === 'Amount')!.id; - const hostRecordId = host.records[0].id; - - try { - const rollupField = await createField(host.id, { - name: 'Sum Amount', - type: FieldType.ConditionalRollup, - options: { - foreignTableId: foreign.id, - lookupFieldId: amountId, - expression: 'sum({values})', - }, - } as IFieldRo); - - const baseline = await getRecord(host.id, hostRecordId); - expect(baseline.fields[rollupField.id]).toEqual(12); - - // Convert numeric lookup field to SingleSelect (incompatible with sum) - await convertField(foreign.id, amountId, { - name: 'Amount (Select)', - type: FieldType.SingleSelect, - options: { - choices: [ - { name: '2', color: Colors.Blue }, - { name: '4', color: Colors.Green }, - { name: '6', color: Colors.Orange }, - ], - }, - } as IFieldRo); - - let erroredField: IFieldVo | undefined; - for (let attempt = 0; attempt < 10; attempt++) { - const fieldsAfterConversion = await getFields(host.id); - erroredField = fieldsAfterConversion.find((f) => f.id === rollupField.id); - if (erroredField?.hasError) break; - await new Promise((resolve) => setTimeout(resolve, 200)); - } - expect(erroredField?.hasError).toBe(true); - } finally { - await permanentDeleteTable(baseId, host.id); - await permanentDeleteTable(baseId, foreign.id); - } - } - ); - }); }); diff --git a/apps/nestjs-backend/test/field-converting.e2e-spec.ts b/apps/nestjs-backend/test/field-converting.e2e-spec.ts index ca9aa28bcf..458da47be6 100644 --- a/apps/nestjs-backend/test/field-converting.e2e-spec.ts +++ b/apps/nestjs-backend/test/field-converting.e2e-spec.ts @@ -3866,95 +3866,6 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { } ); - it.skipIf(!canRunCanaryV2)( - 'should remove conditional lookup sort and limit when convert payload omits them in v2', - async () => { - const statusField = await createField(table2.id, { - name: 'Status', - type: FieldType.SingleLineText, - }); - const scoreField = await createField(table2.id, { - name: 'Score', - type: FieldType.Number, - }); - const statusFilterField = await createField(table1.id, { - name: 'Status Filter', - type: FieldType.SingleLineText, - }); - - await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'row-1'); - await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'row-2'); - await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'Active'); - await updateRecordByApi(table2.id, table2.records[1].id, statusField.id, 'Active'); - await updateRecordByApi(table2.id, table2.records[0].id, scoreField.id, 10); - await updateRecordByApi(table2.id, table2.records[1].id, scoreField.id, 20); - await updateRecordByApi(table1.id, table1.records[0].id, statusFilterField.id, 'Active'); - - const lookupField = await createField(table1.id, { - type: FieldType.SingleLineText, - isLookup: true, - isConditionalLookup: true, - lookupOptions: { - foreignTableId: table2.id, - lookupFieldId: table2.fields[0].id, - filter: { - conjunction: 'and', - filterSet: [ - { - fieldId: statusField.id, - operator: 'is', - value: { type: 'field', fieldId: statusFilterField.id }, - }, - ], - }, - sort: { - fieldId: scoreField.id, - order: SortFunc.Desc, - }, - limit: 1, - }, - }); - - const beforeRecord = await getRecord(table1.id, table1.records[0].id); - expect(beforeRecord.fields[lookupField.id]).toEqual(['row-2']); - - const updatedField = await convertFieldByCanaryV2(table1.id, lookupField.id, { - type: FieldType.SingleLineText, - isLookup: true, - isConditionalLookup: true, - lookupOptions: { - foreignTableId: table2.id, - lookupFieldId: table2.fields[0].id, - filter: { - conjunction: 'and', - filterSet: [ - { - fieldId: statusField.id, - operator: 'is', - value: { type: 'field', fieldId: statusFilterField.id }, - }, - ], - }, - }, - }); - - const updatedLookupOptions = updatedField.lookupOptions as IConditionalLookupOptions; - expect(updatedLookupOptions.sort).toBeUndefined(); - expect(updatedLookupOptions.limit).toBeUndefined(); - - const refreshedField = await getField(table1.id, lookupField.id); - const refreshedLookupOptions = refreshedField.lookupOptions as IConditionalLookupOptions; - expect(refreshedLookupOptions.sort).toBeUndefined(); - expect(refreshedLookupOptions.limit).toBeUndefined(); - - const afterRecord = await getRecord(table1.id, table1.records[0].id); - expect([...(afterRecord.fields[lookupField.id] as string[])].sort()).toEqual([ - 'row-1', - 'row-2', - ]); - } - ); - it.skipIf(!canRunCanaryV2)( 'should remove conditional lookup sort and limit for formula inner type when switch is off in v2', async () => { @@ -4089,6 +4000,52 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { } ); + it.skipIf(!canRunCanaryV2)( + 'should remove link filter options when convert payload omits them in v2', + async () => { + const statusField = await createField(table2.id, { + name: 'Status', + type: FieldType.SingleLineText, + }); + await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'Active'); + + const linkField = await createField(table1.id, { + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: table2.id, + lookupFieldId: table2.fields[0].id, + filterByViewId: table2.defaultViewId, + visibleFieldIds: [table2.fields[0].id], + filter: { + conjunction: 'and', + filterSet: [{ fieldId: statusField.id, operator: 'is', value: 'Active' }], + }, + }, + }); + + const updatedField = await convertFieldByCanaryV2(table1.id, linkField.id, { + type: FieldType.Link, + options: { + relationship: Relationship.ManyOne, + foreignTableId: table2.id, + lookupFieldId: table2.fields[0].id, + }, + }); + + const updatedOptions = updatedField.options as ILinkFieldOptions; + expect(updatedOptions.filterByViewId).toBeUndefined(); + expect(updatedOptions.visibleFieldIds).toBeUndefined(); + expect(updatedOptions.filter).toBeUndefined(); + + const refreshedField = await getField(table1.id, linkField.id); + const refreshedOptions = refreshedField.options as ILinkFieldOptions; + expect(refreshedOptions.filterByViewId).toBeUndefined(); + expect(refreshedOptions.visibleFieldIds).toBeUndefined(); + expect(refreshedOptions.filter).toBeUndefined(); + } + ); + it.skipIf(!canRunCanaryV2)( 'should preserve formula datetime formatting when converting conditional lookup inner type in v2', async () => { @@ -4234,52 +4191,6 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { } ); - it.skipIf(!canRunCanaryV2)( - 'should remove link filter options when convert payload omits them in v2', - async () => { - const statusField = await createField(table2.id, { - name: 'Status', - type: FieldType.SingleLineText, - }); - await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'Active'); - - const linkField = await createField(table1.id, { - type: FieldType.Link, - options: { - relationship: Relationship.ManyOne, - foreignTableId: table2.id, - lookupFieldId: table2.fields[0].id, - filterByViewId: table2.defaultViewId, - visibleFieldIds: [table2.fields[0].id], - filter: { - conjunction: 'and', - filterSet: [{ fieldId: statusField.id, operator: 'is', value: 'Active' }], - }, - }, - }); - - const updatedField = await convertFieldByCanaryV2(table1.id, linkField.id, { - type: FieldType.Link, - options: { - relationship: Relationship.ManyOne, - foreignTableId: table2.id, - lookupFieldId: table2.fields[0].id, - }, - }); - - const updatedOptions = updatedField.options as ILinkFieldOptions; - expect(updatedOptions.filterByViewId).toBeUndefined(); - expect(updatedOptions.visibleFieldIds).toBeUndefined(); - expect(updatedOptions.filter).toBeUndefined(); - - const refreshedField = await getField(table1.id, linkField.id); - const refreshedOptions = refreshedField.options as ILinkFieldOptions; - expect(refreshedOptions.filterByViewId).toBeUndefined(); - expect(refreshedOptions.visibleFieldIds).toBeUndefined(); - expect(refreshedOptions.filter).toBeUndefined(); - } - ); - it('should change lookupField from link to text', async () => { const linkFieldRo: IFieldRo = { type: FieldType.Link, @@ -4701,93 +4612,6 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { await convertField(table2.id, rollupField.id, rollupFieldRo2); }); - - it.skipIf(!canRunCanaryV2)( - 'should remove conditional rollup sort and limit when convert payload omits them in v2', - async () => { - const statusField = await createField(table2.id, { - name: 'Status', - type: FieldType.SingleLineText, - }); - const scoreField = await createField(table2.id, { - name: 'Score', - type: FieldType.Number, - }); - const statusFilterField = await createField(table1.id, { - name: 'Status Filter', - type: FieldType.SingleLineText, - }); - - await updateRecordByApi(table2.id, table2.records[0].id, table2.fields[0].id, 'row-1'); - await updateRecordByApi(table2.id, table2.records[1].id, table2.fields[0].id, 'row-2'); - await updateRecordByApi(table2.id, table2.records[0].id, statusField.id, 'Active'); - await updateRecordByApi(table2.id, table2.records[1].id, statusField.id, 'Active'); - await updateRecordByApi(table2.id, table2.records[0].id, scoreField.id, 10); - await updateRecordByApi(table2.id, table2.records[1].id, scoreField.id, 20); - await updateRecordByApi(table1.id, table1.records[0].id, statusFilterField.id, 'Active'); - - const conditionalRollupField = await createField(table1.id, { - type: FieldType.ConditionalRollup, - options: { - foreignTableId: table2.id, - lookupFieldId: table2.fields[0].id, - expression: 'array_compact({values})', - filter: { - conjunction: 'and', - filterSet: [ - { - fieldId: statusField.id, - operator: 'is', - value: { type: 'field', fieldId: statusFilterField.id }, - }, - ], - }, - sort: { - fieldId: scoreField.id, - order: SortFunc.Desc, - }, - limit: 1, - } as IConditionalRollupFieldOptions, - }); - - const beforeRecord = await getRecord(table1.id, table1.records[0].id); - expect(beforeRecord.fields[conditionalRollupField.id]).toEqual(['row-2']); - - const updatedField = await convertFieldByCanaryV2(table1.id, conditionalRollupField.id, { - type: FieldType.ConditionalRollup, - options: { - foreignTableId: table2.id, - lookupFieldId: table2.fields[0].id, - expression: 'array_compact({values})', - filter: { - conjunction: 'and', - filterSet: [ - { - fieldId: statusField.id, - operator: 'is', - value: { type: 'field', fieldId: statusFilterField.id }, - }, - ], - }, - } as IConditionalRollupFieldOptions, - }); - - const updatedOptions = updatedField.options as IConditionalRollupFieldOptions; - expect(updatedOptions.sort).toBeUndefined(); - expect(updatedOptions.limit).toBeUndefined(); - - const refreshedField = await getField(table1.id, conditionalRollupField.id); - const refreshedOptions = refreshedField.options as IConditionalRollupFieldOptions; - expect(refreshedOptions.sort).toBeUndefined(); - expect(refreshedOptions.limit).toBeUndefined(); - - const afterRecord = await getRecord(table1.id, table1.records[0].id); - expect([...(afterRecord.fields[conditionalRollupField.id] as string[])].sort()).toEqual([ - 'row-1', - 'row-2', - ]); - } - ); }); describe('rollup conversion regressions', () => { diff --git a/apps/nestjs-backend/test/field.e2e-spec.ts b/apps/nestjs-backend/test/field.e2e-spec.ts index 4b68dae6fe..705562bc48 100644 --- a/apps/nestjs-backend/test/field.e2e-spec.ts +++ b/apps/nestjs-backend/test/field.e2e-spec.ts @@ -21,7 +21,6 @@ import { } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { ITableFullVo } from '@teable/openapi'; -import { convertField } from '@teable/openapi'; import type { Knex } from 'knex'; import type { FieldCreateEvent } from '../src/event-emitter/events'; import { Events } from '../src/event-emitter/events'; @@ -38,7 +37,19 @@ import { getRecords, } from './utils/init-app'; -const isForceV2 = process.env.FORCE_V2_ALL === 'true'; +const withForceV2All = async (callback: () => Promise) => { + const previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + try { + return await callback(); + } finally { + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + } +}; describe('OpenAPI FieldController (e2e)', () => { let app: INestApplication; @@ -315,79 +326,79 @@ describe('OpenAPI FieldController (e2e)', () => { }); describe('v2 lookup option sync', () => { - const itIfForceV2 = isForceV2 ? it : it.skip; - - itIfForceV2('ignores API-supplied choices for lookup-backed single select fields', async () => { + it('ignores API-supplied choices for lookup-backed single select fields', async () => { let hostTable: ITableFullVo | undefined; let foreignTable: ITableFullVo | undefined; try { - foreignTable = await createTable(baseId, { - name: 'lookup-option-sync-foreign', - fields: [ - { name: 'Title', type: FieldType.SingleLineText }, - { - name: 'Importance', - type: FieldType.SingleSelect, - options: { - choices: [ - { id: 'choLookupCore', name: '核心', color: Colors.Blue }, - { id: 'choLookupImportant', name: '重要', color: Colors.Green }, - { id: 'choLookupReference', name: '参考', color: Colors.Orange }, - ], + await withForceV2All(async () => { + foreignTable = await createTable(baseId, { + name: 'lookup-option-sync-foreign', + fields: [ + { name: 'Title', type: FieldType.SingleLineText }, + { + name: 'Importance', + type: FieldType.SingleSelect, + options: { + choices: [ + { id: 'choLookupCore', name: '核心', color: Colors.Blue }, + { id: 'choLookupImportant', name: '重要', color: Colors.Green }, + { id: 'choLookupReference', name: '参考', color: Colors.Orange }, + ], + }, }, - }, - ], - }); - hostTable = await createTable(baseId, { - name: 'lookup-option-sync-host', - fields: [{ name: 'Name', type: FieldType.SingleLineText }], - }); + ], + }); + hostTable = await createTable(baseId, { + name: 'lookup-option-sync-host', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + }); - const foreignImportanceField = foreignTable.fields.find( - (field) => field.name === 'Importance' - )!; - const expectedChoices = ( - foreignImportanceField.options as { - choices: Array<{ id: string; name: string; color: string }>; - } - ).choices; - - const linkField = await createField(hostTable.id, { - name: 'Related', - type: FieldType.Link, - options: { - relationship: Relationship.ManyMany, - foreignTableId: foreignTable.id, - } as ILinkFieldOptionsRo, - }); + const foreignImportanceField = foreignTable.fields.find( + (field) => field.name === 'Importance' + )!; + const expectedChoices = ( + foreignImportanceField.options as { + choices: Array<{ id: string; name: string; color: string }>; + } + ).choices; - const createdLookupField = await createField(hostTable.id, { - name: '章节重要程度', - type: FieldType.SingleSelect, - isLookup: true, - lookupOptions: { - foreignTableId: foreignTable.id, - lookupFieldId: foreignImportanceField.id, - linkFieldId: linkField.id, - } as ILookupOptionsRo, - options: { - choices: [ - { id: 'choBroken1', name: 'Option 1', color: Colors.Blue }, - { id: 'choBroken2', name: 'Option 2', color: Colors.Green }, - ], - }, - }); + const linkField = await createField(hostTable.id, { + name: 'Related', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: foreignTable.id, + } as ILinkFieldOptionsRo, + }); - expect(createdLookupField.options).toEqual({ - choices: expectedChoices, - }); + const createdLookupField = await createField(hostTable.id, { + name: '章节重要程度', + type: FieldType.SingleSelect, + isLookup: true, + lookupOptions: { + foreignTableId: foreignTable.id, + lookupFieldId: foreignImportanceField.id, + linkFieldId: linkField.id, + } as ILookupOptionsRo, + options: { + choices: [ + { id: 'choBroken1', name: 'Option 1', color: Colors.Blue }, + { id: 'choBroken2', name: 'Option 2', color: Colors.Green }, + ], + }, + }); - const persistedLookupField = (await getFields(hostTable.id)).find( - (field) => field.id === createdLookupField.id - ); - expect(persistedLookupField?.options).toEqual({ - choices: expectedChoices, + expect(createdLookupField.options).toEqual({ + choices: expectedChoices, + }); + + const persistedLookupField = (await getFields(hostTable.id)).find( + (field) => field.id === createdLookupField.id + ); + expect(persistedLookupField?.options).toEqual({ + choices: expectedChoices, + }); }); } finally { if (hostTable) { @@ -399,13 +410,12 @@ describe('OpenAPI FieldController (e2e)', () => { } }); - itIfForceV2( - 'ignores API-supplied choices for conditional lookup-backed single select fields', - async () => { - let hostTable: ITableFullVo | undefined; - let foreignTable: ITableFullVo | undefined; + it('ignores API-supplied choices for conditional lookup-backed single select fields', async () => { + let hostTable: ITableFullVo | undefined; + let foreignTable: ITableFullVo | undefined; - try { + try { + await withForceV2All(async () => { foreignTable = await createTable(baseId, { name: 'conditional-lookup-option-sync-foreign', fields: [ @@ -484,185 +494,16 @@ describe('OpenAPI FieldController (e2e)', () => { expect(persistedConditionalLookupField?.options).toEqual({ choices: expectedChoices, }); - } finally { - if (hostTable) { - await permanentDeleteTable(baseId, hostTable.id); - } - if (foreignTable) { - await permanentDeleteTable(baseId, foreignTable.id); - } - } - } - ); - }); - - describe('long text markdown showAs API', () => { - const itIfForceV2 = isForceV2 ? it : it.skip; - - itIfForceV2('should update and clear long text showAs via convert field API', async () => { - let table: ITableFullVo | undefined; - - try { - table = await createTable(baseId, { - name: 'long-text-show-as-update-api', - fields: [{ name: 'Name', type: FieldType.SingleLineText }], - }); - - const longTextField = await createField(table.id, { - name: 'Body', - type: FieldType.LongText, }); - - const markdownUpdatedResponse = await convertField(table.id, longTextField.id, { - name: longTextField.name, - type: FieldType.LongText, - options: { - showAs: { - type: 'markdown', - }, - }, - }); - expect(markdownUpdatedResponse.status).toBe(200); - - const persistedAfterEnable = (await getFields(table.id)).find( - (field) => field.id === longTextField.id - )!; - expect(persistedAfterEnable.options).toMatchObject({ - showAs: { - type: 'markdown', - }, - }); - - const clearedResponse = await convertField(table.id, longTextField.id, { - name: longTextField.name, - type: FieldType.LongText, - options: { - showAs: null, - }, - }); - expect(clearedResponse.status).toBe(200); - - const persistedAfterClear = (await getFields(table.id)).find( - (field) => field.id === longTextField.id - )!; - expect((persistedAfterClear.options as { showAs?: unknown }).showAs).toBeUndefined(); } finally { - if (table) { - await permanentDeleteTable(baseId, table.id); + if (hostTable) { + await permanentDeleteTable(baseId, hostTable.id); } - } - }); - - itIfForceV2( - 'should keep lookup long text showAs cleared when API attempts to set markdown', - async () => { - let hostTable: ITableFullVo | undefined; - let foreignTable: ITableFullVo | undefined; - - try { - foreignTable = await createTable(baseId, { - name: 'lookup-long-text-show-as-foreign', - fields: [ - { name: 'Title', type: FieldType.SingleLineText }, - { - name: 'Foreign Long Text', - type: FieldType.LongText, - options: { - showAs: { - type: 'markdown', - }, - }, - }, - ], - }); - - hostTable = await createTable(baseId, { - name: 'lookup-long-text-show-as-host', - fields: [{ name: 'Name', type: FieldType.SingleLineText }], - }); - - const foreignLongTextField = foreignTable.fields.find( - (field) => field.name === 'Foreign Long Text' - )!; - - const linkField = await createField(hostTable.id, { - name: 'Related', - type: FieldType.Link, - options: { - relationship: Relationship.ManyMany, - foreignTableId: foreignTable.id, - } as ILinkFieldOptionsRo, - }); - - const lookupLongTextField = await createField(hostTable.id, { - name: 'Lookup Long Text', - type: FieldType.LongText, - isLookup: true, - lookupOptions: { - foreignTableId: foreignTable.id, - lookupFieldId: foreignLongTextField.id, - linkFieldId: linkField.id, - } as ILookupOptionsRo, - }); - - expect(lookupLongTextField.options).toMatchObject({ - showAs: { - type: 'markdown', - }, - }); - - const lookupOptions = lookupLongTextField.lookupOptions as ILookupOptionsRo; - const clearedLookupResponse = await convertField(hostTable.id, lookupLongTextField.id, { - name: lookupLongTextField.name, - type: FieldType.LongText, - isLookup: true, - lookupOptions: { - foreignTableId: lookupOptions.foreignTableId, - lookupFieldId: lookupOptions.lookupFieldId, - linkFieldId: lookupOptions.linkFieldId, - }, - options: { - showAs: null, - }, - }); - expect(clearedLookupResponse.status).toBe(200); - - const persistedAfterClear = (await getFields(hostTable.id)).find( - (field) => field.id === lookupLongTextField.id - )!; - expect((persistedAfterClear.options as { showAs?: unknown }).showAs).toBeUndefined(); - - const updatedLookupResponse = await convertField(hostTable.id, lookupLongTextField.id, { - name: lookupLongTextField.name, - type: FieldType.LongText, - isLookup: true, - lookupOptions: { - foreignTableId: lookupOptions.foreignTableId, - lookupFieldId: lookupOptions.lookupFieldId, - linkFieldId: lookupOptions.linkFieldId, - }, - options: { - showAs: { - type: 'markdown', - }, - }, - }); - expect(updatedLookupResponse.status).toBe(200); - - const persistedLookupField = (await getFields(hostTable.id)).find( - (field) => field.id === lookupLongTextField.id - )!; - expect((persistedLookupField.options as { showAs?: unknown }).showAs).toBeUndefined(); - } finally { - if (hostTable) { - await permanentDeleteTable(baseId, hostTable.id); - } - if (foreignTable) { - await permanentDeleteTable(baseId, foreignTable.id); - } + if (foreignTable) { + await permanentDeleteTable(baseId, foreignTable.id); } } - ); + }); }); describe('should decide whether to create field validation rules based on the field type', () => { diff --git a/apps/nestjs-backend/test/filter.e2e-spec.ts b/apps/nestjs-backend/test/filter.e2e-spec.ts index d10454bfb1..d41d408ac7 100644 --- a/apps/nestjs-backend/test/filter.e2e-spec.ts +++ b/apps/nestjs-backend/test/filter.e2e-spec.ts @@ -15,6 +15,20 @@ afterAll(async () => { await app.close(); }); +const withForceV2All = async (callback: () => Promise) => { + const previousForceV2All = process.env.FORCE_V2_ALL; + process.env.FORCE_V2_ALL = 'true'; + try { + return await callback(); + } finally { + if (previousForceV2All == null) { + delete process.env.FORCE_V2_ALL; + } else { + process.env.FORCE_V2_ALL = previousForceV2All; + } + } +}; + async function updateViewFilter(tableId: string, viewId: string, filterRo: IFilterRo) { try { const result = await apiSetViewFilter(tableId, viewId, filterRo); @@ -80,18 +94,17 @@ describe('OpenAPI ViewController (e2e) option (PUT)', () => { }); // V1 does not normalize is/isNot+null through the domain FieldConditionSpecBuilder, -// so this test only applies to V2. -describe.skipIf(process.env.FORCE_V2_ALL !== 'true')( - 'View filter with is/isNot null value (e2e)', - () => { - let tableId: string; - let viewId: string; +// so this test must force the V2 path explicitly inside the single integration run. +describe('View filter with is/isNot null value (e2e)', () => { + let tableId: string; + let viewId: string; - afterAll(async () => { - await permanentDeleteTable(baseId, tableId); - }); + afterAll(async () => { + await permanentDeleteTable(baseId, tableId); + }); - it('should apply view filter with is+null (checkbox) and isNotEmpty via API viewId query', async () => { + it('should apply view filter with is+null (checkbox) and isNotEmpty via API viewId query', async () => { + await withForceV2All(async () => { // Create table with checkbox and text fields const table = await createTable(baseId, { name: 'View Filter Null Test', @@ -144,5 +157,5 @@ describe.skipIf(process.env.FORCE_V2_ALL !== 'true')( const names = records.map((r) => r.fields.Name).sort(); expect(names).toEqual(['row2', 'row3']); }); - } -); + }); +}); diff --git a/apps/nestjs-backend/test/group.e2e-spec.ts b/apps/nestjs-backend/test/group.e2e-spec.ts index 6d7d599e53..3631669ead 100644 --- a/apps/nestjs-backend/test/group.e2e-spec.ts +++ b/apps/nestjs-backend/test/group.e2e-spec.ts @@ -695,3 +695,118 @@ describe('Lookup multiple select respects choice order when sorting groups', () expect(recordCategories).toEqual([choiceOrder[0], choiceOrder[1], choiceOrder[2]]); }); }); + +describe('Single select grouping with special characters in choice names', () => { + const choiceOrder = ['Pending?', 'Done!', 'N/A'] as const; + const choiceDefinitions = choiceOrder.map((name, index) => ({ + id: `sc-choice-${index}`, + name, + color: index === 0 ? Colors.Red : index === 1 ? Colors.Green : Colors.Blue, + })); + const statusFieldName = 'Status'; + const itemFieldName = 'Item'; + + let table: ITableFullVo; + let statusField: IFieldRo; + + beforeAll(async () => { + table = await createTable(baseId, { + name: 'group_special_char_choices', + fields: [ + { name: itemFieldName, type: FieldType.SingleLineText }, + { + name: statusFieldName, + type: FieldType.SingleSelect, + options: { choices: choiceDefinitions }, + }, + ], + records: [ + { fields: { [itemFieldName]: 'r1', [statusFieldName]: 'Pending?' } }, + { fields: { [itemFieldName]: 'r2', [statusFieldName]: 'Done!' } }, + { fields: { [itemFieldName]: 'r3', [statusFieldName]: 'N/A' } }, + ], + }); + statusField = table.fields!.find( + ({ name, type }) => name === statusFieldName && type === FieldType.SingleSelect + ) as IFieldRo; + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, table.id); + }); + + it('groups correctly when choice name contains ? character', async () => { + const query: IGetRecordsRo = { + fieldKeyType: FieldKeyType.Id, + groupBy: [{ fieldId: statusField.id!, order: SortFunc.Asc }], + }; + const { records, extra } = await getRecords(table.id, query); + + const headerValues = + extra?.groupPoints + ?.filter((point): point is IGroupHeaderPoint => point.type === GroupPointType.Header) + .map((point) => point.value as string) ?? []; + + expect(headerValues).toEqual([...choiceOrder]); + expect(records).toHaveLength(3); + + const statusSequence = records.map((record) => record.fields?.[statusField.id!] as string); + expect(statusSequence).toEqual([...choiceOrder]); + }); +}); + +describe('Multiple select grouping with special characters in choice names', () => { + const choiceOrder = ['Alpha?', 'Beta!', 'Gamma'] as const; + const choiceDefinitions = choiceOrder.map((name, index) => ({ + id: `ms-choice-${index}`, + name, + color: index === 0 ? Colors.Red : index === 1 ? Colors.Green : Colors.Blue, + })); + const tagFieldName = 'Tags'; + const itemFieldName = 'Item'; + + let table: ITableFullVo; + let tagField: IFieldRo; + + beforeAll(async () => { + table = await createTable(baseId, { + name: 'group_multi_select_special_char', + fields: [ + { name: itemFieldName, type: FieldType.SingleLineText }, + { + name: tagFieldName, + type: FieldType.MultipleSelect, + options: { choices: choiceDefinitions }, + }, + ], + records: [ + { fields: { [itemFieldName]: 'r1', [tagFieldName]: ['Alpha?'] } }, + { fields: { [itemFieldName]: 'r2', [tagFieldName]: ['Beta!'] } }, + { fields: { [itemFieldName]: 'r3', [tagFieldName]: ['Gamma'] } }, + ], + }); + tagField = table.fields!.find( + ({ name, type }) => name === tagFieldName && type === FieldType.MultipleSelect + ) as IFieldRo; + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, table.id); + }); + + it('groups correctly when multiple select choice name contains ? character', async () => { + const query: IGetRecordsRo = { + fieldKeyType: FieldKeyType.Id, + groupBy: [{ fieldId: tagField.id!, order: SortFunc.Asc }], + }; + const { records, extra } = await getRecords(table.id, query); + + const headerValues = + extra?.groupPoints + ?.filter((point): point is IGroupHeaderPoint => point.type === GroupPointType.Header) + .map((point) => point.value) ?? []; + + expect(headerValues).toHaveLength(3); + expect(records).toHaveLength(3); + }); +}); diff --git a/apps/nestjs-backend/test/record-search-query.e2e-spec.ts b/apps/nestjs-backend/test/record-search-query.e2e-spec.ts index 0452682c62..9a6ab7ea33 100644 --- a/apps/nestjs-backend/test/record-search-query.e2e-spec.ts +++ b/apps/nestjs-backend/test/record-search-query.e2e-spec.ts @@ -321,6 +321,75 @@ describe('OpenAPI Record-Search-Query (e2e)', async () => { }); }); + describe('global search should skip number fields for non-numeric queries', () => { + let table: ITableFullVo; + beforeAll(async () => { + table = await createTable(baseId, { + name: 'number_skip_test', + fields: [ + { + name: 'text', + type: FieldType.SingleLineText, + }, + { + name: 'amount', + type: FieldType.Number, + options: { + formatting: { type: 'decimal', precision: 0 }, + }, + }, + ], + records: [ + { fields: { text: 'apple', amount: 100 } }, + { fields: { text: 'banana', amount: 200 } }, + { fields: { text: '100 items', amount: 300 } }, + ], + }); + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, table.id); + }); + + it('should not match number fields when searching non-numeric text globally', async () => { + const { records } = ( + await apiGetRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: table.views[0].id, + search: ['apple', '', true], + }) + ).data; + // should only match the text field, not scan number fields + expect(records.length).toBe(1); + expect(records[0].fields[table.fields[0].id]).toBe('apple'); + }); + + it('should match number fields when searching numeric text globally', async () => { + const { records } = ( + await apiGetRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: table.views[0].id, + search: ['100', '', true], + }) + ).data; + // should match both text "100 items" and number 100 + expect(records.length).toBe(2); + }); + + it('should still search number fields when targeting a specific field', async () => { + const numberFieldId = table.fields[1].id; + const { records } = ( + await apiGetRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + viewId: table.views[0].id, + search: ['apple', numberFieldId, true], + }) + ).data; + // no number value matches "apple" + expect(records.length).toBe(0); + }); + }); + describe('search value with special characters', () => { let table: ITableFullVo; beforeAll(async () => { diff --git a/apps/nestjs-backend/test/rollup.e2e-spec.ts b/apps/nestjs-backend/test/rollup.e2e-spec.ts index 30701842fd..11097b5663 100644 --- a/apps/nestjs-backend/test/rollup.e2e-spec.ts +++ b/apps/nestjs-backend/test/rollup.e2e-spec.ts @@ -1389,63 +1389,4 @@ describe('OpenAPI Rollup field (e2e)', () => { expect(record1.fields[rollup1.id]).toEqual(0); }); }); - - describe('v2 update field hasError propagation', () => { - const isForceV2 = process.env.FORCE_V2_ALL === 'true'; - const itV2Only = isForceV2 ? it : it.skip; - - itV2Only( - 'marks rollup as errored when foreign lookup field type becomes incompatible via v2 convert', - async () => { - const foreign = await createTable(baseId, { - name: 'V2RollupHasError_Foreign', - fields: [{ name: 'Amount', type: FieldType.Number } as IFieldRo], - }); - const host = await createTable(baseId, { - name: 'V2RollupHasError_Host', - fields: [{ name: 'Label', type: FieldType.SingleLineText } as IFieldRo], - }); - const amountFieldId = foreign.fields.find((field) => field.name === 'Amount')!.id; - - try { - const linkField = await createField(host.id, { - name: 'Link to Foreign', - type: FieldType.Link, - options: { - relationship: Relationship.OneMany, - foreignTableId: foreign.id, - }, - } as IFieldRo); - - const rollupField = await createField(host.id, { - name: 'Sum Amount', - type: FieldType.Rollup, - options: { - expression: 'sum({values})', - }, - lookupOptions: { - foreignTableId: foreign.id, - linkFieldId: linkField.id, - lookupFieldId: amountFieldId, - } as ILookupOptionsRo, - } as IFieldRo); - - expect((await getField(host.id, rollupField.id)).hasError).toBeFalsy(); - - // Convert the foreign lookup field to an incompatible type via v2 - await convertField(foreign.id, amountFieldId, { - name: 'Amount', - type: FieldType.SingleLineText, - options: {}, - } as IFieldRo); - - const afterConvert = await getField(host.id, rollupField.id); - expect(afterConvert.hasError).toBe(true); - } finally { - await permanentDeleteTable(baseId, host.id); - await permanentDeleteTable(baseId, foreign.id); - } - } - ); - }); }); diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/CodingModels.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/CodingModels.tsx index 703a75a9d2..8cb0f5a88f 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/CodingModels.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/CodingModels.tsx @@ -3,14 +3,17 @@ import { chatModelAbilityType } from '@teable/openapi'; import type { IAIIntegrationConfig, IChatModelAbility, IAbilityDetail } from '@teable/openapi'; import { cn, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@teable/ui-lib/shadcn'; -import { Cpu } from 'lucide-react'; +import { ChevronRight, Cpu } from 'lucide-react'; import { useTranslation } from 'next-i18next'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { AIModelSelect, type IModelOption } from './AiModelSelect'; // Helper to check if ability is supported (handles both boolean and detailed format) @@ -52,6 +55,10 @@ export const CodingModels = ({ placeholder?: string; }) => { const { t } = useTranslation('common'); + const [tiersOpen, setTiersOpen] = useState( + () => + Boolean(value?.md && value.md !== value?.lg) || Boolean(value?.sm && value.sm !== value?.lg) + ); const abilityIconMap = useMemo(() => { return { @@ -74,15 +81,38 @@ export const CodingModels = ({ return selectedModel?.capabilities as IChatModelAbility | undefined; }, [value?.lg, value?.ability, models]); - const handleModelChange = (model: string) => { + const handleLgChange = (model: string) => { // Get ability from the model's capabilities (already tested) const selectedModel = models?.find((m) => m.modelKey === model); const ability = (selectedModel?.capabilities as IChatModelAbility) || {}; - // Set all sizes to the same model (simplified selection) - onChange({ ...value, lg: model, md: model, sm: model, ability }); + // Update lg; clear md/sm if they were inheriting (same as old lg) + const next: IAIIntegrationConfig['chatModel'] = { ...value, lg: model, ability }; + if (value?.md === value?.lg) next.md = undefined; + if (value?.sm === value?.lg) next.sm = undefined; + onChange(next); + }; + + const handleMdChange = (model: string) => { + onChange({ ...value, md: model || undefined }); + }; + + const handleSmChange = (model: string) => { + onChange({ ...value, sm: model || undefined }); }; + // Display name of the lg model for inherit hint + const lgModelLabel = useMemo(() => { + if (!value?.lg || !models) return ''; + const m = models.find((m) => m.modelKey === value.lg); + return m?.label || value.lg; + }, [value?.lg, models]); + + const inheritPlaceholder = useMemo( + () => t('admin.setting.ai.chatModels.inheritHint', { model: lgModelLabel }), + [t, lgModelLabel] + ); + // Icon for chat model selection const chatModelIcon = useMemo(() => , []); @@ -124,12 +154,20 @@ export const CodingModels = ({ return missing.length > 0 ? missing : null; }, [value?.lg, isModelTested, selectedModelAbility, t]); + // Count how many tiers have a custom (non-inherited) model + const customizedCount = useMemo(() => { + let count = 0; + if (value?.md && value.md !== value?.lg) count++; + if (value?.sm && value.sm !== value?.lg) count++; + return count; + }, [value?.lg, value?.md, value?.sm]); + // Abilities to test and display const testableAbilities = chatModelAbilityType.options; return (
- {/* Chat model selection - simplified to one model */} + {/* LG - Primary chat model (required) */}
{chatModelIcon} @@ -142,7 +180,7 @@ export const CodingModels = ({ )}
+ + {/* Model tiers - collapsible */} + {value?.lg && ( + + + + {t('admin.setting.ai.chatModels.modelTiers')} + {!tiersOpen && ( + + {customizedCount > 0 + ? t('admin.setting.ai.chatModels.customized', { count: customizedCount }) + : t('admin.setting.ai.chatModels.allInheriting')} + + )} + + +
+ {t('admin.setting.ai.chatModels.modelTiersDescription')} +
+
+ {/* MD - Standard */} +
+
+ {t('admin.setting.ai.chatModels.md')} + + {t('admin.setting.ai.chatModels.mdDescription')} + +
+ +
+ {/* SM - Lightweight */} +
+
+ {t('admin.setting.ai.chatModels.sm')} + + {t('admin.setting.ai.chatModels.smDescription')} + +
+ +
+
+
+
+ )}
); }; diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/DefaultModelsStep.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/DefaultModelsStep.tsx index 1085e30891..7ad8ae1bb6 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/DefaultModelsStep.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/DefaultModelsStep.tsx @@ -3,13 +3,18 @@ import { Zap, MessageSquare, Star, HelpCircle } from '@teable/icons'; import { Button, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@teable/ui-lib/shadcn'; +import { ChevronRight } from 'lucide-react'; import { useTranslation } from 'next-i18next'; -import { useCallback } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { IModelOption } from './AiModelSelect'; import { AIModelSelect } from './AiModelSelect'; @@ -33,6 +38,18 @@ export function DefaultModelsStep({ disabled, }: IDefaultModelsStepProps) { const { t } = useTranslation('common'); + const [tiersOpen, setTiersOpen] = useState( + () => + Boolean(chatModel?.md && chatModel.md !== chatModel?.lg) || + Boolean(chatModel?.sm && chatModel.sm !== chatModel?.lg) + ); + + const customizedCount = useMemo(() => { + let count = 0; + if (chatModel?.md && chatModel.md !== chatModel?.lg) count++; + if (chatModel?.sm && chatModel.sm !== chatModel?.lg) count++; + return count; + }, [chatModel?.lg, chatModel?.md, chatModel?.sm]); // Filter to only text models (not image models) const textModels = models.filter((m) => !m.isImageModel); @@ -40,27 +57,49 @@ export function DefaultModelsStep({ // Find a recommended default (first gateway model, or first model) const recommendedDefault = textModels.find((m) => m.isGateway) || textModels[0]; + const lgModelLabel = useMemo(() => { + if (!chatModel?.lg) return ''; + const m = textModels.find((m) => m.modelKey === chatModel.lg); + return m?.label || chatModel.lg; + }, [chatModel?.lg, textModels]); + + const inheritPlaceholder = useMemo( + () => t('admin.setting.ai.chatModels.inheritHint', { model: lgModelLabel }), + [t, lgModelLabel] + ); + const handleUseRecommended = useCallback(() => { if (recommendedDefault) { - // Set the same model for all sizes onChange({ + ...chatModel, lg: recommendedDefault.modelKey, - md: recommendedDefault.modelKey, - sm: recommendedDefault.modelKey, }); } - }, [recommendedDefault, onChange]); + }, [recommendedDefault, chatModel, onChange]); - const handleModelChange = useCallback( + const handleLgChange = useCallback( (value: string) => { - // Set all sizes to the same model for simplicity - onChange({ - lg: value, - md: value, - sm: value, - }); + const next: IChatModel = { ...chatModel, lg: value }; + // Clear md/sm if they were inheriting from the old lg + if (chatModel?.md === chatModel?.lg) next.md = undefined; + if (chatModel?.sm === chatModel?.lg) next.sm = undefined; + onChange(next); + }, + [chatModel, onChange] + ); + + const handleMdChange = useCallback( + (value: string) => { + onChange({ ...chatModel, md: value || undefined }); }, - [onChange] + [chatModel, onChange] + ); + + const handleSmChange = useCallback( + (value: string) => { + onChange({ ...chatModel, sm: value || undefined }); + }, + [chatModel, onChange] ); if (disabled) { @@ -107,7 +146,7 @@ export function DefaultModelsStep({
)} - {/* Model Selection - simplified to one model */} + {/* Model Selection */}
@@ -126,10 +165,70 @@ export function DefaultModelsStep({ + + {/* Model tiers - collapsible */} + {chatModel?.lg && ( + + + + {t('admin.setting.ai.chatModels.modelTiers')} + {!tiersOpen && ( + + {customizedCount > 0 + ? t('admin.setting.ai.chatModels.customized', { count: customizedCount }) + : t('admin.setting.ai.chatModels.allInheriting')} + + )} + + +
+ {t('admin.setting.ai.chatModels.modelTiersDescription')} +
+
+
+
+ + {t('admin.setting.ai.chatModels.md')} + + + {t('admin.setting.ai.chatModels.mdDescription')} + +
+ +
+
+
+ + {t('admin.setting.ai.chatModels.sm')} + + + {t('admin.setting.ai.chatModels.smDescription')} + +
+ +
+
+
+
+ )}
{/* Status */} diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/ModelSelectTrigger.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/ModelSelectTrigger.tsx index 9fbf8e0bab..cd22ae0b32 100644 --- a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/ModelSelectTrigger.tsx +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/ai-config/ai-model-select/ModelSelectTrigger.tsx @@ -40,7 +40,9 @@ export const ModelSelectTrigger = forwardRef
{!currentModel ? ( - placeholder ?? t('admin.setting.ai.selectModel') + + {placeholder ?? t('admin.setting.ai.selectModel')} + ) : ( <> {Icon && } diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx index df20ae8a65..d69dcde711 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSidebarHeaderLeft.tsx @@ -133,14 +133,16 @@ const BaseDropdownMenu = ({
)} - setOpen(false)} closeOnSuccess={false}> - e.preventDefault()}> -
- - {t('space:publishBase.publishToCommunity')} -
-
-
+ {showRename && ( + setOpen(false)} closeOnSuccess={false}> + e.preventDefault()}> +
+ + {t('space:publishBase.publishToCommunity')} +
+
+
+ )} diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/NodeShareContent.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/NodeShareContent.tsx index 11bb39be80..9231b8d23c 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/NodeShareContent.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/NodeShareContent.tsx @@ -48,7 +48,7 @@ import { TooltipTrigger, } from '@teable/ui-lib/shadcn'; import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; -import { Check, ChevronDown, ChevronRight, Eye, HelpCircle } from 'lucide-react'; +import { Check, ChevronDown, ChevronRight, Eye } from 'lucide-react'; import { useTranslation } from 'next-i18next'; import { QRCodeSVG } from 'qrcode.react'; import { useMemo, useState } from 'react'; @@ -403,42 +403,68 @@ export const NodeShareContent = ({ {isShareEnabled && share && ( <>
-
- {t('table:baseShare.linkHolderLabel')} - - - - - - handleUpdateSetting({ allowSave: false })} - > - {!share.allowSave ? ( - - ) : ( - - )} - {t('table:baseShare.linkHolderCanView')} - - handleUpdateSetting({ allowSave: true })} - > - {share.allowSave ? ( - - ) : ( - - )} - {t('table:baseShare.linkHolderCanCopyAndSave')} - - - +
+
+ + {t('table:baseShare.linkHolderLabel')} + + + + + + + {[ + { + active: !share.allowSave && !share.allowEdit, + label: t('table:baseShare.linkHolderCanView'), + desc: t('table:baseShare.linkHolderCanViewDesc'), + onClick: () => handleUpdateSetting({ allowSave: false, allowEdit: false }), + }, + node.resourceType === BaseNodeResourceType.Table && { + active: !!share.allowEdit, + label: t('table:baseShare.linkHolderCanEdit'), + desc: t('table:baseShare.linkHolderCanEditDesc'), + onClick: () => handleUpdateSetting({ allowEdit: true, allowSave: false }), + }, + { + active: !!share.allowSave, + label: t('table:baseShare.linkHolderCanCopyAndSave'), + desc: t('table:baseShare.linkHolderCanCopyAndSaveDesc'), + onClick: () => handleUpdateSetting({ allowSave: true, allowEdit: false }), + }, + ] + .filter((item): item is Exclude => Boolean(item)) + .map((item) => ( + +
+ {item.active ? ( + + ) : ( + + )} +
+ {item.label} + + {item.desc} + +
+
+
+ ))} +
+
+
diff --git a/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx b/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx index 39f8270a81..facbc2195e 100644 --- a/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx +++ b/apps/nextjs-app/src/features/app/blocks/design/components/IntegrityV2Components.tsx @@ -1,22 +1,62 @@ -import { Badge, Button, ToggleGroup, ToggleGroupItem, cn } from '@teable/ui-lib/shadcn'; +import { FieldType } from '@teable/core'; +import { useFieldStaticGetter } from '@teable/sdk/hooks'; +import { + Alert, + AlertDescription, + Badge, + Button, + Checkbox, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, + ToggleGroup, + ToggleGroupItem, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + cn, +} from '@teable/ui-lib/shadcn'; import { AlertTriangle, + ChevronDown, CheckCircle2, Clock, + Columns3, + Info, Loader2, RefreshCcw, + Table2, Wrench, XCircle, } from 'lucide-react'; import { useTranslation } from 'next-i18next'; +import { useEffect, useMemo, useState, type ComponentType } from 'react'; import { getLocalizedDetailItems, + getLocalizedRepairDescription, + getLocalizedRepairReason, getLocalizedResultMessage, getLocalizedRuleDescription, getGroupDisplayName, getGroupDisplayState, integrityFilterStatuses, getPhaseText, + translateIntegrityMessage, type GroupDisplayState, type IntegrityFilterStatus, type IntegrityPhase, @@ -76,14 +116,462 @@ const OutcomeBadge = ({ result }: { result: IntegrityResult }) => { ); }; -const RuleResultItem = ({ result }: { result: IntegrityResult }) => { +const SYSTEM_FIELD_TYPE_MAP: Record = { + __id: FieldType.SingleLineText, + __auto_number: FieldType.AutoNumber, + __created_time: FieldType.CreatedTime, + __last_modified_time: FieldType.LastModifiedTime, + __created_by: FieldType.CreatedBy, + __last_modified_by: FieldType.LastModifiedBy, + __version: FieldType.Number, +}; + +const getSystemFieldType = (fieldId: string) => { + if (!fieldId.startsWith('__system__:')) { + return undefined; + } + + return SYSTEM_FIELD_TYPE_MAP[fieldId.replace('__system__:', '')]; +}; + +const getRuleType = (ruleId: string) => ruleId.split(':')[0]; + +const getColumnDataType = (ruleDescription: string) => { + const match = ruleDescription.match(/\(([^()]+)\)\s*$/); + return match?.[1]?.toLowerCase(); +}; + +const DB_TYPE_TO_FIELD_TYPE: Record = { + text: FieldType.SingleLineText, + varchar: FieldType.SingleLineText, + 'character varying': FieldType.SingleLineText, + integer: FieldType.Number, + bigint: FieldType.Number, + numeric: FieldType.Number, + real: FieldType.Number, + 'double precision': FieldType.Number, + timestamptz: FieldType.Date, + timestamp: FieldType.Date, + date: FieldType.Date, + boolean: FieldType.Checkbox, +}; + +const inferFieldTypeFromReferenceRule = (description: string) => { + if (description.includes('conditional rollup')) { + return { type: FieldType.ConditionalRollup }; + } + + if (description.includes('rollup')) { + return { type: FieldType.Rollup }; + } + + if (description.includes('conditional lookup')) { + return { type: FieldType.Link, isLookup: true, isConditionalLookup: true }; + } + + if (description.includes('lookup field') || description.includes('lookup-link field')) { + return { type: FieldType.Link, isLookup: true }; + } + + return undefined; +}; + +const inferFieldTypeFromRule = (result: IntegrityResult) => { + const ruleType = getRuleType(result.ruleId); + const description = result.ruleDescription.toLowerCase(); + + if ( + ruleType === 'link_value_column' || + ruleType === 'fk_column' || + ruleType === 'order_column' || + ruleType === 'field_meta' || + ruleType === 'symmetric_field' + ) { + return { type: FieldType.Link }; + } + + if (ruleType === 'reference') { + return inferFieldTypeFromReferenceRule(description); + } + + if (ruleType === 'generated_column' || ruleType === 'generated_meta') { + return { type: FieldType.Formula }; + } + + if (ruleType === 'column' || ruleType === 'system_column') { + const columnDataType = getColumnDataType(result.ruleDescription); + if (columnDataType && DB_TYPE_TO_FIELD_TYPE[columnDataType]) { + return { type: DB_TYPE_TO_FIELD_TYPE[columnDataType] }; + } + } + + return undefined; +}; + +const inferFieldTypeFromGroup = (group: ResultGroup) => { + const systemFieldType = getSystemFieldType(group.fieldId); + if (systemFieldType) { + return { type: systemFieldType }; + } + + for (const result of group.results) { + const inferredFieldType = inferFieldTypeFromRule(result); + if (inferredFieldType) { + return inferredFieldType; + } + } + + return undefined; +}; + +type ManualRepairSchema = NonNullable['manualRepairSchema']>; +type ManualRepairProperty = ManualRepairSchema['properties'][string]; + +const getManualRepairDefaultValues = (manualRepairSchema?: ManualRepairSchema) => { + return Object.fromEntries( + Object.entries(manualRepairSchema?.properties || {}).map(([key, property]) => [ + key, + property.defaultValue ?? (property.type === 'boolean' ? false : ''), + ]) + ) as Record; +}; + +const getManualRepairWidget = (property: ManualRepairProperty) => { + return ( + property.widget ?? + (property.options?.length ? 'select' : property.type === 'boolean' ? 'checkbox' : 'text') + ); +}; + +const ManualRepairFieldInput = ({ + property, + value, + onChange, + t, +}: { + property: ManualRepairProperty; + value: string | boolean | undefined; + onChange: (value: string | boolean) => void; + t: Translate; +}) => { + const widget = getManualRepairWidget(property); + + if (widget === 'select') { + return ( + + ); + } + + if (widget === 'textarea') { + return ( +