diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts index d74ab71f5b..c75b7d158f 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts @@ -1906,7 +1906,7 @@ export class FieldSupplementService { select: { name: true, baseId: true }, }); - const fieldName = await this.uniqFieldName(tableId, tableName); + const fieldName = await this.uniqFieldName(field.options.foreignTableId, tableName); // lookup field id is the primary field of the table to which it is linked const { id: lookupFieldId } = await prisma.field.findFirstOrThrow({ diff --git a/apps/nestjs-backend/test/field-duplicate.e2e-spec.ts b/apps/nestjs-backend/test/field-duplicate.e2e-spec.ts index a647aeb836..d501b5434b 100644 --- a/apps/nestjs-backend/test/field-duplicate.e2e-spec.ts +++ b/apps/nestjs-backend/test/field-duplicate.e2e-spec.ts @@ -36,6 +36,7 @@ import { initApp, createRecords, getRecords, + convertField, } from './utils/init-app'; describe('OpenAPI FieldOpenApiController for duplicate field (e2e)', () => { @@ -598,6 +599,96 @@ describe('OpenAPI FieldOpenApiController for duplicate field (e2e)', () => { await permanentDeleteTable(baseId, table.id); await permanentDeleteTable(baseId, subTable.id); }); + + it('keeps symmetric field names unique after converting a duplicated one-way link back to two-way', async () => { + let sourceTable: ITableFullVo | undefined; + let foreignTable: ITableFullVo | undefined; + + try { + sourceTable = await createTable(baseId, { + name: 'dup_link_name_source', + fields: [{ name: 'Name', type: FieldType.SingleLineText, isPrimary: true } as IFieldRo], + }); + + foreignTable = await createTable(baseId, { + name: 'dup_link_name_foreign', + fields: [{ name: 'Title', type: FieldType.SingleLineText, isPrimary: true } as IFieldRo], + }); + + const foreignPrimaryFieldId = foreignTable.fields.find((field) => field.isPrimary)?.id; + expect(foreignPrimaryFieldId).toBeDefined(); + if (!foreignPrimaryFieldId) { + throw new Error('Missing foreign primary field'); + } + + const originalField = ( + await createField(sourceTable.id, { + type: FieldType.Link, + name: 'Customer', + options: { + relationship: Relationship.ManyMany, + foreignTableId: foreignTable.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: false, + }, + }) + ).data; + + const originalSymmetricFieldId = (originalField.options as ILinkFieldOptions) + .symmetricFieldId; + expect(originalSymmetricFieldId).toBeDefined(); + if (!originalSymmetricFieldId) { + throw new Error('Missing original symmetric field'); + } + + const duplicatedField = ( + await duplicateField(sourceTable.id, originalField.id, { + name: 'Customer Copy', + }) + ).data; + + expect((duplicatedField.options as ILinkFieldOptions).isOneWay).toBe(true); + expect((duplicatedField.options as ILinkFieldOptions).symmetricFieldId).toBeUndefined(); + + const convertedField = await convertField(sourceTable.id, duplicatedField.id, { + type: FieldType.Link, + name: duplicatedField.name, + options: { + relationship: Relationship.ManyMany, + foreignTableId: foreignTable.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: false, + }, + }); + + const convertedSymmetricFieldId = (convertedField.options as ILinkFieldOptions) + .symmetricFieldId; + expect(convertedSymmetricFieldId).toBeDefined(); + if (!convertedSymmetricFieldId) { + throw new Error('Missing converted symmetric field'); + } + + const foreignFields = (await getFields(foreignTable.id)).data; + const originalSymmetricField = foreignFields.find( + (field) => field.id === originalSymmetricFieldId + ); + const convertedSymmetricField = foreignFields.find( + (field) => field.id === convertedSymmetricFieldId + ); + + expect(originalSymmetricField?.name).toBeDefined(); + expect(convertedSymmetricField?.name).toBeDefined(); + expect(originalSymmetricField?.name).not.toBe(convertedSymmetricField?.name); + expect(new Set([originalSymmetricField?.name, convertedSymmetricField?.name]).size).toBe(2); + } finally { + if (sourceTable) { + await permanentDeleteTable(baseId, sourceTable.id); + } + if (foreignTable) { + await permanentDeleteTable(baseId, foreignTable.id); + } + } + }); }); describe('duplicate link field should copy cell data', () => { diff --git a/packages/v2/e2e/src/duplicateField.e2e.spec.ts b/packages/v2/e2e/src/duplicateField.e2e.spec.ts index 46d3f92428..b6a53a2da6 100644 --- a/packages/v2/e2e/src/duplicateField.e2e.spec.ts +++ b/packages/v2/e2e/src/duplicateField.e2e.spec.ts @@ -336,6 +336,123 @@ describe('duplicateField', () => { await ctx.deleteTable(table.id); }); + it('keeps symmetric field names unique after converting a duplicated one-way link back to two-way', async () => { + let hostTableId: string | undefined; + let foreignTableId: string | undefined; + + try { + const hostTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `DupLinkNameHost-${Date.now()}`, + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }); + hostTableId = hostTable.id; + + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: `DupLinkNameForeign-${Date.now()}`, + fields: [{ type: 'singleLineText', name: 'Title', isPrimary: true }], + }); + foreignTableId = foreignTable.id; + + const foreignPrimaryFieldId = foreignTable.fields.find((field) => field.isPrimary)?.id; + expect(foreignPrimaryFieldId).toBeTruthy(); + if (!foreignPrimaryFieldId) return; + + const hostTableWithLink = await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { + type: 'link', + name: 'Customer', + options: { + foreignTableId: foreignTable.id, + relationship: 'manyMany', + lookupFieldId: foreignPrimaryFieldId, + isOneWay: false, + }, + }, + }); + + const originalField = hostTableWithLink.fields.find((field) => field.name === 'Customer'); + expect(originalField).toBeTruthy(); + if (!originalField) return; + + const originalSymmetricFieldId = (originalField.options as { symmetricFieldId?: string }) + .symmetricFieldId; + expect(originalSymmetricFieldId).toBeTruthy(); + if (!originalSymmetricFieldId) return; + + const duplicateResponse = await fetch(`${ctx.baseUrl}/tables/duplicateField`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + baseId: ctx.baseId, + tableId: hostTable.id, + fieldId: originalField.id, + includeRecordValues: true, + newFieldName: 'Customer Copy', + }), + }); + + expect(duplicateResponse.status).toBe(200); + const duplicateRaw = await duplicateResponse.json(); + const duplicateParsed = duplicateFieldOkResponseSchema.safeParse(duplicateRaw); + expect(duplicateParsed.success).toBe(true); + expect(duplicateParsed.success && duplicateParsed.data.ok).toBe(true); + if (!duplicateParsed.success || !duplicateParsed.data.ok) return; + + const duplicatedFieldId = duplicateParsed.data.data.newFieldId; + + const duplicatedTable = await ctx.getTableById(hostTable.id); + const duplicatedField = duplicatedTable.fields.find( + (field) => field.id === duplicatedFieldId + ); + expect(duplicatedField?.type).toBe('link'); + expect((duplicatedField?.options as { isOneWay?: boolean })?.isOneWay).toBe(true); + + const updatedTable = await ctx.updateField({ + tableId: hostTable.id, + fieldId: duplicatedFieldId, + field: { + options: { + foreignTableId: foreignTable.id, + relationship: 'manyMany', + lookupFieldId: foreignPrimaryFieldId, + isOneWay: false, + }, + }, + }); + + const updatedField = updatedTable.fields.find((field) => field.id === duplicatedFieldId); + const newSymmetricFieldId = (updatedField?.options as { symmetricFieldId?: string }) + ?.symmetricFieldId; + + expect(newSymmetricFieldId).toBeTruthy(); + if (!newSymmetricFieldId) return; + + const foreignTableAfter = await ctx.getTableById(foreignTable.id); + const originalSymmetricField = foreignTableAfter.fields.find( + (field) => field.id === originalSymmetricFieldId + ); + const newSymmetricField = foreignTableAfter.fields.find( + (field) => field.id === newSymmetricFieldId + ); + + expect(originalSymmetricField?.name).toBeTruthy(); + expect(newSymmetricField?.name).toBeTruthy(); + expect(originalSymmetricField?.name).not.toBe(newSymmetricField?.name); + expect(new Set([originalSymmetricField?.name, newSymmetricField?.name]).size).toBe(2); + } finally { + if (hostTableId) { + await ctx.deleteTable(hostTableId); + } + if (foreignTableId) { + await ctx.deleteTable(foreignTableId); + } + } + }); + it('duplicates all field types with unique dbFieldName', async () => { let hostTableId: string | undefined; let foreignTableId: string | undefined;