diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts index 710fe4b4ab..d5f6507d44 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts @@ -13,7 +13,7 @@ import { UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import type { ICreateRecordsVo, IRecord, IRecordsVo } from '@teable/openapi'; +import type { ICreateRecordsVo, IRecord, IRecordStatusVo, IRecordsVo } from '@teable/openapi'; import { createRecordsRoSchema, getRecordQuerySchema, @@ -177,4 +177,14 @@ export class RecordOpenApiController { ) { return this.recordService.getDocIdsByQuery(tableId, query); } + + @Permissions('record|read') + @Get(':recordId/status') + async getRecordStatus( + @Param('tableId') tableId: string, + @Param('recordId') recordId: string, + @Query(new ZodValidationPipe(getRecordsRoSchema), TqlPipe) query: IGetRecordsRo + ): Promise { + return await this.recordService.getRecordStatus(tableId, recordId, query); + } } diff --git a/apps/nestjs-backend/src/features/record/record.service.ts b/apps/nestjs-backend/src/features/record/record.service.ts index 5f79f95b64..e5204a8a88 100644 --- a/apps/nestjs-backend/src/features/record/record.service.ts +++ b/apps/nestjs-backend/src/features/record/record.service.ts @@ -42,6 +42,7 @@ import type { IGroupHeaderPoint, IGroupPoint, IGroupPointsVo, + IRecordStatusVo, IRecordsVo, } from '@teable/openapi'; import { GroupPointType, UploadType } from '@teable/openapi'; @@ -1678,4 +1679,38 @@ export class RecordService { return { groupPoints, filter: mergeFilter(filter, filterWithCollapsed) }; } + + async getRecordStatus( + tableId: string, + recordId: string, + query: IGetRecordsRo + ): Promise { + const dbTableName = await this.getDbTableName(tableId); + const queryBuilder = this.knex(dbTableName).select('__id').where('__id', recordId).limit(1); + + const result = await this.prismaService + .txClient() + .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery()); + + const isDeleted = result.length === 0; + + if (isDeleted) { + return { isDeleted, isVisible: false }; + } + + const queryResult = await this.getDocIdsByQuery(tableId, { + viewId: query.viewId, + skip: query.skip, + take: query.take, + filter: query.filter, + orderBy: query.orderBy, + search: query.search, + groupBy: query.groupBy, + filterLinkCellCandidate: query.filterLinkCellCandidate, + filterLinkCellSelected: query.filterLinkCellSelected, + selectedRecordIds: query.selectedRecordIds, + }); + const isVisible = queryResult.ids.includes(recordId); + return { isDeleted, isVisible }; + } } diff --git a/apps/nextjs-app/src/features/app/blocks/graph/CellGraph.tsx b/apps/nextjs-app/src/features/app/blocks/graph/CellGraph.tsx index c238ee9649..673e0369b9 100644 --- a/apps/nextjs-app/src/features/app/blocks/graph/CellGraph.tsx +++ b/apps/nextjs-app/src/features/app/blocks/graph/CellGraph.tsx @@ -2,12 +2,11 @@ import { useMutation } from '@tanstack/react-query'; import { ColorUtils } from '@teable/core'; import { DraggableHandle, X } from '@teable/icons'; import { IdReturnType, getGraph, getIdsFromRanges } from '@teable/openapi'; -import { useBaseId, useTableId, useViewId } from '@teable/sdk'; +import { useBaseId, useGridViewStore, useTableId, useViewId } from '@teable/sdk'; import { Button } from '@teable/ui-lib/shadcn'; import { useEffect, useRef, useState } from 'react'; import { Rnd } from 'react-rnd'; import { useMount } from 'react-use'; -import { useGridViewStore } from '../view/grid/store/gridView'; import { useCellGraphStore } from './useCellGraphStore'; import { useGraph } from './useGraph'; diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx index 840347eb7e..59437d2120 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx @@ -29,6 +29,7 @@ import { generateLocalId, useGridTooltipStore, RegionType, + useGridViewStore, } from '@teable/sdk/components'; import { useIsHydrated, @@ -52,7 +53,6 @@ import type { IExpandRecordContainerRef } from '@/features/app/components/Expand import { useHiddenFields } from '@/features/app/hooks/useHiddenFields'; import { GIRD_ROW_HEIGHT_DEFINITIONS } from '../../../../view/grid/const'; import { useSelectionOperation } from '../../../../view/grid/hooks'; -import { useGridViewStore } from '../../../../view/grid/store/gridView'; interface IGridViewProps { groupPointsServerData?: IGroupPointsVo; 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 68d0905f36..eb8e2df906 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 @@ -16,7 +16,6 @@ import type { ICellItem, ICell, IInnerCell, - Record, GridView, IGroupPoint, IUseTablePermissionAction, @@ -48,6 +47,10 @@ import { SelectableType, useGridRowOrder, ExpandRecorder, + useGridViewStore, + useGridSelection, + Record, + DragRegionType, } from '@teable/sdk'; import { GRID_DEFAULT } from '@teable/sdk/components/grid/configs'; import { useScrollFrameRate } from '@teable/sdk/components/grid/hooks'; @@ -76,13 +79,12 @@ import type { IExpandRecordContainerRef } from '@/features/app/components/Expand import { tableConfig } from '@/features/i18n/table.config'; import { FieldOperator } from '../../../components/field-setting'; import { useFieldSettingStore } from '../field/useFieldSettingStore'; -import { PrefillingRowContainer } from './components'; +import { PrefillingRowContainer, PresortRowContainer } from './components'; import type { IConfirmNewRecordsRef } from './components/ConfirmNewRecords'; import { ConfirmNewRecords } from './components/ConfirmNewRecords'; import { GIRD_ROW_HEIGHT_DEFINITIONS } from './const'; import { DomBox } from './DomBox'; import { useCollaborate, useSelectionOperation } from './hooks'; -import { useGridViewStore } from './store/gridView'; interface IGridViewBaseInnerProps { groupPointsServerData?: IGroupPointsVo; @@ -97,10 +99,6 @@ export const GridViewBaseInner: React.FC = ( const { groupPointsServerData, onRowExpand } = props; const { t } = useTranslation(tableConfig.i18nNamespaces); const router = useRouter(); - const gridRef = useRef(null); - const prefillingGridRef = useRef(null); - const containerRef = useRef(null); - const expandRecordRef = useRef(null); const tableId = useTableId() as string; const activeViewId = useViewId(); const view = useView(activeViewId) as GridView | undefined; @@ -128,10 +126,17 @@ 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 confirmNewRecordsRef = useRef(null); const [newRecords, setNewRecords] = useState(); + const gridRef = useRef(null); + const presortGridRef = useRef(null); + const prefillingGridRef = useRef(null); + const containerRef = useRef(null); + const expandRecordRef = useRef(null); + const confirmNewRecordsRef = useRef(null); + const groupCollection = useGridGroupCollection(); const { viewQuery, collapsedGroupIds, onCollapsedGroupChanged } = useGridCollapsedGroup( @@ -143,7 +148,7 @@ export const GridViewBaseInner: React.FC = ( const commentCountMap = useCommentCountMap(recordsQuery); - const onRowOrdered = useGridRowOrder(recordMap); + const { onRowOrdered, setDraggingRecordIds } = useGridRowOrder(recordMap); const { copy, paste, clear, deleteRecords } = useSelectionOperation({ collapsedGroupIds: viewQuery?.collapsedGroupIds @@ -151,6 +156,15 @@ export const GridViewBaseInner: React.FC = ( : undefined, }); + const { + presortRecord, + onSelectionChanged, + presortRecordData, + onPresortCellEdited, + getPresortCellContent, + setPresortRecordData, + } = useGridSelection({ recordMap, columns, viewQuery, gridRef }); + const { localRecord, prefillingRowIndex, @@ -480,10 +494,13 @@ export const GridViewBaseInner: React.FC = ( copy(selection); }; - const onCopyForPrefilling = async (selection: CombinedSelection) => { + const onCopyForSingleRow = async ( + selection: CombinedSelection, + fieldValueMap?: { [fieldId: string]: unknown } + ) => { const { type } = selection; - if (type !== SelectionRegionType.Cells || prefillingFieldValueMap == null) return; + if (type !== SelectionRegionType.Cells || fieldValueMap == null) return; const getCopyData = async () => { const [start, end] = selection.serialize(); @@ -495,9 +512,7 @@ export const GridViewBaseInner: React.FC = ( }) .filter(Boolean) as IFieldVo[]; const content = [ - selectedFields.map((field) => - field.cellValue2String(prefillingFieldValueMap[field.id] as never) - ), + selectedFields.map((field) => field.cellValue2String(fieldValueMap[field.id] as never)), ]; return { content: stringifyClipboardText(content), header: filteredPropsFields }; }; @@ -526,12 +541,20 @@ export const GridViewBaseInner: React.FC = ( }); }; - const onSelectionChanged = useCallback( - (selection: CombinedSelection) => { - setSelection(selection); - }, - [setSelection] - ); + const onPasteForPresort = (selection: CombinedSelection, e: React.ClipboardEvent) => { + if (!presortRecord) return; + if (!permission['record|update']) { + return toast({ title: 'Unable to paste' }); + } + paste(e, selection, { 0: presortRecord }, (records) => { + Record.updateRecord(tableId, presortRecord.id, { + fieldKeyType: FieldKeyType.Id, + record: { + fields: { ...presortRecord.fields, ...records[0].fields }, + }, + }); + }); + }; const collaborators = useCollaborate(selection, getCellContent); @@ -650,6 +673,16 @@ export const GridViewBaseInner: React.FC = ( return DraggableType.All; }, [isAutoSort]); + const onDragStart = useCallback( + (type: DragRegionType, dragIndexs: number[]) => { + if (type === DragRegionType.Rows) { + const recordIds = dragIndexs.map((index) => recordMap[index]?.id).filter(Boolean); + setDraggingRecordIds(recordIds); + } + }, + [recordMap, setDraggingRecordIds] + ); + const getAuthorizedFunction = useCallback( // eslint-disable-next-line @typescript-eslint/no-explicit-any any>( @@ -686,6 +719,24 @@ export const GridViewBaseInner: React.FC = ( }; }, [rowHeight, prefillingRowIndex]); + const presortRowStyle = useMemo(() => { + const defaultTop = rowHeight / 2; + const height = rowHeight + 5; + const rowIndex = presortRecordData?.rowIndex; + + if (gridRef.current == null || rowIndex == null) { + return { top: defaultTop, height }; + } + + return { + top: Math.max( + gridRef.current.getRowOffset(rowIndex) + defaultTop, + GIRD_ROW_HEIGHT_DEFINITIONS[RowHeightLevel.Short] + ), + height, + }; + }, [rowHeight, presortRecordData]); + useEffect(() => { if (!inPrefilling) return; const scrollState = gridRef.current?.getScrollState(); @@ -731,6 +782,7 @@ export const GridViewBaseInner: React.FC = ( collaborators={collaborators} getCellContent={getCellContent} onDelete={getAuthorizedFunction(onDelete, 'record|update')} + onDragStart={onDragStart} onRowOrdered={onRowOrdered} onRowExpand={onRowExpandInner} onRowAppend={ @@ -792,11 +844,43 @@ export const GridViewBaseInner: React.FC = ( getCellContent={getPrefillingCellContent} onScrollChanged={onPrefillingGridScrollChanged} onCellEdited={onPrefillingCellEdited} - onCopy={onCopyForPrefilling} + onCopy={(selection) => onCopyForSingleRow(selection, prefillingFieldValueMap)} onPaste={onPasteForPrefilling} /> )} + {presortRecord && ( + setPresortRecordData(undefined)} + > + onCopyForSingleRow(selection, presortRecord.fields)} + onPaste={onPasteForPresort} + /> + + )} {!onRowExpand && } diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/components/FieldMenu.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/components/FieldMenu.tsx index eb3e94a137..78ced2ccc1 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/components/FieldMenu.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/components/FieldMenu.tsx @@ -14,7 +14,14 @@ import { } from '@teable/icons'; import { deleteFields } from '@teable/openapi'; import type { GridView, IUseFieldPermissionAction } from '@teable/sdk'; -import { useFields, useIsTouchDevice, useTableId, useTablePermission, useView } from '@teable/sdk'; +import { + useFields, + useGridViewStore, + useIsTouchDevice, + useTableId, + useTablePermission, + useView, +} from '@teable/sdk'; import { TablePermissionContext } from '@teable/sdk/context/table-permission'; import { insertSingle } from '@teable/sdk/utils'; @@ -37,7 +44,6 @@ import { FieldOperator } from '@/features/app/components/field-setting/type'; import { tableConfig } from '@/features/i18n/table.config'; import { useFieldSettingStore } from '../../field/useFieldSettingStore'; import { useToolBarStore } from '../../tool-bar/components/useToolBarStore'; -import { useGridViewStore } from '../store/gridView'; import type { IMenuItemProps } from './RecordMenu'; enum MenuItemType { diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/components/PresortRowContainer.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/components/PresortRowContainer.tsx new file mode 100644 index 0000000000..9b734c1380 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/components/PresortRowContainer.tsx @@ -0,0 +1,35 @@ +import { ArrowUpDown } from '@teable/icons'; +import { useTranslation } from 'next-i18next'; +import { useRef } from 'react'; +import { useClickAway } from 'react-use'; +import { tableConfig } from '@/features/i18n/table.config'; + +interface IRowStatusContainerProps { + children: React.ReactNode; + style?: React.CSSProperties; + onClickOutside?: () => void; +} + +export const PresortRowContainer = (props: IRowStatusContainerProps) => { + const { style, children, onClickOutside } = props; + const prefillingGridContainerRef = useRef(null); + const { t } = useTranslation(tableConfig.i18nNamespaces); + + useClickAway(prefillingGridContainerRef, () => { + onClickOutside?.(); + }); + + return ( +
+
+ + {t('table:grid.presortRowTitle')} +
+ {children} +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/components/RecordMenu.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/components/RecordMenu.tsx index ffd747941c..dcdb0c1674 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/components/RecordMenu.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/components/RecordMenu.tsx @@ -1,4 +1,5 @@ import { Trash, ArrowUp, ArrowDown } from '@teable/icons'; +import { useGridViewStore } from '@teable/sdk/components'; import { useTableId, useTablePermission, useView } from '@teable/sdk/hooks'; import { cn, @@ -19,7 +20,6 @@ import { useTranslation, Trans } from 'next-i18next'; import { Fragment, useCallback, useRef, useState } from 'react'; import { useClickAway } from 'react-use'; import { tableConfig } from '@/features/i18n/table.config'; -import { useGridViewStore } from '../store/gridView'; export interface IMenuItemProps { type: T; diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/components/StatisticMenu.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/components/StatisticMenu.tsx index d7b8077954..7eab98e09a 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/components/StatisticMenu.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/components/StatisticMenu.tsx @@ -1,7 +1,7 @@ /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ import { getValidStatisticFunc, NoneFunc } from '@teable/core'; import type { StatisticsFunc } from '@teable/core'; -import { useStatisticFunc2NameMap } from '@teable/sdk/components'; +import { useGridViewStore, useStatisticFunc2NameMap } from '@teable/sdk/components'; import { useField, useIsTouchDevice, useView } from '@teable/sdk/hooks'; import { Command, @@ -19,7 +19,6 @@ import { useTranslation } from 'next-i18next'; import { useRef } from 'react'; import { useClickAway } from 'react-use'; import { tableConfig } from '@/features/i18n/table.config'; -import { useGridViewStore } from '../store/gridView'; export const StatisticMenu = () => { const view = useView(); diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/components/index.ts b/apps/nextjs-app/src/features/app/blocks/view/grid/components/index.ts index d17e89c8e7..b7b2bc7392 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/components/index.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/components/index.ts @@ -2,4 +2,5 @@ export * from './FieldMenu'; export * from './RecordMenu'; export * from './StatisticMenu'; export * from './PrefillingRowContainer'; +export * from './PresortRowContainer'; export * from '../../field/FieldSetting'; diff --git a/packages/common-i18n/src/locales/en/table.json b/packages/common-i18n/src/locales/en/table.json index c8d2a5e141..38fcc76f90 100644 --- a/packages/common-i18n/src/locales/en/table.json +++ b/packages/common-i18n/src/locales/en/table.json @@ -371,7 +371,8 @@ }, "grid": { "prefillingRowTitle": "Add new record", - "prefillingRowTooltip": "Please enter the new record data below. The record will be saved automatically once you click outside this row." + "prefillingRowTooltip": "Please enter the new record data below. The record will be saved automatically once you click outside this row.", + "presortRowTitle": "This record has been filtered or moved due to sorting rules" }, "form": { "fieldsManagement": "Fields", diff --git a/packages/common-i18n/src/locales/fr/table.json b/packages/common-i18n/src/locales/fr/table.json index f511626d4f..50b2968f2c 100644 --- a/packages/common-i18n/src/locales/fr/table.json +++ b/packages/common-i18n/src/locales/fr/table.json @@ -362,7 +362,8 @@ }, "grid": { "prefillingRowTitle": "Ajouter un nouvel enregistrement", - "prefillingRowTooltip": "Veuillez entrer les données du nouvel enregistrement ci-dessous. L'enregistrement sera sauvegardé automatiquement une fois que vous cliquerez en dehors de cette ligne." + "prefillingRowTooltip": "Veuillez entrer les données du nouvel enregistrement ci-dessous. L'enregistrement sera sauvegardé automatiquement une fois que vous cliquerez en dehors de cette ligne.", + "presortRowTitle": "Cet enregistrement a été filtré ou déplacé en raison des règles de tri" }, "form": { "fieldsManagement": "Champs", diff --git a/packages/common-i18n/src/locales/ja/table.json b/packages/common-i18n/src/locales/ja/table.json index 1d1c3bbf4d..5419c44ce6 100644 --- a/packages/common-i18n/src/locales/ja/table.json +++ b/packages/common-i18n/src/locales/ja/table.json @@ -361,7 +361,8 @@ }, "grid": { "prefillingRowTitle": "新しいレコードを追加", - "prefillingRowTooltip": "以下に新しいレコードデータを入力してください。この行の外側をクリックすると、レコードは自動的に保存されます。" + "prefillingRowTooltip": "以下に新しいレコードデータを入力してください。この行の外側をクリックすると、レコードは自動的に保存されます。", + "presortRowTitle": "このレコードはフィルタリングまたは並べ替えルールにより変更されました" }, "form": { "fieldsManagement": "フィールド", diff --git a/packages/common-i18n/src/locales/ru/table.json b/packages/common-i18n/src/locales/ru/table.json index 7bcc38e6a7..4bb673f790 100644 --- a/packages/common-i18n/src/locales/ru/table.json +++ b/packages/common-i18n/src/locales/ru/table.json @@ -362,7 +362,8 @@ }, "grid": { "prefillingRowTitle": "Добавить новую запись", - "prefillingRowTooltip": "Пожалуйста, введите данные новой записи ниже. Запись будет сохранена автоматически, как только вы кликнете вне этой строки." + "prefillingRowTooltip": "Пожалуйста, введите данные новой записи ниже. Запись будет сохранена автоматически, как только вы кликнете вне этой строки.", + "presortRowTitle": "Эта запись была отфильтрована или перемещена из-за правил сортировки" }, "form": { "fieldsManagement": "Поля", diff --git a/packages/common-i18n/src/locales/zh/table.json b/packages/common-i18n/src/locales/zh/table.json index dd1ca4bae3..9153d80fe4 100644 --- a/packages/common-i18n/src/locales/zh/table.json +++ b/packages/common-i18n/src/locales/zh/table.json @@ -370,7 +370,8 @@ }, "grid": { "prefillingRowTitle": "新建记录", - "prefillingRowTooltip": "请在下方输入新记录的数据。当焦点离开当前行后,数据将自动保存。" + "prefillingRowTooltip": "请在下方输入新记录的数据。当焦点离开当前行后,数据将自动保存。", + "presortRowTitle": "此记录已经被过滤或因排序规则改变位置" }, "form": { "fieldsManagement": "字段管理", diff --git a/packages/openapi/src/record/get-record-status.ts b/packages/openapi/src/record/get-record-status.ts new file mode 100644 index 0000000000..24dfe81c0b --- /dev/null +++ b/packages/openapi/src/record/get-record-status.ts @@ -0,0 +1,57 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import type { AxiosResponse } from 'axios'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; +import type { IGetRecordsRo } from './get-list'; +import { getRecordsRoSchema } from './get-list'; + +export const GET_RECORD_STATUS_URL = '/table/{tableId}/record/{recordId}/status'; + +export const recordStatusVoSchema = z.object({ + isVisible: z.boolean(), + isDeleted: z.boolean(), +}); + +export type IRecordStatusVo = z.infer; + +export const GetRecordStatusRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_RECORD_STATUS_URL, + description: 'Get record status', + request: { + params: z.object({ + tableId: z.string(), + recordId: z.string(), + }), + query: getRecordsRoSchema, + }, + responses: { + 200: { + description: 'List of records', + content: { + 'application/json': { + schema: recordStatusVoSchema, + }, + }, + }, + }, + tags: ['record'], +}); + +export const getRecordStatus = ( + tableId: string, + recordId: string, + query?: IGetRecordsRo +): Promise> => { + const serializedQuery = { + ...query, + filter: query?.filter ? JSON.stringify(query.filter) : undefined, + orderBy: query?.orderBy ? JSON.stringify(query.orderBy) : undefined, + groupBy: query?.groupBy ? JSON.stringify(query.groupBy) : undefined, + }; + + return axios.get(urlBuilder(GET_RECORD_STATUS_URL, { tableId, recordId }), { + params: serializedQuery, + }); +}; diff --git a/packages/openapi/src/record/index.ts b/packages/openapi/src/record/index.ts index 6e4561e017..ba63be4aeb 100644 --- a/packages/openapi/src/record/index.ts +++ b/packages/openapi/src/record/index.ts @@ -8,3 +8,4 @@ export * from './delete-list'; export * from './get-record-history'; export * from './get-record-list-history'; export * from './upload-attachment'; +export * from './get-record-status'; diff --git a/packages/sdk/src/components/grid-enhancements/hooks/index.ts b/packages/sdk/src/components/grid-enhancements/hooks/index.ts index 2f0ce70d2e..f4f3333595 100644 --- a/packages/sdk/src/components/grid-enhancements/hooks/index.ts +++ b/packages/sdk/src/components/grid-enhancements/hooks/index.ts @@ -10,3 +10,4 @@ export * from './use-grid-column-statistics'; export * from './use-grid-group-collection'; export * from './use-grid-collapsed-group'; export * from './use-grid-prefilling-row'; +export * from './use-grid-selection'; diff --git a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-group-collection.ts b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-group-collection.ts index 068fffe2e2..b83a9574a9 100644 --- a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-group-collection.ts +++ b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-group-collection.ts @@ -43,7 +43,7 @@ const useGenerateGroupCellFn = () => { return useCallback( (fields: IFieldInstance[]) => // eslint-disable-next-line sonarjs/cognitive-complexity - (cellValue: unknown, depth: number): ICell => { + (_cellValue: unknown, depth: number): ICell => { const field = fields[depth]; if (field == null) return { type: CellType.Loading }; @@ -51,6 +51,11 @@ const useGenerateGroupCellFn = () => { const { id: fieldId, type, isMultipleCellValue: isMultiple, cellValueType } = field; const emptyStr = '(Empty)'; + const validateCellValue = field.validateCellValue(_cellValue); + const cellValue = ( + validateCellValue.success ? validateCellValue.data : undefined + ) as unknown; + if (cellValue == null) { return { type: CellType.Text, diff --git a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-row-order.ts b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-row-order.ts index 864d9bfcad..bd024c35be 100644 --- a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-row-order.ts +++ b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-row-order.ts @@ -1,5 +1,5 @@ import { FieldKeyType } from '@teable/core'; -import { useCallback } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTableId, useView, useViewId } from '../../../hooks'; import { Record, type GridView } from '../../../model'; import type { IRecordIndexMap } from './use-grid-async-records'; @@ -10,16 +10,12 @@ export const useGridRowOrder = (recordMap: IRecordIndexMap) => { const view = useView(viewId) as GridView | undefined; const group = view?.group; - return useCallback( - (rowIndexCollection: number[], newRowIndex: number) => { - const operationRecordIds: string[] = []; + const [draggingRecordIds, setDraggingRecordIds] = useState(); - for (const rowIndex of rowIndexCollection) { - const record = recordMap[rowIndex]; - if (!record) { - throw new Error('Can not find record by index: ' + rowIndex); - } - operationRecordIds.push(record.id); + const onRowOrdered = useCallback( + (rowIndexCollection: number[], newRowIndex: number) => { + if (draggingRecordIds?.length !== rowIndexCollection.length) { + return; } if (!viewId) { @@ -48,7 +44,7 @@ export const useGridRowOrder = (recordMap: IRecordIndexMap) => { if (newRowIndex === 0) { return Record.updateRecords(tableId as string, { fieldKeyType: FieldKeyType.Id, - records: operationRecordIds.map((recordId) => ({ id: recordId, fields: fieldValueMap })), + records: draggingRecordIds.map((recordId) => ({ id: recordId, fields: fieldValueMap })), order: { viewId, anchorId: recordMap[0].id, @@ -64,7 +60,7 @@ export const useGridRowOrder = (recordMap: IRecordIndexMap) => { return Record.updateRecords(tableId as string, { fieldKeyType: FieldKeyType.Id, - records: operationRecordIds.map((recordId) => ({ id: recordId, fields: fieldValueMap })), + records: draggingRecordIds.map((recordId) => ({ id: recordId, fields: fieldValueMap })), order: { viewId, anchorId: record.id, @@ -72,6 +68,13 @@ export const useGridRowOrder = (recordMap: IRecordIndexMap) => { }, }); }, - [viewId, recordMap, tableId, group] + [viewId, group, recordMap, tableId, draggingRecordIds] ); + + return useMemo(() => { + return { + onRowOrdered, + setDraggingRecordIds, + }; + }, [onRowOrdered, setDraggingRecordIds]); }; diff --git a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-selection.ts b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-selection.ts new file mode 100644 index 0000000000..031078fb15 --- /dev/null +++ b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-selection.ts @@ -0,0 +1,207 @@ +import { useMutation } from '@tanstack/react-query'; +import type { IGetRecordsRo } from '@teable/openapi'; +import { getRecordStatus } from '@teable/openapi'; +import { isEqual } from 'lodash'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useFieldCellEditable, useFields, useRecord, useTableId, useViewId } from '../../../hooks'; +import type { Record as IRecord } from '../../../model'; +import type { IGridRef } from '../../grid/Grid'; +import type { ICell, ICellItem, IGridColumn, IInnerCell } from '../../grid/interface'; +import { CellType, SelectionRegionType } from '../../grid/interface'; +import { emptySelection, type CombinedSelection } from '../../grid/managers'; +import { useGridViewStore } from '../store/useGridViewStore'; +import { useCreateCellValue2GridDisplay } from './use-grid-columns'; + +interface IUseGridSelectionProps { + recordMap: Record; + columns: (IGridColumn & { + id: string; + })[]; + viewQuery?: Pick; + gridRef: React.RefObject; +} + +interface IActiveCell { + recordId: string; + fieldId: string; + rowIndex: number; + columnIndex: number; +} + +export const useGridSelection = (props: IUseGridSelectionProps) => { + const { recordMap, columns, viewQuery, gridRef } = props; + const [activeCell, setActiveCell] = useState(); + const [presortRecordData, setPresortRecordData] = useState<{ + rowIndex: number; + recordId: string; + }>(); + const prevActiveCellRef = useRef(activeCell); + + const fields = useFields(); + const fieldEditable = useFieldCellEditable(); + const presortRecord = useRecord(presortRecordData?.recordId); + + const viewId = useViewId() as string; + const tableId = useTableId() as string; + const { setSelection } = useGridViewStore(); + + const { mutateAsync: mutateGetRecordStatus } = useMutation({ + mutationFn: ({ + tableId, + recordId, + skip, + }: { + tableId: string; + recordId: string; + skip: number; + }) => getRecordStatus(tableId, recordId, { ...viewQuery, viewId, skip, take: 1 }), + onSuccess: (data) => { + if (activeCell == null) return setActiveCell(undefined); + + const { isDeleted, isVisible } = data.data; + + if (!isDeleted && !isVisible) { + setPresortRecordData({ + rowIndex: activeCell.rowIndex, + recordId: activeCell.recordId, + }); + } + + setActiveCell(undefined); + setSelection(emptySelection); + gridRef.current?.setSelection(emptySelection); + }, + }); + + const createCellValue2GridDisplay = useCreateCellValue2GridDisplay(); + + const getPresortCellContent = useCallback<(cell: ICellItem) => ICell>( + (cell) => { + const [columnIndex] = cell; + const cellValue2GridDisplay = createCellValue2GridDisplay(fields, fieldEditable); + if (presortRecord != null) { + const fieldId = columns[columnIndex]?.id; + if (!fieldId) return { type: CellType.Loading }; + return cellValue2GridDisplay(presortRecord, columnIndex, true); + } + return { type: CellType.Loading }; + }, + [columns, createCellValue2GridDisplay, fieldEditable, fields, presortRecord] + ); + + const onPresortCellEdited = useCallback( + (cell: ICellItem, newVal: IInnerCell) => { + if (presortRecord == null) return; + + const [col] = cell; + const fieldId = columns[col].id; + const { type, data } = newVal; + let newCellValue: unknown = null; + + switch (type) { + case CellType.Select: + newCellValue = data?.length ? data : null; + break; + case CellType.Text: + case CellType.Number: + case CellType.Boolean: + default: + newCellValue = data === '' ? null : data; + } + const oldCellValue = presortRecord.getCellValue(fieldId) ?? null; + if (isEqual(newCellValue, oldCellValue)) return; + presortRecord.updateCell(fieldId, newCellValue); + return presortRecord; + }, + [presortRecord, columns] + ); + + const onSelectionChanged = useCallback( + (selection: CombinedSelection) => { + const { type, ranges } = selection; + let columnIndex: number | undefined; + let rowIndex: number | undefined; + + setSelection(selection); + + if (type === SelectionRegionType.None) { + setActiveCell(undefined); + prevActiveCellRef.current = undefined; + return; + } + if (type === SelectionRegionType.Cells) { + columnIndex = ranges[0][0]; + rowIndex = ranges[0][1]; + } + if (type === SelectionRegionType.Columns) { + columnIndex = ranges[0][0]; + rowIndex = 0; + } + if (type === SelectionRegionType.Rows) return; + if (columnIndex == null || rowIndex == null) return; + + const record = recordMap[rowIndex]; + const column = columns[columnIndex]; + + if (!column || !record) return; + + const curActiveCell = { + recordId: record.id, + fieldId: column.id, + rowIndex, + columnIndex, + }; + + if (isEqual(activeCell, curActiveCell)) return; + + prevActiveCellRef.current = curActiveCell; + setActiveCell(curActiveCell); + }, + [activeCell, columns, recordMap, setSelection] + ); + + useEffect(() => { + if (activeCell == null || prevActiveCellRef.current == null) return; + + const { rowIndex, columnIndex } = prevActiveCellRef.current; + + if (rowIndex !== activeCell.rowIndex || columnIndex !== activeCell.columnIndex) { + return; + } + + const activeRecordId = activeCell.recordId; + + if (recordMap[rowIndex]?.id === activeRecordId) return; + + const isVisibleRecord = Object.values(recordMap).find( + (record) => record?.id === activeRecordId + ); + + if (isVisibleRecord) return; + + mutateGetRecordStatus({ + tableId, + recordId: activeCell.recordId, + skip: activeCell.rowIndex, + }); + }, [activeCell, gridRef, recordMap, tableId, mutateGetRecordStatus]); + + return useMemo( + () => ({ + presortRecord, + presortRecordData, + onSelectionChanged, + onPresortCellEdited, + getPresortCellContent, + setPresortRecordData, + }), + [ + presortRecord, + presortRecordData, + onSelectionChanged, + onPresortCellEdited, + getPresortCellContent, + setPresortRecordData, + ] + ); +}; diff --git a/packages/sdk/src/components/grid-enhancements/store/index.ts b/packages/sdk/src/components/grid-enhancements/store/index.ts index 131d38dc9b..2187a7a443 100644 --- a/packages/sdk/src/components/grid-enhancements/store/index.ts +++ b/packages/sdk/src/components/grid-enhancements/store/index.ts @@ -1 +1,2 @@ export * from './useGridCollapsedGroupStore'; +export * from './useGridViewStore'; diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/store/type.ts b/packages/sdk/src/components/grid-enhancements/store/type.ts similarity index 73% rename from apps/nextjs-app/src/features/app/blocks/view/grid/store/type.ts rename to packages/sdk/src/components/grid-enhancements/store/type.ts index 8bfdec3c6f..76fe42ca46 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/store/type.ts +++ b/packages/sdk/src/components/grid-enhancements/store/type.ts @@ -1,5 +1,6 @@ -import type { CombinedSelection, IPosition, IRectangle } from '@teable/sdk/components'; -import type { IFieldInstance, Record } from '@teable/sdk/model'; +import type { IFieldInstance, Record } from '../../../model'; +import type { IPosition, IRectangle } from '../../grid/interface'; +import type { CombinedSelection } from '../../grid/managers'; export interface IHeaderMenu { fields: IFieldInstance[]; diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/store/gridView.ts b/packages/sdk/src/components/grid-enhancements/store/useGridViewStore.ts similarity index 96% rename from apps/nextjs-app/src/features/app/blocks/view/grid/store/gridView.ts rename to packages/sdk/src/components/grid-enhancements/store/useGridViewStore.ts index 6c755ec823..dcd6f621ee 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/store/gridView.ts +++ b/packages/sdk/src/components/grid-enhancements/store/useGridViewStore.ts @@ -1,5 +1,5 @@ -import type { CombinedSelection } from '@teable/sdk/components'; import { create } from 'zustand'; +import type { CombinedSelection } from '../../grid/managers'; import type { IHeaderMenu, IRecordMenu, IStatisticMenu } from './type'; interface IGridViewState { diff --git a/packages/sdk/src/components/grid/Grid.tsx b/packages/sdk/src/components/grid/Grid.tsx index 331ae437e7..83b8f59895 100644 --- a/packages/sdk/src/components/grid/Grid.tsx +++ b/packages/sdk/src/components/grid/Grid.tsx @@ -29,6 +29,7 @@ import type { IGroupPoint, ILinearRow, IGroupCollection, + DragRegionType, } from './interface'; import { RegionType, @@ -109,6 +110,7 @@ export interface IGridExternalProps { onColumnStatisticClick?: (colIndex: number, bounds: IRectangle) => void; onContextMenu?: (selection: CombinedSelection, position: IPosition) => void; onScrollChanged?: (scrollLeft: number, scrollTop: number) => void; + onDragStart?: (type: DragRegionType, dragIndexs: number[]) => void; /** * Triggered when the mouse hovers over the every type of region @@ -198,6 +200,7 @@ const GridBase: ForwardRefRenderFunction = (props, forward onColumnAppend, onColumnResize, onColumnOrdered, + onDragStart, onContextMenu, onSelectionChanged, onVisibleRegionChanged, @@ -605,6 +608,7 @@ const GridBase: ForwardRefRenderFunction = (props, forward onCopy={onCopy} onPaste={onPaste} onDelete={onDelete} + onDragStart={onDragStart} onRowAppend={onRowAppend} onRowExpand={onRowExpand} onRowOrdered={onRowOrdered} diff --git a/packages/sdk/src/components/grid/InteractionLayer.tsx b/packages/sdk/src/components/grid/InteractionLayer.tsx index 9f5d3301a4..0ca383d66d 100644 --- a/packages/sdk/src/components/grid/InteractionLayer.tsx +++ b/packages/sdk/src/components/grid/InteractionLayer.tsx @@ -146,6 +146,7 @@ export const InteractionLayerBase: ForwardRefRenderFunction< onColumnHeaderMenuClick, onColumnStatisticClick, onCollapsedGroupChanged, + onDragStart: _onDragStart, } = props; useImperativeHandle(ref, () => ({ @@ -556,7 +557,15 @@ export const InteractionLayerBase: ForwardRefRenderFunction< setEditing(false); editorContainerRef.current?.saveValue?.(); } - onDragStart(mouseState); + onDragStart(mouseState, (type, ranges) => { + if (type === DragRegionType.Columns) { + _onDragStart?.(type, flatRanges(ranges)); + } + if (type === DragRegionType.Rows) { + const originRealIndexs = flatRanges(ranges).map((index) => getLinearRow(index).realIndex); + _onDragStart?.(type, originRealIndexs); + } + }); onColumnFreezeStart(mouseState); prevActiveCellRef.current = activeCell; onSelectionStart(event, mouseState); diff --git a/packages/sdk/src/components/grid/hooks/useDrag.ts b/packages/sdk/src/components/grid/hooks/useDrag.ts index 5f10d03144..4d17a90313 100644 --- a/packages/sdk/src/components/grid/hooks/useDrag.ts +++ b/packages/sdk/src/components/grid/hooks/useDrag.ts @@ -47,7 +47,10 @@ export const useDrag = ( const [dragState, setDragState] = useState(DEFAULT_DRAG_STATE); const { scrollTop, scrollLeft } = scrollState; - const onDragStart = (mouseState: IMouseState) => { + const onDragStart = ( + mouseState: IMouseState, + onEnd: (type: DragRegionType, dragIndexs: IRange[]) => void + ) => { if (draggable === DraggableType.None) return; const { type, rowIndex: hoverRowIndex, columnIndex: hoverColumnIndex, x, y } = mouseState; @@ -59,6 +62,7 @@ export const useDrag = ( isRowSelection && selection.includes([hoverRowIndex, hoverRowIndex]) ? selectionRanges : ([[hoverRowIndex, hoverRowIndex]] as IRange[]); + onEnd(DragRegionType.Rows, ranges); setDragState({ type: DragRegionType.Rows, ranges, @@ -73,6 +77,7 @@ export const useDrag = ( isColumnSelection && selection.includes([hoverColumnIndex, hoverColumnIndex]) ? selectionRanges : ([[hoverColumnIndex, hoverColumnIndex]] as IRange[]); + onEnd(DragRegionType.Columns, ranges); setDragState({ type: DragRegionType.Columns, ranges, @@ -97,7 +102,7 @@ export const useDrag = ( const onDragEnd = ( mouseState: IMouseState, - onEnd: (dragIndex: IRange[], dropIndex: number) => void + onEnd: (dragIndexs: IRange[], dropIndex: number) => void ) => { const { type, isDragging } = dragState; diff --git a/packages/sdk/src/context/use-instances/reducer.ts b/packages/sdk/src/context/use-instances/reducer.ts index fa3e0014e7..44b2347b20 100644 --- a/packages/sdk/src/context/use-instances/reducer.ts +++ b/packages/sdk/src/context/use-instances/reducer.ts @@ -53,7 +53,6 @@ export function instanceReducer( ...state.instances.slice(0, action.index), ...state.instances.slice(action.index + action.docs.length), ], - extra: undefined, }; case 'move': { const { docs, from, to } = action; diff --git a/packages/sdk/src/hooks/use-record.ts b/packages/sdk/src/hooks/use-record.ts index 74e14f1dc7..93f2574176 100644 --- a/packages/sdk/src/hooks/use-record.ts +++ b/packages/sdk/src/hooks/use-record.ts @@ -48,10 +48,10 @@ export const useRecord = (recordId: string | undefined, initData?: IRecord) => { }, [connection, recordId, tableId]); return useMemo(() => { - if (!instance || !fields.length) { + if (!instance || !fields.length || recordId == null) { return undefined; } const fieldMap = keyBy(fields, 'id'); return recordInstanceFieldMap(instance, fieldMap); - }, [fields, instance]); + }, [fields, instance, recordId]); };