diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.spec.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.spec.ts index f4ce4229bd..33d18eb797 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.spec.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.spec.ts @@ -25,11 +25,35 @@ describe('FieldConvertingService', () => { { formatting: 'italic', showAs: 'number', + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: 'fldxxxxxxx01', + operator: 'is', + value: 'x', + }, + ], + }, + filterByViewId: 'viewxxxxxxx01', + hiddenFieldIds: ['fldxxxxxxx01'], anotherKey: 'anotherKey', }, { formatting: 'bold', showAs: 'text', + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: 'fldxxxxxxx02', + operator: 'is', + value: 'x', + }, + ], + }, + filterByViewId: 'viewxxxxxxx02', + hiddenFieldIds: ['fldxxxxxxx02'], otherKey: 'otherKey', } ) @@ -43,11 +67,35 @@ describe('FieldConvertingService', () => { { formatting: 'italic', showAs: 'number', + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: 'fldxxxxxxx01', + operator: 'is', + value: 'x', + }, + ], + }, + filterByViewId: 'viewxxxxxxx01', + hiddenFieldIds: ['fldxxxxxxx01'], anotherKey: 'anotherKey', }, { formatting: 'bold', showAs: 'text', + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: 'fldxxxxxxx02', + operator: 'is', + value: 'x', + }, + ], + }, + filterByViewId: 'viewxxxxxxx02', + hiddenFieldIds: ['fldxxxxxxx02'], otherKey: 'otherKey', }, true @@ -57,6 +105,9 @@ describe('FieldConvertingService', () => { otherKey: null, formatting: null, showAs: null, + filter: null, + filterByViewId: null, + hiddenFieldIds: null, }); expect( @@ -64,11 +115,35 @@ describe('FieldConvertingService', () => { { formatting: 'italic', showAs: 'number', + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: 'fldxxxxxxx01', + operator: 'is', + value: 'x', + }, + ], + }, + filterByViewId: 'viewxxxxxxx01', + hiddenFieldIds: ['fldxxxxxxx01'], otherKey: 'newOtherKey', }, { formatting: 'bold', showAs: 'text', + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: 'fldxxxxxxx02', + operator: 'is', + value: 'x', + }, + ], + }, + filterByViewId: 'viewxxxxxxx02', + hiddenFieldIds: ['fldxxxxxxx02'], otherKey: 'oldOtherKey', } ) diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts index dae77ff09e..a9fe13a309 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts @@ -26,7 +26,11 @@ import { PrismaService } from '@teable/db-main-prisma'; import { Knex } from 'knex'; import { difference, intersection, isEmpty, isEqual, keyBy, set } from 'lodash'; import { InjectModel } from 'nest-knexjs'; -import { majorFieldKeysChanged } from '../../../utils/major-field-keys-changed'; +import { + majorFieldKeysChanged, + majorOptionsKeyChanged, + NON_INFECT_OPTION_KEYS, +} from '../../../utils/major-field-keys-changed'; import { BatchService } from '../../calculation/batch.service'; import { FieldCalculationService } from '../../calculation/field-calculation.service'; import { LinkService } from '../../calculation/link.service'; @@ -253,7 +257,7 @@ export class FieldConvertingService { newOptions = { ...newOptions }; oldOptions = { ...oldOptions }; - const nonInfectKeys = ['formatting', 'showAs']; + const nonInfectKeys = Array.from(NON_INFECT_OPTION_KEYS); nonInfectKeys.forEach((key) => { delete newOptions[key]; delete oldOptions[key]; @@ -1018,7 +1022,7 @@ export class FieldConvertingService { } // for same field with options change - if (keys.includes('options')) { + if (keys.includes('options') && majorOptionsKeyChanged(oldField.options, newField.options)) { return await this.modifyOptions(tableId, newField, oldField); } } @@ -1152,7 +1156,6 @@ export class FieldConvertingService { newField, oldField ); - return { newField, oldField, diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-view-sync.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-view-sync.service.ts index 6625266755..19e030ff46 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-view-sync.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-view-sync.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { getValidFilterOperators, FieldType, ViewOpBuilder } from '@teable/core'; +import { getValidFilterOperators, FieldType, ViewOpBuilder, FieldOpBuilder } from '@teable/core'; import type { IFilterSet, ISelectFieldOptionsRo, @@ -7,10 +7,13 @@ import type { IFilterItem, IFilter, IFilterValue, + ILinkFieldOptions, + IOtOperation, } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { isEqual, differenceBy, find, isEmpty } from 'lodash'; import { ViewService } from '../../view/view.service'; +import { FieldService } from '../field.service'; import type { IFieldInstance } from '../model/factory'; /** @@ -23,14 +26,142 @@ export class FieldViewSyncService { constructor( private readonly viewService: ViewService, + private readonly fieldService: FieldService, private readonly prismaService: PrismaService ) {} - async deleteViewRelativeByFields(tableId: string, fieldIds: string[]) { + async deleteDependenciesByFieldIds(tableId: string, fieldIds: string[]) { await this.viewService.deleteViewRelativeByFields(tableId, fieldIds); + await this.deleteLinkOptionsDependenciesByFieldIds(tableId, fieldIds); } - async convertFieldRelative(tableId: string, newField: IFieldInstance, oldField: IFieldInstance) { + // eslint-disable-next-line sonarjs/cognitive-complexity + async deleteLinkOptionsDependenciesByFieldIds(tableId: string, fieldIds: string[]) { + const foreignFields = await this.getLinkForeignFields(tableId); + const deletedFieldIdSet = new Set(fieldIds); + + for (const field of foreignFields) { + const ops: IOtOperation[] = []; + const { id: fieldId, tableId, options: rawOptions } = field; + const options = rawOptions ? JSON.parse(rawOptions) : null; + + if (options == null) continue; + + const { filter, hiddenFieldIds } = options as ILinkFieldOptions; + const newOptions: ILinkFieldOptions = { ...options }; + let isOptionsChanged = false; + + if (hiddenFieldIds?.length) { + const newHiddenFieldIds = hiddenFieldIds.filter((id) => !deletedFieldIdSet.has(id)); + if (!isEqual(newHiddenFieldIds, hiddenFieldIds)) { + newOptions.hiddenFieldIds = newHiddenFieldIds?.length ? newHiddenFieldIds : null; + isOptionsChanged = true; + } + } + + const filterString = JSON.stringify(filter); + const filteredFieldIds = fieldIds.filter((id) => filterString?.includes(id)); + + if (filter != null && filteredFieldIds.length) { + let newFilter: IFilterSet | null = filter; + filteredFieldIds.forEach((id) => { + if (newFilter) { + newFilter = this.viewService.getDeletedFilterByFieldId(newFilter, id); + } + }); + newOptions.filter = newFilter ? (newFilter?.filterSet?.length ? newFilter : null) : null; + isOptionsChanged = true; + } + + if (isOptionsChanged) { + ops.push( + FieldOpBuilder.editor.setFieldProperty.build({ + key: 'options', + newValue: newOptions, + oldValue: options, + }) + ); + } + + if (ops.length) { + await this.fieldService.batchUpdateFields(tableId, [{ fieldId, ops }]); + } + } + } + + async deleteLinkOptionsDependenciesByViewId(tableId: string, viewId: string) { + const foreignFields = await this.getLinkForeignFields(tableId); + + for (const field of foreignFields) { + const { id: fieldId, tableId, options: rawOptions } = field; + const options = rawOptions ? JSON.parse(rawOptions) : null; + + if (options == null) continue; + + const { filterByViewId } = options as ILinkFieldOptions; + + if (filterByViewId == null || filterByViewId !== viewId) continue; + + const ops = [ + FieldOpBuilder.editor.setFieldProperty.build({ + key: 'options', + oldValue: options, + newValue: { ...options, filterByViewId: null }, + }), + ]; + await this.fieldService.batchUpdateFields(tableId, [{ fieldId, ops }]); + } + } + + async convertDependenciesByFieldIds( + tableId: string, + newField: IFieldInstance, + oldField: IFieldInstance + ) { + await this.convertViewDependenciesByFieldIds(tableId, newField, oldField); + await this.convertLinkOptionsDependenciesByFieldIds(tableId, newField, oldField); + } + + async convertLinkOptionsDependenciesByFieldIds( + tableId: string, + newField: IFieldInstance, + oldField: IFieldInstance + ) { + const convertedFieldId = newField.id; + const foreignFields = await this.getLinkForeignFields(tableId); + + for (const field of foreignFields) { + const { id: fieldId, tableId, options: rawOptions } = field; + const options = rawOptions ? JSON.parse(rawOptions) : null; + + if (options == null) continue; + + const ops: IOtOperation[] = []; + const { filter } = options as ILinkFieldOptions; + + if (filter == null || !JSON.stringify(filter).includes(convertedFieldId)) continue; + + const newFilter = this.getNewFilterByFieldChanges(filter, newField, oldField); + ops.push( + FieldOpBuilder.editor.setFieldProperty.build({ + key: 'options', + oldValue: options, + newValue: { + ...options, + filter: newFilter ? (newFilter?.filterSet?.length ? newFilter : null) : null, + }, + }) + ); + + await this.fieldService.batchUpdateFields(tableId, [{ fieldId, ops }]); + } + } + + async convertViewDependenciesByFieldIds( + tableId: string, + newField: IFieldInstance, + oldField: IFieldInstance + ) { const views = await this.prismaService.txClient().view.findMany({ select: { filter: true, @@ -64,6 +195,21 @@ export class FieldViewSyncService { } } + async getLinkForeignFields(tableId: string) { + const linkFields = await this.prismaService.txClient().field.findMany({ + where: { tableId, type: FieldType.Link, deletedTime: null }, + }); + const foreignFieldIds = linkFields + .map( + ({ options }) => + ((options ? JSON.parse(options) : null) as ILinkFieldOptions)?.symmetricFieldId + ) + .filter(Boolean) as string[]; + return await this.prismaService.txClient().field.findMany({ + where: { id: { in: foreignFieldIds }, type: FieldType.Link, deletedTime: null }, + }); + } + getNewFilterByFieldChanges( originalFilter: IFilter, newField: IFieldInstance, diff --git a/apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts b/apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts index ceab58ea69..6ca936f822 100644 --- a/apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts @@ -22,11 +22,11 @@ import { updateFieldRoSchema, IUpdateFieldRo, } from '@teable/core'; -import { - deleteFieldsQuerySchema, - IDeleteFieldsQuery, - type IPlanFieldConvertVo, - type IPlanFieldVo, +import { deleteFieldsQuerySchema, IDeleteFieldsQuery } from '@teable/openapi'; +import type { + IGetViewFilterLinkRecordsVo, + IPlanFieldConvertVo, + IPlanFieldVo, } from '@teable/openapi'; import { ZodValidationPipe } from '../../../zod.validation.pipe'; import { Permissions } from '../../auth/decorators/permissions.decorator'; @@ -137,6 +137,15 @@ export class FieldOpenApiController { await this.fieldOpenApiService.deleteFields(tableId, query.fieldIds, windowId); } + @Permissions('field|update') + @Get('/:fieldId/filter-link-records') + async getFilterLinkRecords( + @Param('tableId') tableId: string, + @Param('fieldId') fieldId: string + ): Promise { + return this.fieldOpenApiService.getFilterLinkRecords(tableId, fieldId); + } + @Permissions('field|read') @Get('/socket/snapshot-bulk') async getSnapshotBulk(@Param('tableId') tableId: string, @Query('ids') ids: string[]) { diff --git a/apps/nestjs-backend/src/features/field/open-api/field-open-api.module.ts b/apps/nestjs-backend/src/features/field/open-api/field-open-api.module.ts index 6364899a43..f7287e8616 100644 --- a/apps/nestjs-backend/src/features/field/open-api/field-open-api.module.ts +++ b/apps/nestjs-backend/src/features/field/open-api/field-open-api.module.ts @@ -5,6 +5,7 @@ import { CalculationModule } from '../../calculation/calculation.module'; import { GraphModule } from '../../graph/graph.module'; import { RecordOpenApiModule } from '../../record/open-api/record-open-api.module'; import { RecordModule } from '../../record/record.module'; +import { ViewOpenApiModule } from '../../view/open-api/view-open-api.module'; import { ViewModule } from '../../view/view.module'; import { FieldCalculateModule } from '../field-calculate/field-calculate.module'; import { FieldModule } from '../field.module'; @@ -15,7 +16,7 @@ import { FieldOpenApiService } from './field-open-api.service'; imports: [ FieldModule, RecordModule, - ViewModule, + ViewOpenApiModule, ShareDbModule, CalculationModule, RecordOpenApiModule, diff --git a/apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts b/apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts index 19c5f9c756..2425c18da5 100644 --- a/apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts +++ b/apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FieldKeyType, FieldOpBuilder, IFieldRo } from '@teable/core'; +import { FieldKeyType, FieldOpBuilder, FieldType, IFieldRo } from '@teable/core'; import type { IFieldVo, IConvertFieldRo, @@ -22,6 +22,7 @@ import { FieldCalculationService } from '../../calculation/field-calculation.ser import type { IOpsMap } from '../../calculation/reference.service'; import { GraphService } from '../../graph/graph.service'; import { RecordService } from '../../record/record.service'; +import { ViewOpenApiService } from '../../view/open-api/view-open-api.service'; import { ViewService } from '../../view/view.service'; import { FieldConvertingService } from '../field-calculate/field-converting.service'; import { FieldCreatingService } from '../field-calculate/field-creating.service'; @@ -44,6 +45,7 @@ export class FieldOpenApiService { private readonly prismaService: PrismaService, private readonly fieldService: FieldService, private readonly viewService: ViewService, + private readonly viewOpenApiService: ViewOpenApiService, private readonly fieldCreatingService: FieldCreatingService, private readonly fieldDeletingService: FieldDeletingService, private readonly fieldConvertingService: FieldConvertingService, @@ -279,7 +281,7 @@ export class FieldOpenApiService { const referenceMap = await this.getFieldReferenceMap(fieldIds); await this.prismaService.$tx(async () => { - await this.fieldViewSyncService.deleteViewRelativeByFields( + await this.fieldViewSyncService.deleteDependenciesByFieldIds( tableId, fields.map((f) => f.id) ); @@ -401,7 +403,7 @@ export class FieldOpenApiService { // 2. stage alter field await this.prismaService.$tx(async () => { - await this.fieldViewSyncService.convertFieldRelative(tableId, newField, oldField); + await this.fieldViewSyncService.convertDependenciesByFieldIds(tableId, newField, oldField); await this.fieldConvertingService.stageAlter(tableId, newField, oldField); await this.fieldConvertingService.deleteOrCreateSupplementLink(tableId, newField, oldField); // for modify supplement link @@ -467,4 +469,16 @@ export class FieldOpenApiService { return newFieldVo; } + + async getFilterLinkRecords(tableId: string, fieldId: string) { + const field = await this.fieldService.getField(tableId, fieldId); + + if (field.type !== FieldType.Link) return []; + + const { filter, foreignTableId } = field.options as ILinkFieldOptions; + + if (!foreignTableId || !filter) return []; + + return this.viewOpenApiService.getFilterLinkRecordsByTable(foreignTableId, filter); + } } diff --git a/apps/nestjs-backend/src/features/share/share-auth.service.ts b/apps/nestjs-backend/src/features/share/share-auth.service.ts index 3f6f7292d1..ef886118c3 100644 --- a/apps/nestjs-backend/src/features/share/share-auth.service.ts +++ b/apps/nestjs-backend/src/features/share/share-auth.service.ts @@ -6,7 +6,7 @@ import { } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { FieldType } from '@teable/core'; -import type { IViewVo, IShareViewMeta } from '@teable/core'; +import type { IViewVo, IShareViewMeta, ILinkFieldOptions } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { PermissionService } from '../auth/permission.service'; import { createFieldInstanceByRaw } from '../field/model/factory'; @@ -16,6 +16,7 @@ export interface IShareViewInfo { shareId: string; tableId: string; view?: IViewVo; + linkOptions?: Pick; shareMeta?: IShareViewMeta; } @@ -98,9 +99,12 @@ export class ShareAuthService { 'field|read', ]); + const { filterByViewId, hiddenFieldIds, filter } = field.options; + return { shareId: linkFieldId, tableId: field.options.foreignTableId, + linkOptions: { filterByViewId, hiddenFieldIds, filter }, shareMeta: { allowCopy: true, includeRecords: true, diff --git a/apps/nestjs-backend/src/features/share/share-socket.service.ts b/apps/nestjs-backend/src/features/share/share-socket.service.ts index df9f6df8ef..11c3be9f54 100644 --- a/apps/nestjs-backend/src/features/share/share-socket.service.ts +++ b/apps/nestjs-backend/src/features/share/share-socket.service.ts @@ -40,14 +40,24 @@ export class ShareSocketService { return this.viewService.getSnapshotBulk(tableId, [view.id]); } - getFieldDocIdsByQuery(shareInfo: IShareViewInfo, query: IGetFieldsQuery = {}) { - const { tableId, view } = shareInfo; + async getFieldDocIdsByQuery(shareInfo: IShareViewInfo, query: IGetFieldsQuery = {}) { + const { tableId, view, linkOptions } = shareInfo; + const { filterByViewId, hiddenFieldIds } = linkOptions ?? {}; + const viewId = filterByViewId ?? view?.id; const filterHidden = !view?.shareMeta?.includeHiddenField; - return this.fieldService.getDocIdsByQuery(tableId, { + + const { ids: fieldIds } = await this.fieldService.getDocIdsByQuery(tableId, { ...query, - viewId: view?.id, - filterHidden, + viewId, + filterHidden: Boolean(filterByViewId) || filterHidden, }); + + if (hiddenFieldIds?.length) { + return { + ids: fieldIds.filter((id) => !hiddenFieldIds?.includes(id)), + }; + } + return { ids: fieldIds }; } async getFieldSnapshotBulk(shareInfo: IShareViewInfo, ids: string[]) { @@ -59,15 +69,27 @@ export class ShareSocketService { `Field(${unPermissionIds.join(',')}) permission not allowed: read` ); } - return this.fieldService.getSnapshotBulk(tableId, ids); + return this.fieldService.getSnapshotBulk(tableId, fieldIds); } - getRecordDocIdsByQuery(shareInfo: IShareViewInfo, query: IGetRecordsRo) { - const { tableId, view, shareMeta } = shareInfo; + async getRecordDocIdsByQuery(shareInfo: IShareViewInfo, query: IGetRecordsRo) { + const { tableId, view, linkOptions, shareMeta } = shareInfo; + if (!shareMeta?.includeRecords) { return { ids: [] }; } - return this.recordService.getDocIdsByQuery(tableId, { ...query, viewId: view?.id }); + + const { id } = view ?? {}; + const { filterByViewId, hiddenFieldIds } = linkOptions ?? {}; + const viewId = filterByViewId ?? id; + const filter = linkOptions?.filter ?? query.filter; + let projection = query.projection; + + if (filterByViewId || hiddenFieldIds?.length) { + projection = (await this.getFieldDocIdsByQuery(shareInfo, query)).ids; + } + + return this.recordService.getDocIdsByQuery(tableId, { ...query, viewId, filter, projection }); } async getRecordSnapshotBulk(shareInfo: IShareViewInfo, ids: string[]) { diff --git a/apps/nestjs-backend/src/features/share/share.service.ts b/apps/nestjs-backend/src/features/share/share.service.ts index 02a3f7f112..5e1cc1058b 100644 --- a/apps/nestjs-backend/src/features/share/share.service.ts +++ b/apps/nestjs-backend/src/features/share/share.service.ts @@ -69,13 +69,18 @@ export class ShareService { ) {} async getShareView(shareInfo: IShareViewInfo): Promise { - const { shareId, tableId, view, shareMeta } = shareInfo; + const { shareId, tableId, view, linkOptions, shareMeta } = shareInfo; + const { id, group } = view ?? {}; + const { filterByViewId, filter, hiddenFieldIds } = linkOptions ?? {}; + const viewId = filterByViewId ?? id; - const { id: viewId, group } = view ?? {}; const fields = await this.fieldService.getFieldsByQuery(tableId, { viewId, - filterHidden: !shareMeta?.includeHiddenField, + filterHidden: Boolean(filterByViewId) || !shareMeta?.includeHiddenField, }); + const filteredFields = hiddenFieldIds?.length + ? fields.filter((f) => !hiddenFieldIds?.includes(f.id)) + : fields; let records: IRecordsVo['records'] = []; let extra: ShareViewGetVo['extra']; @@ -84,9 +89,10 @@ export class ShareService { viewId, skip: 0, take: 50, + filter, groupBy: group, fieldKeyType: FieldKeyType.Id, - projection: fields.map((f) => f.id), + projection: filteredFields.map((f) => f.id), }); records = recordsData.records; extra = recordsData.extra; @@ -130,7 +136,7 @@ export class ShareService { tableId, viewId, view: view ? this.viewService.convertViewVoAttachmentUrl(view) : undefined, - fields, + fields: filteredFields, records, extra, }; @@ -175,12 +181,21 @@ export class ShareService { shareInfo: IShareViewInfo, query?: IShareViewRowCountRo ): Promise { - if (!shareInfo?.shareMeta?.includeRecords) { + const { view, linkOptions, shareMeta } = shareInfo; + + if (!shareMeta?.includeRecords) { return { rowCount: 0 }; } - const viewId = shareInfo.view?.id; + + const { id } = view ?? {}; + const { filterByViewId, filter } = linkOptions ?? {}; + const viewId = filterByViewId ?? id; const tableId = shareInfo.tableId; - const result = await this.aggregationService.performRowCount(tableId, { viewId, ...query }); + const result = await this.aggregationService.performRowCount(tableId, { + viewId, + filter, + ...query, + }); return { rowCount: result.rowCount, @@ -276,10 +291,13 @@ export class ShareService { } async getFormLinkRecords(field: IFieldVo, query: IShareViewLinkRecordsRo) { - const { lookupFieldId, foreignTableId } = field.options as ILinkFieldOptions; + const { lookupFieldId, foreignTableId, filter, filterByViewId } = + field.options as ILinkFieldOptions; const { take, skip, search } = query; return this.recordService.getRecords(foreignTableId, { + viewId: filterByViewId ?? undefined, + filter, take, skip, search: search ? [search, lookupFieldId] : undefined, diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api.module.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api.module.ts index 706b2bf91b..936f12285f 100644 --- a/apps/nestjs-backend/src/features/view/open-api/view-open-api.module.ts +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ShareDbModule } from '../../../share-db/share-db.module'; +import { FieldCalculateModule } from '../../field/field-calculate/field-calculate.module'; import { FieldModule } from '../../field/field.module'; import { RecordModule } from '../../record/record.module'; import { ViewModule } from '../view.module'; @@ -7,7 +8,7 @@ import { ViewOpenApiController } from './view-open-api.controller'; import { ViewOpenApiService } from './view-open-api.service'; @Module({ - imports: [ViewModule, ShareDbModule, RecordModule, FieldModule], + imports: [ViewModule, ShareDbModule, RecordModule, FieldModule, FieldCalculateModule], controllers: [ViewOpenApiController], providers: [ViewOpenApiService], exports: [ViewOpenApiService], diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts index 7073b3de4a..cedd69e530 100644 --- a/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts @@ -49,6 +49,7 @@ import { Events } from '../../../event-emitter/events'; import type { IClsStore } from '../../../types/cls'; import { Timing } from '../../../utils/timing'; import { updateMultipleOrders, updateOrder } from '../../../utils/update-order'; +import { FieldViewSyncService } from '../../field/field-calculate/field-view-sync.service'; import { FieldService } from '../../field/field.service'; import type { IFieldInstance } from '../../field/model/factory'; import { createFieldInstanceByRaw, createFieldInstanceByVo } from '../../field/model/factory'; @@ -65,6 +66,7 @@ export class ViewOpenApiService { private readonly recordService: RecordService, private readonly viewService: ViewService, private readonly fieldService: FieldService, + private readonly fieldViewSyncService: FieldViewSyncService, private readonly eventEmitterService: EventEmitterService, private readonly cls: ClsService, @InjectDbProvider() private readonly dbProvider: IDbProvider, @@ -86,6 +88,7 @@ export class ViewOpenApiService { async deleteView(tableId: string, viewId: string) { return await this.prismaService.$tx(async () => { + await this.fieldViewSyncService.deleteLinkOptionsDependenciesByViewId(tableId, viewId); return await this.deleteViewInner(tableId, viewId); }); } diff --git a/apps/nestjs-backend/src/utils/major-field-keys-changed.ts b/apps/nestjs-backend/src/utils/major-field-keys-changed.ts index d75aa70f5d..5a13c8ee99 100644 --- a/apps/nestjs-backend/src/utils/major-field-keys-changed.ts +++ b/apps/nestjs-backend/src/utils/major-field-keys-changed.ts @@ -1,7 +1,25 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import type { IFieldVo, IConvertFieldRo } from '@teable/core'; import { FIELD_RO_PROPERTIES } from '@teable/core'; import { isEqual, difference } from 'lodash'; +export const NON_INFECT_OPTION_KEYS = new Set([ + 'formatting', + 'showAs', + 'hiddenFieldIds', + 'filterByViewId', + 'filter', +]); + +export const majorOptionsKeyChanged = ( + oldOptions: Record, + newOptions: Record +) => { + const keys = Object.keys(newOptions).filter((key) => !isEqual(oldOptions[key], newOptions[key])); + + return keys.some((key) => !NON_INFECT_OPTION_KEYS.has(key)); +}; + export function majorFieldKeysChanged(oldField: IFieldVo, fieldRo: IConvertFieldRo) { const keys = FIELD_RO_PROPERTIES.filter((key) => !isEqual(fieldRo[key], oldField[key])); // filter property @@ -11,16 +29,12 @@ export function majorFieldKeysChanged(oldField: IFieldVo, fieldRo: IConvertField return false; } - // only formatting or showAs changed + // only non infect options changed if (majorKeys.length === 1 && majorKeys[0] === 'options') { const oldOptions = (oldField.options as Record) || {}; const newOptions = (fieldRo.options as Record) || {}; - const keys = Object.keys(newOptions).filter( - (key) => !isEqual(oldOptions[key], newOptions[key]) - ); - - return keys.some((key) => !['formatting', 'showAs'].includes(key)); + return majorOptionsKeyChanged(oldOptions, newOptions); } return true; diff --git a/apps/nestjs-backend/test/share.e2e-spec.ts b/apps/nestjs-backend/test/share.e2e-spec.ts index 62a5d7bd40..5cb7d1f07d 100644 --- a/apps/nestjs-backend/test/share.e2e-spec.ts +++ b/apps/nestjs-backend/test/share.e2e-spec.ts @@ -1,6 +1,20 @@ import { type INestApplication } from '@nestjs/common'; -import type { IFieldRo, IRecord, IUserFieldOptions, IViewRo } from '@teable/core'; -import { ANONYMOUS_USER_ID, FieldKeyType, FieldType, Relationship, ViewType } from '@teable/core'; +import type { + IFieldRo, + IFilterRo, + ILinkFieldOptions, + IRecord, + IUserFieldOptions, + IViewRo, +} from '@teable/core'; +import { + ANONYMOUS_USER_ID, + FieldKeyType, + FieldType, + is, + Relationship, + ViewType, +} from '@teable/core'; import { urlBuilder, SHARE_VIEW_GET, @@ -19,9 +33,11 @@ import { createField, updateViewShareMeta, shareViewFormSubmit, + deleteView, } from '@teable/openapi'; import type { ITableFullVo, ShareViewAuthVo, ShareViewGetVo } from '@teable/openapi'; import { map } from 'lodash'; +import { x_20 } from './data-helpers/20x'; import { createAnonymousUserAxios } from './utils/axios-instance/anonymous-user'; import { createNewUserAxios } from './utils/axios-instance/new-user'; import { getError } from './utils/get-error'; @@ -31,6 +47,10 @@ import { permanentDeleteTable, initApp, updateViewColumnMeta, + updateViewFilter, + getField, + deleteField, + convertField, } from './utils/init-app'; const formViewRo: IViewRo = { @@ -540,4 +560,241 @@ describe('OpenAPI ShareController (e2e)', () => { ).rejects.toThrow(); }); }); + + describe('link view limit', () => { + let table1: ITableFullVo; + let table2: ITableFullVo; + + beforeEach(async () => { + table1 = await createTable(baseId, { name: 'table1' }); + table2 = await createTable(baseId, { + name: 'table2', + fields: x_20.fields, + records: x_20.records, + }); + }); + + afterEach(async () => { + await permanentDeleteTable(baseId, table1.id); + await permanentDeleteTable(baseId, table2.id); + }); + + it('should get link view limit by view', async () => { + const filterByViewId = table2.defaultViewId; + const singleSelectField = table2.fields[2]; + const filter: IFilterRo = { + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: singleSelectField.id, + operator: is.value, + value: 'x', + }, + ], + }, + }; + + await updateViewFilter(table2.id, table2.defaultViewId!, filter); + + const linkField = await createField(table1.id, { + name: 'link field limit by view', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: table2.id, + filterByViewId, + }, + }); + const shareResult = await getShareView(linkField.data.id); + + expect(shareResult.data.records.length).toEqual(7); + }); + + it('should get link view limit by filter', async () => { + const singleSelectField = table2.fields[2]; + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: singleSelectField.id, + operator: is.value, + value: 'x', + }, + ], + }; + const linkField = await createField(table1.id, { + name: 'link field limit by filter', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: table2.id, + filter, + }, + }); + const shareResult = await getShareView(linkField.data.id); + + expect(shareResult.data.records.length).toEqual(7); + }); + + it('should get link view limit by hidden fields', async () => { + const fields = table2.fields; + const hiddenFieldIds = fields.slice(1, fields.length).map((field) => field.id); + const linkField = await createField(table1.id, { + name: 'link field limit by hidden fields', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: table2.id, + hiddenFieldIds, + }, + }); + const shareResult = await getShareView(linkField.data.id); + + expect(shareResult.data.fields.length).toEqual(2); + }); + + it('should get link view limited by multiple conditions', async () => { + const filterByViewId = table2.defaultViewId; + const textField = table2.fields[0]; + const singleSelectField = table2.fields[2]; + const filter: IFilterRo = { + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: singleSelectField.id, + operator: is.value, + value: 'x', + }, + ], + }, + }; + + await updateViewFilter(table2.id, table2.defaultViewId!, filter); + + const fields = table2.fields; + const hiddenFieldIds = fields.slice(2, fields.length).map((field) => field.id); + + const additionalFilter = { + conjunction: 'and', + filterSet: [ + { + fieldId: textField.id, + operator: is.value, + value: '6', + }, + ], + }; + + const linkField = await createField(table1.id, { + name: 'link field with multiple limits', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: table2.id, + filterByViewId, + filter: additionalFilter, + hiddenFieldIds, + }, + }); + const shareResult = await getShareView(linkField.data.id); + + expect(shareResult.data.records.length).toBeLessThanOrEqual(1); + expect(shareResult.data.fields.length).toEqual(3); + }); + + it('should clean link options after filterByViewId is deleted', async () => { + const view = await createView(table2.id, { + name: 'view', + type: ViewType.Grid, + }); + + const linkField = await createField(table1.id, { + name: 'clean link options filterByViewId', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: table2.id, + filterByViewId: view.id, + }, + }); + + expect((linkField.data.options as ILinkFieldOptions).filterByViewId).toEqual(view.id); + + await deleteView(table2.id, view.id); + const currentLinkField = await getField(table1.id, linkField.data.id); + + expect((currentLinkField.options as ILinkFieldOptions).filterByViewId).toBeNull(); + }); + + it('should clean link options after filtering field is deleted', async () => { + const singleSelectField = table2.fields[2]; + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: singleSelectField.id, + operator: is.value, + value: 'x', + }, + ], + }; + + const linkField = await createField(table1.id, { + name: 'clean link options filter', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: table2.id, + filter, + hiddenFieldIds: [singleSelectField.id], + }, + }); + + expect((linkField.data.options as ILinkFieldOptions).filter).toEqual(filter); + expect((linkField.data.options as ILinkFieldOptions).hiddenFieldIds).toEqual([ + singleSelectField.id, + ]); + + await deleteField(table2.id, singleSelectField.id); + const currentLinkField = await getField(table1.id, linkField.data.id); + + expect((currentLinkField.options as ILinkFieldOptions).filter).toBeNull(); + expect((currentLinkField.options as ILinkFieldOptions).hiddenFieldIds).toBeNull(); + }); + + it('should clean link options after filtering field is converted', async () => { + const singleSelectField = table2.fields[2]; + const filter = { + conjunction: 'and', + filterSet: [ + { + fieldId: singleSelectField.id, + operator: is.value, + value: 'x', + }, + ], + }; + + const linkField = await createField(table1.id, { + name: 'convert link options filter', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: table2.id, + filter, + }, + }); + + expect((linkField.data.options as ILinkFieldOptions).filter).toEqual(filter); + + await convertField(table2.id, singleSelectField.id, { + type: FieldType.MultipleSelect, + }); + const currentLinkField = await getField(table1.id, linkField.data.id); + + expect((currentLinkField.options as ILinkFieldOptions).filter).toBeNull(); + }); + }); }); diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx index 8ad22dae20..34e508e744 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx @@ -47,6 +47,7 @@ import { useGridPrefillingRow, SelectableType, useGridRowOrder, + ExpandRecorder, } from '@teable/sdk'; import { GRID_DEFAULT } from '@teable/sdk/components/grid/configs'; import { useScrollFrameRate } from '@teable/sdk/components/grid/hooks'; @@ -67,7 +68,7 @@ import { useToast } from '@teable/ui-lib'; import { isEqual, keyBy, uniqueId, groupBy } from 'lodash'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { usePrevious, useClickAway } from 'react-use'; import { ExpandRecordContainer } from '@/features/app/components/ExpandRecordContainer'; @@ -125,6 +126,7 @@ export const GridViewBaseInner: React.FC = ( const realRowCount = rowCount ?? ssrRecords?.length ?? 0; const fieldEditable = useFieldCellEditable(); const { undo, redo } = useUndoRedo(); + const [expandRecord, setExpandRecord] = useState<{ tableId: string; recordId: string }>(); const groupCollection = useGridGroupCollection(); @@ -214,7 +216,9 @@ export const GridViewBaseInner: React.FC = ( if (record !== undefined) { const fieldId = columns[colIndex]?.id; if (!fieldId) return { type: CellType.Loading }; - return cellValue2GridDisplay(record, colIndex); + return cellValue2GridDisplay(record, colIndex, false, (tableId, recordId) => + setExpandRecord({ tableId, recordId }) + ); } return { type: CellType.Loading }; }, @@ -758,6 +762,14 @@ export const GridViewBaseInner: React.FC = ( {!onRowExpand && } + {expandRecord != null && ( + setExpandRecord(undefined)} + /> + )} ); }; diff --git a/apps/nextjs-app/src/features/app/components/field-setting/FieldOptions.tsx b/apps/nextjs-app/src/features/app/components/field-setting/FieldOptions.tsx index e5dfa835f4..547819b782 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/FieldOptions.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/FieldOptions.tsx @@ -35,7 +35,7 @@ export interface IFieldOptionsProps { } export const FieldOptions: React.FC = ({ field, onChange }) => { - const { type, isLookup, cellValueType, isMultipleCellValue, options } = field; + const { id, type, isLookup, cellValueType, isMultipleCellValue, options } = field; switch (type) { case FieldType.SingleLineText: return ( @@ -75,6 +75,7 @@ export const FieldOptions: React.FC = ({ field, onChange }) case FieldType.Link: return ( { return ( <> - +
{/* Header */}
{title}
diff --git a/apps/nextjs-app/src/features/app/components/field-setting/options/LinkOptions/LinkOptions.tsx b/apps/nextjs-app/src/features/app/components/field-setting/options/LinkOptions/LinkOptions.tsx index 9f33a00fd5..e40b3a494d 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/options/LinkOptions/LinkOptions.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/options/LinkOptions/LinkOptions.tsx @@ -5,22 +5,31 @@ import { ArrowUpRight } from '@teable/icons'; import { getTablePermission } from '@teable/openapi'; import { ReactQueryKeys } from '@teable/sdk/config'; import { useBaseId, useTableId } from '@teable/sdk/hooks'; -import { Label, Switch } from '@teable/ui-lib/shadcn'; +import { Button, Label, Switch } from '@teable/ui-lib/shadcn'; import Link from 'next/link'; import { Trans, useTranslation } from 'next-i18next'; +import { Fragment, useState } from 'react'; import { tableConfig } from '@/features/i18n/table.config'; +import { MoreLinkOptions } from './MoreLinkOptions'; import { SelectTable } from './SelectTable'; export const LinkOptions = (props: { options: Partial | undefined; + fieldId?: string; isLookup?: boolean; onChange?: (options: Partial) => void; }) => { - const { options, isLookup, onChange } = props; + const { fieldId, options, isLookup, onChange } = props; const tableId = useTableId(); const selfBaseId = useBaseId() as string; const { t } = useTranslation(tableConfig.i18nNamespaces); + const isMoreVisible = Boolean( + options?.filterByViewId || options?.filter || options?.hiddenFieldIds + ); + + const [moreVisible, setMoreVisible] = useState(isMoreVisible); + const relationship = options?.relationship ?? Relationship.ManyOne; const foreignTableId = options?.foreignTableId; const isOneWay = options?.isOneWay; @@ -81,9 +90,43 @@ export const LinkOptions = (props: { baseId={options?.baseId} tableId={options?.foreignTableId} onChange={(baseId, tableId) => { - onChange?.({ baseId, foreignTableId: tableId, relationship, isOneWay }); + onChange?.({ + baseId, + foreignTableId: tableId, + relationship, + isOneWay, + filterByViewId: null, + hiddenFieldIds: null, + filter: null, + }); }} /> + {options?.foreignTableId && ( + +
+ +
+ {moreVisible && ( + ) => { + onChange?.({ ...options, ...partialOptions }); + }} + /> + )} +
+ )} {foreignTableId && ( <>
diff --git a/apps/nextjs-app/src/features/app/components/field-setting/options/LinkOptions/MoreLinkOptions.tsx b/apps/nextjs-app/src/features/app/components/field-setting/options/LinkOptions/MoreLinkOptions.tsx new file mode 100644 index 0000000000..4838a38df5 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/field-setting/options/LinkOptions/MoreLinkOptions.tsx @@ -0,0 +1,132 @@ +import { useQuery } from '@tanstack/react-query'; +import type { IFilter, ILinkFieldOptionsRo } from '@teable/core'; +import { EyeOff, Maximize2 } from '@teable/icons'; +import { getFields } from '@teable/openapi'; +import { + FilterWithTable, + HideFieldsBase, + useFieldFilterLinkContext, + ViewSelect, +} from '@teable/sdk/components'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useTableId } from '@teable/sdk/hooks'; +import type { IFieldInstance } from '@teable/sdk/model'; +import { Button, cn, Dialog, DialogContent, DialogTrigger } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useMemo } from 'react'; +import { tableConfig } from '@/features/i18n/table.config'; + +interface IMoreOptionsProps { + foreignTableId?: string; + fieldId?: string; + filter?: IFilter | null; + filterByViewId?: string | null; + hiddenFieldIds?: string[] | null; + onChange?: (options: Partial) => void; +} + +export const MoreLinkOptions = (props: IMoreOptionsProps) => { + const { + foreignTableId = '', + fieldId, + filterByViewId, + hiddenFieldIds: _hiddenFieldIds, + filter, + onChange, + } = props; + + const { t } = useTranslation(tableConfig.i18nNamespaces); + const currentTableId = useTableId() as string; + const hiddenFieldIds = _hiddenFieldIds ?? []; + + const query = useMemo(() => { + return { + viewId: filterByViewId ?? undefined, + }; + }, [filterByViewId]); + + const { data: totalFields = [] } = useQuery({ + queryKey: ReactQueryKeys.fieldList(foreignTableId), + queryFn: () => getFields(foreignTableId).then((res) => res.data), + enabled: !!foreignTableId, + }); + + const { data: withViewFields } = useQuery({ + queryKey: ReactQueryKeys.fieldList(foreignTableId, query), + queryFn: () => getFields(foreignTableId, query).then((res) => res.data), + enabled: !!foreignTableId && !!filterByViewId, + }); + + const context = useFieldFilterLinkContext(currentTableId, fieldId, !fieldId); + + if (!foreignTableId || !totalFields.length) { + return null; + } + + const hiddenCount = hiddenFieldIds.length; + const text = hiddenCount + ? t('sdk:hidden.configLabel_other', { count: hiddenCount }) + : t('sdk:hidden.label'); + + const onHiddenChange = (hiddenFieldIds: string[]) => + onChange?.({ hiddenFieldIds: hiddenFieldIds.length ? hiddenFieldIds : null }); + + return ( +
+
+ {t('table:field.editor.filterByView')} + onChange?.({ filterByViewId: viewId })} + cancelable + /> +
+
+
+ {t('table:field.editor.filter')} + + + + + + onChange?.({ filter: value })} + /> + + +
+ + onChange?.({ filter: value })} + /> +
+
+ {t('table:field.editor.hideFields')} + +
+
+ ); +}; diff --git a/packages/common-i18n/src/locales/en/sdk.json b/packages/common-i18n/src/locales/en/sdk.json index 2802c1ecbf..0813ac3417 100644 --- a/packages/common-i18n/src/locales/en/sdk.json +++ b/packages/common-i18n/src/locales/en/sdk.json @@ -60,7 +60,8 @@ "unselected": "Unselected", "selected": "Selected", "expandRecordError": "No permission to view this record.", - "alreadyOpen": "This record is already open." + "alreadyOpen": "This record is already open.", + "goToForeignTable": "Go to foreign table" }, "user": { "searchPlaceholder": "Find users by name", @@ -183,7 +184,9 @@ "before": "Before", "after": "After", "viewRecord": "View record" - } + }, + "showHiddenFields": "Show {{count}} hidden fields", + "hideHiddenFields": "Hide {{count}} hidden fields" }, "sort": { "label": "Sort", diff --git a/packages/common-i18n/src/locales/en/table.json b/packages/common-i18n/src/locales/en/table.json index 3be9f6deb1..93c312beee 100644 --- a/packages/common-i18n/src/locales/en/table.json +++ b/packages/common-i18n/src/locales/en/table.json @@ -232,7 +232,11 @@ "enableValidateFieldUnique": "Enable unique value validation", "enableValidateFieldNotNull": "Enable not null value validation", "knowMore": "know more", - "linkFieldKnowMoreLink": "https://help.teable.io/product/fields/field-type/link" + "linkFieldKnowMoreLink": "https://help.teable.io/product/fields/field-type/link", + "filterByView": "Filter records by view", + "filter": "Filter records", + "hideFields": "Hide fields", + "moreOptions": "More options" }, "subTitle": { "link": "Link to records in the table you choose", diff --git a/packages/common-i18n/src/locales/fr/sdk.json b/packages/common-i18n/src/locales/fr/sdk.json index 1c29e0c7cb..e371b4e754 100644 --- a/packages/common-i18n/src/locales/fr/sdk.json +++ b/packages/common-i18n/src/locales/fr/sdk.json @@ -46,7 +46,8 @@ "selectRecord": "Sélectionner un enregistrement", "unselected": "Non sélectionné", "selected": "Sélectionné", - "expandRecordError": "Aucun droit pour voir cet enregistrement." + "expandRecordError": "Aucun droit pour voir cet enregistrement.", + "goToForeignTable": "Accéder à la table liée" }, "user": { "searchPlaceholder": "Trouver des utilisateurs par nom", @@ -163,7 +164,9 @@ "before": "Avant", "after": "Après", "viewRecord": "Voir l'enregistrement" - } + }, + "showHiddenFields": "Afficher {{count}} champs cachés", + "hideHiddenFields": "Masquer {{count}} champs cachés" }, "sort": { "label": "Trier", diff --git a/packages/common-i18n/src/locales/fr/table.json b/packages/common-i18n/src/locales/fr/table.json index f8e555255a..f511626d4f 100644 --- a/packages/common-i18n/src/locales/fr/table.json +++ b/packages/common-i18n/src/locales/fr/table.json @@ -223,7 +223,11 @@ "enableValidateFieldUnique": "Activer la validation des valeurs uniques", "enableValidateFieldNotNull": "Activer la validation des valeurs non nulles", "knowMore": "en savoir plus", - "linkFieldKnowMoreLink": "https://help.teable.io/product/fields/field-type/link" + "linkFieldKnowMoreLink": "https://help.teable.io/product/fields/field-type/link", + "filterByView": "Filtrer les enregistrements par vue", + "filter": "Filtrer les enregistrements", + "hideFields": "Masquer les champs", + "moreOptions": "Plus d'options" }, "subTitle": { "link": "Lien vers les enregistrements dans la table que vous choisissez", diff --git a/packages/common-i18n/src/locales/ja/sdk.json b/packages/common-i18n/src/locales/ja/sdk.json index d37b848391..95cc3a3429 100644 --- a/packages/common-i18n/src/locales/ja/sdk.json +++ b/packages/common-i18n/src/locales/ja/sdk.json @@ -46,7 +46,8 @@ "selectRecord": "レコードを選択", "unselected": "未選択", "selected": "選択済", - "expandRecordError": "このレコードを表示する権限がありません。" + "expandRecordError": "このレコードを表示する権限がありません。", + "goToForeignTable": "関連テーブルに移動" }, "user": { "searchPlaceholder": "名前でユーザーを検索する", @@ -166,7 +167,9 @@ "before": "以前", "after": "以後", "viewRecord": "レコードを表示" - } + }, + "showHiddenFields": "{{count}} 個の隠しフィールドを表示", + "hideHiddenFields": "{{count}} 個の隠しフィールドを非表示" }, "sort": { "label": "ソート", diff --git a/packages/common-i18n/src/locales/ja/table.json b/packages/common-i18n/src/locales/ja/table.json index 669d22df56..1d1c3bbf4d 100644 --- a/packages/common-i18n/src/locales/ja/table.json +++ b/packages/common-i18n/src/locales/ja/table.json @@ -223,7 +223,11 @@ "enableValidateFieldUnique": "固有の値の検証を有効にする", "enableValidateFieldNotNull": "NULLでない値の検証を有効にする", "knowMore": "さらに詳しく", - "linkFieldKnowMoreLink": "https://help.teable.io/product/fields/field-type/link" + "linkFieldKnowMoreLink": "https://help.teable.io/product/fields/field-type/link", + "filterByView": "ビューでレコードをフィルターする", + "filter": "レコードをフィルターする", + "hideFields": "フィールドを非表示にする", + "moreOptions": "オプションを表示" }, "subTitle": { "link": "選択したテーブル内のレコードへのリンク", diff --git a/packages/common-i18n/src/locales/ru/sdk.json b/packages/common-i18n/src/locales/ru/sdk.json index b32caf1239..dfdf134483 100644 --- a/packages/common-i18n/src/locales/ru/sdk.json +++ b/packages/common-i18n/src/locales/ru/sdk.json @@ -58,7 +58,8 @@ "selectRecord": "Выбрать запись", "unselected": "Не выбрано", "selected": "Выбрано", - "expandRecordError": "Нет доступа для просмотра этой записи." + "expandRecordError": "Нет доступа для просмотра этой записи.", + "goToForeignTable": "Перейти к связанной таблице" }, "user": { "searchPlaceholder": "Найдите пользователей по имени", @@ -181,7 +182,9 @@ "before": "До", "after": "После", "viewRecord": "Просмотреть запись" - } + }, + "showHiddenFields": "Показать {{count}} скрытых поля", + "hideHiddenFields": "Скрыть {{count}} скрытых поля" }, "sort": { "label": "Сортировка", diff --git a/packages/common-i18n/src/locales/ru/table.json b/packages/common-i18n/src/locales/ru/table.json index 9a9deeb6ea..7bcc38e6a7 100644 --- a/packages/common-i18n/src/locales/ru/table.json +++ b/packages/common-i18n/src/locales/ru/table.json @@ -223,7 +223,11 @@ "enableValidateFieldUnique": "Включить проверку на уникальность значения", "enableValidateFieldNotNull": "Включить проверку на ненулевое значение", "knowMore": "узнать больше", - "linkFieldKnowMoreLink": "https://help.teable.io/product/fields/field-type/link" + "linkFieldKnowMoreLink": "https://help.teable.io/product/fields/field-type/link", + "filterByView": "Фильтровать записи по представлению", + "filter": "Фильтровать записи", + "hideFields": "Скрыть поля", + "moreOptions": "Дополнительные параметры" }, "subTitle": { "link": "Связать с записями в выбранной таблице", diff --git a/packages/common-i18n/src/locales/zh/sdk.json b/packages/common-i18n/src/locales/zh/sdk.json index d5d70fd2a7..7e4ae0caa0 100644 --- a/packages/common-i18n/src/locales/zh/sdk.json +++ b/packages/common-i18n/src/locales/zh/sdk.json @@ -72,7 +72,8 @@ "unselected": "未选择", "selected": "已选择", "expandRecordError": "无权查看此记录", - "alreadyOpen": "此记录已被打开" + "alreadyOpen": "此记录已被打开", + "goToForeignTable": "跳转至关联表" }, "user": { "searchPlaceholder": "通过姓名查找用户", @@ -196,7 +197,9 @@ "before": "变更前", "after": "变更后", "viewRecord": "查看记录" - } + }, + "showHiddenFields": "显示 {{count}} 个隐藏字段", + "hideHiddenFields": "隐藏 {{count}} 个隐藏字段" }, "sort": { "label": "排序", diff --git a/packages/common-i18n/src/locales/zh/table.json b/packages/common-i18n/src/locales/zh/table.json index 785606534f..094df8cfb4 100644 --- a/packages/common-i18n/src/locales/zh/table.json +++ b/packages/common-i18n/src/locales/zh/table.json @@ -231,7 +231,11 @@ "enableValidateFieldUnique": "唯一值校验", "enableValidateFieldNotNull": "非空值校验", "knowMore": "了解更多", - "linkFieldKnowMoreLink": "https://help.teable.cn/ji-ben-gong-neng/zi-duan/gao-ji-zi-duan/guan-lian" + "linkFieldKnowMoreLink": "https://help.teable.cn/ji-ben-gong-neng/zi-duan/gao-ji-zi-duan/guan-lian", + "filterByView": "从视图筛选记录", + "filter": "筛选记录", + "hideFields": "隐藏字段", + "moreOptions": "更多配置" }, "subTitle": { "link": "在表之间创建关联关系", diff --git a/packages/core/src/models/field/derivate/link.field.spec.ts b/packages/core/src/models/field/derivate/link.field.spec.ts index 12f895afbc..c0dc5ea421 100644 --- a/packages/core/src/models/field/derivate/link.field.spec.ts +++ b/packages/core/src/models/field/derivate/link.field.spec.ts @@ -24,6 +24,12 @@ describe('LinkFieldCore', () => { selfKeyName: '__id', foreignKeyName: '__fk_fldxxxxxxx', symmetricFieldId: 'fldxxxxxxx', + filterByViewId: 'viwxxxxxxx', + hiddenFieldIds: ['fldxxxxxxx'], + filter: { + conjunction: 'and', + filterSet: [], + }, }, type: FieldType.Link, dbFieldType: DbFieldType.Json, @@ -142,11 +148,23 @@ describe('LinkFieldCore', () => { lookupFieldId: 'fldXWPHcgSGeKgFFuOI', dbForeignKeyName: '__fk_fldiBBKwOZuW8rlrtoW', symmetricFieldId: 'fld8bh5u0MkjdmtFCxv', + filterByViewId: 'viwXWPHcgSGeKgFFuOI', + hiddenFieldIds: ['fldXWPHcgSGeKgFFuOI'], + filter: { + conjunction: 'and', + filterSet: [], + }, }; expect(linkFieldOptionsRoSchema.safeParse(object).success).toBeTruthy(); expect(linkFieldOptionsRoSchema.parse(object)).toEqual({ relationship: 'manyOne', foreignTableId: 'tblERSkHpp4KDRK1hvL', + filterByViewId: 'viwXWPHcgSGeKgFFuOI', + hiddenFieldIds: ['fldXWPHcgSGeKgFFuOI'], + filter: { + conjunction: 'and', + filterSet: [], + }, }); }); }); diff --git a/packages/core/src/models/field/derivate/link.field.ts b/packages/core/src/models/field/derivate/link.field.ts index ccfd05ca0a..0a2bf3b9a3 100644 --- a/packages/core/src/models/field/derivate/link.field.ts +++ b/packages/core/src/models/field/derivate/link.field.ts @@ -1,5 +1,6 @@ import { IdPrefix } from '../../../utils'; import { z } from '../../../zod'; +import { filterSchema } from '../../view/filter'; import type { FieldType, CellValueType } from '../constant'; import { Relationship } from '../constant'; import { FieldCore } from '../field'; @@ -36,6 +37,13 @@ export const linkFieldOptionsSchema = z symmetricFieldId: z.string().optional().openapi({ description: 'the symmetric field in the foreign table, empty if the field is a one-way link', }), + filterByViewId: z.string().nullable().optional().openapi({ + description: 'the view id that limits the number of records in the link field', + }), + hiddenFieldIds: z.array(z.string()).nullable().optional().openapi({ + description: 'the fields that will be hidden in the link field', + }), + filter: filterSchema.optional(), }) .strip(); @@ -46,6 +54,9 @@ export const linkFieldOptionsRoSchema = linkFieldOptionsSchema.pick({ relationship: true, foreignTableId: true, isOneWay: true, + filterByViewId: true, + hiddenFieldIds: true, + filter: true, }); export type ILinkFieldOptionsRo = z.infer; diff --git a/packages/core/src/models/view/filter/operator.ts b/packages/core/src/models/view/filter/operator.ts index ebef38851c..4814d133c6 100644 --- a/packages/core/src/models/view/filter/operator.ts +++ b/packages/core/src/models/view/filter/operator.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { pick, pullAll, uniq } from 'lodash'; import { z } from 'zod'; -import type { FieldCore } from '../../field'; -import { CellValueType, FieldType } from '../../field'; +import { CellValueType, FieldType } from '../../field/constant'; +import type { FieldCore } from '../../field/field'; export const is = z.literal('is'); export const isNot = z.literal('isNot'); diff --git a/packages/openapi/src/field/filter-link-records.ts b/packages/openapi/src/field/filter-link-records.ts new file mode 100644 index 0000000000..3245673f67 --- /dev/null +++ b/packages/openapi/src/field/filter-link-records.ts @@ -0,0 +1,40 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { getViewFilterLinkRecordsVoSchema } from '../view/filter-link-records'; +import { z } from '../zod'; + +export const GET_FIELD_FILTER_LINK_RECORDS = '/table/{tableId}/field/{fieldId}/filter-link-records'; + +export const getFieldFilterLinkRecordsVoSchema = getViewFilterLinkRecordsVoSchema; + +export type IGetFieldFilterLinkRecordsVo = z.infer; + +export const GetFieldFilterLinkRecordsRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_FIELD_FILTER_LINK_RECORDS, + description: 'Getting associated records for a view filter configuration.', + request: { + params: z.object({ + tableId: z.string(), + viewId: z.string(), + }), + }, + responses: { + 200: { + description: 'Returns the link field to filter the configured records.', + content: { + 'application/json': { + schema: getFieldFilterLinkRecordsVoSchema, + }, + }, + }, + }, + tags: ['field'], +}); + +export const getFieldFilterLinkRecords = async (tableId: string, fieldId: string) => { + return axios.get( + urlBuilder(GET_FIELD_FILTER_LINK_RECORDS, { tableId, fieldId }) + ); +}; diff --git a/packages/openapi/src/field/index.ts b/packages/openapi/src/field/index.ts index 112da5c1f4..24b6caa988 100644 --- a/packages/openapi/src/field/index.ts +++ b/packages/openapi/src/field/index.ts @@ -5,3 +5,4 @@ export * from './get'; export * from './update'; export * from './convert'; export * from './delete-list'; +export * from './filter-link-records'; diff --git a/packages/sdk/src/components/editor/link/EditorMain.tsx b/packages/sdk/src/components/editor/link/EditorMain.tsx index 29bac1c57c..510613836f 100644 --- a/packages/sdk/src/components/editor/link/EditorMain.tsx +++ b/packages/sdk/src/components/editor/link/EditorMain.tsx @@ -1,6 +1,6 @@ import type { ILinkCellValue, ILinkFieldOptions } from '@teable/core'; import { isMultiValueLink } from '@teable/core'; -import { Plus } from '@teable/icons'; +import { ArrowUpRight, Plus } from '@teable/icons'; import type { IGetRecordsRo } from '@teable/openapi'; import { Button, Tabs, TabsList, TabsTrigger } from '@teable/ui-lib'; import { @@ -16,7 +16,7 @@ import type { ForwardRefRenderFunction } from 'react'; import { RowCountProvider, LinkViewProvider } from '../../../context'; import { useTranslation } from '../../../context/app/i18n'; import { LinkFilterProvider } from '../../../context/query/LinkFilterProvider'; -import { useLinkFilter, useRowCount, useSearch } from '../../../hooks'; +import { useBaseId, useLinkFilter, useRowCount, useSearch } from '../../../hooks'; import { CreateRecordModal } from '../../create-record'; import { SearchInput } from '../../search'; import { LinkListType } from './interface'; @@ -45,6 +45,8 @@ const LinkEditorInnerBase: ForwardRefRenderFunction ({ onReset, @@ -57,7 +59,7 @@ const LinkEditorInnerBase: ForwardRefRenderFunction(LinkListType.Unselected); const isMultiple = isMultiValueLink(options.relationship); - const rowCount = useRowCount() || 0; + const { foreignTableId, filterByViewId } = options; const { filterLinkCellSelected, @@ -109,9 +111,34 @@ const LinkEditorInnerBase: ForwardRefRenderFunction { + if (!baseId) return; + + let path = `/base/${baseId}/${foreignTableId}`; + + if (filterByViewId) { + path += `/${filterByViewId}`; + } + + const url = new URL(path, window.location.origin); + + window.open(url.toString(), '_blank'); + }; + return ( <> -
{t('editor.link.placeholder')}
+
+ {t('editor.link.placeholder')} + +
@@ -172,8 +199,12 @@ const LinkEditorMainBase: ForwardRefRenderFunction { + const { options } = props; + const { baseId: foreignBaseId } = options; + const baseId = useBaseId(); + return ( - + diff --git a/packages/sdk/src/components/editor/link/LinkList.tsx b/packages/sdk/src/components/editor/link/LinkList.tsx index 427f614eb2..849b3cdb68 100644 --- a/packages/sdk/src/components/editor/link/LinkList.tsx +++ b/packages/sdk/src/components/editor/link/LinkList.tsx @@ -30,6 +30,7 @@ import { LinkListType } from './interface'; interface ILinkListProps { type: LinkListType; rowCount: number; + hiddenFieldIds?: string[]; readonly?: boolean; isMultiple?: boolean; recordQuery?: IGetRecordsRo; @@ -49,8 +50,17 @@ const LinkListBase: ForwardRefRenderFunction = ( props, forwardRef ) => { - const { readonly, type, rowCount, cellValue, recordQuery, isMultiple, onChange, onExpand } = - props; + const { + readonly, + type, + rowCount, + cellValue, + recordQuery, + isMultiple, + hiddenFieldIds, + onChange, + onExpand, + } = props; useImperativeHandle(forwardRef, () => ({ onReset, @@ -66,7 +76,7 @@ const LinkListBase: ForwardRefRenderFunction = ( const theme = useGridTheme(); const customIcons = useGridIcons(); const { openTooltip, closeTooltip } = useGridTooltipStore(); - const { columns, cellValue2GridDisplay } = useGridColumns(false); + const { columns, cellValue2GridDisplay } = useGridColumns(false, hiddenFieldIds); const gridRef = useRef(null); const rowCountRef = useRef(rowCount); diff --git a/packages/sdk/src/components/expand-record/RecordEditor.tsx b/packages/sdk/src/components/expand-record/RecordEditor.tsx index c4ee9cd78c..e77373c3c9 100644 --- a/packages/sdk/src/components/expand-record/RecordEditor.tsx +++ b/packages/sdk/src/components/expand-record/RecordEditor.tsx @@ -1,6 +1,8 @@ import { Button } from '@teable/ui-lib'; import { useRef } from 'react'; -import { useMeasure, useToggle } from 'react-use'; +import { useLocalStorage, useMeasure } from 'react-use'; +import { LocalStorageKeys } from '../../config'; +import { useTranslation } from '../../context/app/i18n'; import type { IFieldInstance, Record } from '../../model'; import { RecordEditorItem } from './RecordEditorItem'; @@ -14,11 +16,15 @@ export const RecordEditor = (props: { onChange?: (newValue: unknown, fieldId: string) => void; readonly?: boolean | ((field: IFieldInstance) => boolean); }) => { + const { t } = useTranslation(); const [ref, { width }] = useMeasure(); const wrapRef = useRef(null); const { fields, hiddenFields = [], record, onChange, readonly } = props; const vertical = width > EDITOR_VERTICAL_MIN; - const [showHiddenFields, toggle] = useToggle(false); + const [hiddenFieldsVisible, setHiddenFieldsVisible] = useLocalStorage( + LocalStorageKeys.ExpandRecordHiddenFieldsVisible, + false + ); return (
@@ -36,13 +42,19 @@ export const RecordEditor = (props: { {hiddenFields.length !== 0 && (
-
)} - {showHiddenFields && + {hiddenFieldsVisible && hiddenFields?.map((field) => ( (props: IBaseFilterP component: props.components, }} > -
- {children.map((condition, index) => ( - - ))} -
+ {children.length > 0 && ( +
+ {children.map((condition, index) => ( + + ))} +
+ )} + {footer} ); diff --git a/packages/sdk/src/components/filter/filter-with-table/FilterWithTable.tsx b/packages/sdk/src/components/filter/filter-with-table/FilterWithTable.tsx new file mode 100644 index 0000000000..a734ccd89c --- /dev/null +++ b/packages/sdk/src/components/filter/filter-with-table/FilterWithTable.tsx @@ -0,0 +1,63 @@ +import type { IFilter } from '@teable/core'; +import { FieldType } from '@teable/core'; +import type { ComponentProps } from 'react'; +import type { IFieldInstance } from '../../../model'; +import type { IViewFilterLinkContext } from '../view-filter'; +import { BaseViewFilter, FieldValue } from '../view-filter'; +import { FilterLinkBase, FilterLinkSelect, StandDefaultList } from '../view-filter/component'; +import { FilterLinkContext } from '../view-filter/component/filter-link/context'; +import type { IFilterLinkProps } from '../view-filter/component/filter-link/types'; + +interface IFilterWithTableProps { + value: IFilter | null; + fields: IFieldInstance[]; + context: IViewFilterLinkContext; + onChange: (value: IFilter | null) => void; +} + +type ICustomerValueComponentProps = ComponentProps; + +const CustomValueComponent = (props: ICustomerValueComponentProps) => { + const components = { + [FieldType.Link]: FilterLink, + }; + return ; +}; + +const FilterLinkSelectCom = (props: IFilterLinkProps) => { + return ( + + ); +}; + +const FilterLink = (props: IFilterLinkProps) => { + return ( + + + + ); +}; + +export const FilterWithTable = (props: IFilterWithTableProps) => { + const { fields, value, context, onChange } = props; + + return ( + + ); +}; diff --git a/packages/sdk/src/components/filter/filter-with-table/index.ts b/packages/sdk/src/components/filter/filter-with-table/index.ts new file mode 100644 index 0000000000..fbf8c892fa --- /dev/null +++ b/packages/sdk/src/components/filter/filter-with-table/index.ts @@ -0,0 +1 @@ +export * from './FilterWithTable'; diff --git a/packages/sdk/src/components/filter/index.ts b/packages/sdk/src/components/filter/index.ts index 9e3f9b07d3..679ab6e729 100644 --- a/packages/sdk/src/components/filter/index.ts +++ b/packages/sdk/src/components/filter/index.ts @@ -1,3 +1,4 @@ export * from './BaseFilter'; export * from './view-filter'; +export * from './filter-with-table'; export * from './types'; diff --git a/packages/sdk/src/components/filter/view-filter/component/filter-link/StandDefaultList.tsx b/packages/sdk/src/components/filter/view-filter/component/filter-link/StandDefaultList.tsx new file mode 100644 index 0000000000..f73e358fca --- /dev/null +++ b/packages/sdk/src/components/filter/view-filter/component/filter-link/StandDefaultList.tsx @@ -0,0 +1,32 @@ +import { StandaloneViewProvider } from '../../../../../context'; +import { useTranslation } from '../../../../../context/app/i18n'; +import { useBaseId } from '../../../../../hooks/use-base-id'; +import { SocketRecordList } from '../../../../record-list'; +import { StorageLinkSelected } from './storage'; +import type { IFilterLinkSelectListProps } from './types'; + +export const StandDefaultList = (props: IFilterLinkSelectListProps) => { + const { field, value, onClick } = props; + const { t } = useTranslation(); + const baseId = useBaseId(); + + const isSingle = typeof value === 'string'; + const values = isSingle ? [value] : value; + + return ( + {t('common.empty')}} + > + { + onClick(value.id); + StorageLinkSelected.set(`${field.options.foreignTableId}-${value.id}`, value.title); + }} + primaryFieldId={field.options.lookupFieldId} + /> + + ); +}; diff --git a/packages/sdk/src/components/filter/view-filter/component/filter-link/index.ts b/packages/sdk/src/components/filter/view-filter/component/filter-link/index.ts index 5d7fc1f0c7..696950885a 100644 --- a/packages/sdk/src/components/filter/view-filter/component/filter-link/index.ts +++ b/packages/sdk/src/components/filter/view-filter/component/filter-link/index.ts @@ -1,2 +1,3 @@ export * from './FilterLink'; export * from './FilterLinkSelect'; +export * from './StandDefaultList'; diff --git a/packages/sdk/src/components/filter/view-filter/hooks/index.ts b/packages/sdk/src/components/filter/view-filter/hooks/index.ts index 135a6589df..03035a2552 100644 --- a/packages/sdk/src/components/filter/view-filter/hooks/index.ts +++ b/packages/sdk/src/components/filter/view-filter/hooks/index.ts @@ -4,3 +4,4 @@ export * from './useViewFilterLinkContext'; export * from './useDateI18nMap'; export * from './useOperatorI18nMap'; export * from './useViewFilterContext'; +export * from './useFieldFilterLinkContext'; diff --git a/packages/sdk/src/components/filter/view-filter/hooks/useFieldFilterLinkContext.ts b/packages/sdk/src/components/filter/view-filter/hooks/useFieldFilterLinkContext.ts new file mode 100644 index 0000000000..d98c9983a8 --- /dev/null +++ b/packages/sdk/src/components/filter/view-filter/hooks/useFieldFilterLinkContext.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query'; +import { getFieldFilterLinkRecords } from '@teable/openapi'; +import { ReactQueryKeys } from '../../../../config'; + +export const useFieldFilterLinkContext = ( + tableId: string, + fieldId?: string, + disabled?: boolean +) => { + const { isLoading, data: queryData } = useQuery({ + queryKey: ReactQueryKeys.getFieldFilterLinkRecords(tableId, fieldId!), + queryFn: ({ queryKey }) => + getFieldFilterLinkRecords(queryKey[1], queryKey[2]).then((data) => data.data), + enabled: !disabled && Boolean(fieldId), + }); + + return { + isLoading, + data: queryData?.map((v) => ({ + tableId: v.tableId, + data: v.records.reduce( + (acc, cur) => { + acc[cur.id] = cur.title; + return acc; + }, + {} as Record + ), + })), + }; +}; diff --git a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx index 8902f3492b..01c8f2147a 100644 --- a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx +++ b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx @@ -169,8 +169,13 @@ export const useCreateCellValue2GridDisplay = (rowHeight?: RowHeightLevel) => { return useCallback( (fields: IFieldInstance[], editable: (field: IFieldInstance) => boolean) => - // eslint-disable-next-line sonarjs/cognitive-complexity - (record: Record, col: number, isPrefilling?: boolean): ICell => { + ( + record: Record, + col: number, + isPrefilling?: boolean, + expandRecord?: (tableId: string, recordId: string) => void + // eslint-disable-next-line sonarjs/cognitive-complexity + ): ICell => { const field = fields[col]; if (field == null) return { type: CellType.Loading }; @@ -367,6 +372,7 @@ export const useCreateCellValue2GridDisplay = (rowHeight?: RowHeightLevel) => { const cv = cellValue ? (Array.isArray(cellValue) ? cellValue : [cellValue]) : []; const displayData = cv.map(({ title }) => title || t('common.untitled')); const choices = cv.map(({ id, title }) => ({ id, name: title })); + const { foreignTableId } = field.options; return { ...baseCellProps, type: CellType.Select, @@ -374,6 +380,9 @@ export const useCreateCellValue2GridDisplay = (rowHeight?: RowHeightLevel) => { displayData, choiceSorted: choices, isMultiple, + onPreview: (activeId: string) => { + expandRecord?.(foreignTableId, activeId); + }, customEditor: (props) => , }; } @@ -474,9 +483,9 @@ export const useCreateCellValue2GridDisplay = (rowHeight?: RowHeightLevel) => { ); }; -export function useGridColumns(hasMenu?: boolean) { +export function useGridColumns(hasMenu?: boolean, hiddenFieldIds?: string[]) { const view = useView() as GridView | undefined; - const fields = useFields(); + const originFields = useFields(); const totalFields = useFields({ withHidden: true, withDenied: true }); const fieldEditable = useFieldCellEditable(); const { resolvedTheme } = useTheme(); @@ -485,6 +494,11 @@ export function useGridColumns(hasMenu?: boolean) { const filter = view?.filter; const isAutoSort = sort && !sort?.manualSort; + const fields = useMemo(() => { + const hiddenSet = new Set(hiddenFieldIds ?? []); + return originFields.filter((field) => !hiddenSet.has(field.id)); + }, [originFields, hiddenFieldIds]); + const sortFieldIds = useMemo(() => { if (!isAutoSort) return; diff --git a/packages/sdk/src/components/grid/renderers/cell-renderer/interface.ts b/packages/sdk/src/components/grid/renderers/cell-renderer/interface.ts index 67bf3f46d3..1667fa1b7a 100644 --- a/packages/sdk/src/components/grid/renderers/cell-renderer/interface.ts +++ b/packages/sdk/src/components/grid/renderers/cell-renderer/interface.ts @@ -125,6 +125,7 @@ export interface ISelectCell extends IEditableCell { choiceSorted?: ISelectChoiceSorted[]; isMultiple?: boolean; isEditingOnClick?: boolean; + onPreview?: (activeId: string) => void; } export interface IImageData { diff --git a/packages/sdk/src/components/grid/renderers/cell-renderer/selectCellRenderer.ts b/packages/sdk/src/components/grid/renderers/cell-renderer/selectCellRenderer.ts index 3e0beadccd..fc91abed55 100644 --- a/packages/sdk/src/components/grid/renderers/cell-renderer/selectCellRenderer.ts +++ b/packages/sdk/src/components/grid/renderers/cell-renderer/selectCellRenderer.ts @@ -1,7 +1,7 @@ import { LRUCache } from 'lru-cache'; import type { IGridTheme } from '../../configs'; import { GRID_DEFAULT } from '../../configs'; -import type { IPosition, IRectangle } from '../../interface'; +import type { IRectangle } from '../../interface'; import type { SpriteManager } from '../../managers'; import { GridInnerIcon } from '../../managers'; import { isPointInsideRectangle } from '../../utils'; @@ -16,7 +16,16 @@ import type { ICellClickCallback, } from './interface'; -const deleteBtnPositionCache: LRUCache = new LRUCache({ +enum ISelectRegionType { + Content = 'Content', + DeleteBtn = 'DeleteBtn', +} + +interface ISelectRegion extends IRectangle { + type: ISelectRegionType; +} + +const positionCache: LRUCache = new LRUCache({ max: 10, }); @@ -102,7 +111,7 @@ export const selectCellRenderer: IInternalCellRenderer = { const lineHeight = iconSizeSM + OPTION_GAP_SIZE; const cacheKey = `${String(width)}-${displayData.join(',')}`; - const deleteBtnPositions: IPosition[] = []; + const positions: ISelectRegion[] = []; for (const text of displayData) { const { width: displayWidth } = drawSingleLineText(ctx, { @@ -121,15 +130,28 @@ export const selectCellRenderer: IInternalCellRenderer = { y += lineHeight; } - deleteBtnPositions.push({ - x: x + width - iconSizeXS - OPTION_PADDING_HORIZONTAL + 2, + positions.push({ + type: ISelectRegionType.Content, + x, y: y + 2, + width: displayWidth + OPTION_PADDING_HORIZONTAL + 2, + height: lineHeight, }); + if (!readonly) { + positions.push({ + type: ISelectRegionType.DeleteBtn, + x: x + width - iconSizeXS - OPTION_PADDING_HORIZONTAL + 2, + y: y + 2, + width: iconSizeXS, + height: lineHeight, + }); + } + x += width + OPTION_PADDING_HORIZONTAL; } - deleteBtnPositionCache.set(cacheKey, deleteBtnPositions); + positionCache.set(cacheKey, positions); const totalHeight = SELECT_CELL_PADDING_TOP + lineCount * lineHeight; const displayRowCount = Math.min(maxRowCount, lineCount); @@ -218,36 +240,45 @@ export const selectCellRenderer: IInternalCellRenderer = { ctx.restore(); }, + // eslint-disable-next-line sonarjs/cognitive-complexity checkRegion: (cell: ISelectCell, props: ICellClickProps, shouldCalculate?: boolean) => { const { data, displayData, readonly } = cell; - const { width, theme, isActive, hoverCellPosition, activeCellBound } = props; + const { width, isActive, hoverCellPosition, activeCellBound } = props; const editable = !readonly && isActive && activeCellBound; if (!editable) return { type: CellRegionType.Blank }; - const { iconSizeXS } = theme; const { scrollTop } = activeCellBound; const [hoverX, hoverY] = hoverCellPosition; const cacheKey = `${String(width)}-${displayData.join(',')}`; - const deleteBtnPositions = deleteBtnPositionCache.get(cacheKey); - - if (!deleteBtnPositions) return { type: CellRegionType.Blank }; - - for (let i = 0; i < deleteBtnPositions.length; i++) { - const { x, y } = deleteBtnPositions[i]; - - if ( - isPointInsideRectangle( - [hoverX, scrollTop + hoverY], - [x, y], - [x + iconSizeXS, y + iconSizeXS] - ) - ) { - if (!shouldCalculate) return { type: CellRegionType.Update, data: null }; - const result = data.filter((_, index) => index !== i); + const positions = positionCache.get(cacheKey); + + if (!positions) return { type: CellRegionType.Blank }; + + for (let i = 0; i < positions.length; i++) { + const { type, x, y, width, height } = positions[i]; + + if (isPointInsideRectangle([hoverX, scrollTop + hoverY], [x, y], [x + width, y + height])) { + if (!shouldCalculate) { + return { + type: + type === ISelectRegionType.DeleteBtn ? CellRegionType.Update : CellRegionType.Preview, + data: null, + }; + } + + const realIndex = Math.floor(i / 2); + + if (type === ISelectRegionType.DeleteBtn) { + const result = data.filter((_, index) => index !== realIndex); + return { + type: CellRegionType.Update, + data: result.length ? result : null, + }; + } return { - type: CellRegionType.Update, - data: result.length ? result : null, + type: CellRegionType.Preview, + data: (data[realIndex] as { id: string; title: string })?.id, }; } } @@ -266,6 +297,9 @@ export const selectCellRenderer: IInternalCellRenderer = { } return; } + if (cellRegion.type === CellRegionType.Preview) { + return cell?.onPreview?.(cellRegion.data as string); + } callback(cellRegion); }, }; diff --git a/packages/sdk/src/components/hide-fields/HideFields.tsx b/packages/sdk/src/components/hide-fields/HideFields.tsx index 9102f45463..17e7b8857e 100644 --- a/packages/sdk/src/components/hide-fields/HideFields.tsx +++ b/packages/sdk/src/components/hide-fields/HideFields.tsx @@ -4,6 +4,7 @@ import { useTranslation } from '../../context/app/i18n'; import { useViewId, useFields, useView } from '../../hooks'; import type { GridView, IFieldInstance } from '../../model'; +import { swapReorder } from '../../utils'; import { HideFieldsBase } from './HideFieldsBase'; export const HideFields: React.FC<{ @@ -45,12 +46,40 @@ export const HideFields: React.FC<{ } }; + const onOrderChange = (fieldId: string, fromIndex: number, toIndex: number) => { + if (!view) return; + + const newOrder = swapReorder(1, fromIndex, toIndex, fields.length, (index) => { + const fieldId = fields[index].id; + return view?.columnMeta[fieldId].order; + })[0]; + + if (newOrder === view?.columnMeta[fieldId].order) { + return; + } + + view.updateColumnMeta([ + { + fieldId, + columnMeta: { + order: newOrder, + }, + }, + ]); + }; + if (!activeViewId) { return <>; } return ( -