diff --git a/packages/s2-core/__tests__/bugs/issue-2528-spec.ts b/packages/s2-core/__tests__/bugs/issue-2528-spec.ts new file mode 100644 index 0000000000..527c9d24cf --- /dev/null +++ b/packages/s2-core/__tests__/bugs/issue-2528-spec.ts @@ -0,0 +1,57 @@ +/** + * @description spec for issue #2528 + * https://github.com/antvis/S2/issues/2528 + */ + +import * as mockDataConfig from '../data/simple-table-data.json'; +import { getContainer } from '../util/helpers'; +import type { SpreadSheet, S2DataConfig, S2Options } from '@/index'; +import { TableSheet } from '@/sheet-type'; + +const s2DataConfig: S2DataConfig = { + ...mockDataConfig, + meta: [{ field: 'cost', formatter: (v) => `${v}-@` }], +}; + +const s2Options: S2Options = { + width: 800, + height: 400, +}; + +describe('Table Sheet Editable Formatter Tests', () => { + let s2: SpreadSheet; + + beforeEach(() => { + s2 = new TableSheet(getContainer(), s2DataConfig, s2Options); + + s2.render(); + }); + + test('should get formatted data', () => { + const costValues = s2.interaction + .getPanelGroupAllDataCells() + .filter((cell) => cell.getMeta().valueField === 'cost') + .map((cell) => cell.getFieldValue()); + + expect(costValues).toEqual(['2-@', '2-@', '2-@']); + }); + + test('should only format data once after data edited', () => { + const id = '0-root[&]cost'; + const inputValue = 'test'; + + // 模拟一次编辑 (更新第一行的 cost) + const displayData = s2.dataSet.getDisplayDataSet(); + displayData[0].cost = inputValue; + s2.dataSet.displayFormattedValueMap?.set(id, inputValue); + + s2.render(); + + const costValues = s2.interaction + .getPanelGroupAllDataCells() + .filter((cell) => cell.getMeta().valueField === 'cost') + .map((cell) => cell.getFieldValue()); + + expect(costValues).toEqual([inputValue, '2-@', '2-@']); + }); +}); diff --git a/packages/s2-core/__tests__/unit/facet/pivot-facet-spec.ts b/packages/s2-core/__tests__/unit/facet/pivot-facet-spec.ts index 4e5e2438bf..62017ca0b9 100644 --- a/packages/s2-core/__tests__/unit/facet/pivot-facet-spec.ts +++ b/packages/s2-core/__tests__/unit/facet/pivot-facet-spec.ts @@ -108,6 +108,7 @@ jest.mock('@/data-set/pivot-data-set', () => { getDimensionValues: actualPivotDataSet.prototype.getDimensionValues, getFieldsAndPivotMetaByField: actualPivotDataSet.prototype.getFieldsAndPivotMetaByField, + displayFormattedValueMap: new Map(), }; }), }; diff --git a/packages/s2-core/__tests__/unit/facet/table-facet-spec.ts b/packages/s2-core/__tests__/unit/facet/table-facet-spec.ts index ad72e6226a..277d771e9d 100644 --- a/packages/s2-core/__tests__/unit/facet/table-facet-spec.ts +++ b/packages/s2-core/__tests__/unit/facet/table-facet-spec.ts @@ -63,6 +63,7 @@ jest.mock('@/sheet-type', () => { }, dataSet: { isEmpty: jest.fn(), + displayFormattedValueMap: new Map(), }, }; }), diff --git a/packages/s2-core/__tests__/util/helpers.ts b/packages/s2-core/__tests__/util/helpers.ts index 7d7d9dc486..c357db885b 100644 --- a/packages/s2-core/__tests__/util/helpers.ts +++ b/packages/s2-core/__tests__/util/helpers.ts @@ -72,6 +72,7 @@ export const createFakeSpreadSheet = () => { getMultiData() { return []; }, + displayFormattedValueMap: new Map(), } as unknown as any; s2.facet = { layoutResult: { diff --git a/packages/s2-core/src/cell/base-cell.ts b/packages/s2-core/src/cell/base-cell.ts index 28467f391f..e80abe2c46 100644 --- a/packages/s2-core/src/cell/base-cell.ts +++ b/packages/s2-core/src/cell/base-cell.ts @@ -53,6 +53,7 @@ import type { GuiIcon } from '../common/icons/gui-icon'; import { checkIsLinkField } from '../utils/interaction/link-field'; import type { Node } from '../facet/layout/node'; import type { ViewMeta } from '../common/interface/basic'; +import { customMerge } from '../utils/merge'; export abstract class BaseCell extends Group { // cell's data meta info @@ -113,7 +114,7 @@ export abstract class BaseCell extends Group { } public setMeta(viewMeta: T) { - this.meta = viewMeta; + this.meta = customMerge(this.meta, viewMeta); } public getIconStyle() { diff --git a/packages/s2-core/src/cell/data-cell.ts b/packages/s2-core/src/cell/data-cell.ts index 29472a49bb..ece1b92141 100644 --- a/packages/s2-core/src/cell/data-cell.ts +++ b/packages/s2-core/src/cell/data-cell.ts @@ -180,8 +180,8 @@ export class DataCell extends BaseCell { } } - public setMeta(viewMeta: ViewMeta) { - super.setMeta(viewMeta); + public setMeta(viewMeta: Partial) { + super.setMeta(viewMeta as ViewMeta); this.initCell(); } @@ -293,12 +293,16 @@ export class DataCell extends BaseCell { formattedValue: EMPTY_PLACEHOLDER, }; } - const { rowId, valueField, fieldValue, data } = this.meta; + + const { rowId, valueField, fieldValue, data, id } = this.meta; + const displayFormattedValue = + this.spreadsheet.dataSet.displayFormattedValueMap?.get(id); const rowMeta = this.spreadsheet.dataSet.getFieldMeta(rowId); const fieldId = rowMeta ? rowId : valueField; const formatter = this.spreadsheet.dataSet.getFieldFormatter(fieldId); // TODO: 这里只用 formatter(fieldValue, this.meta) 即可, 为了保持兼容, 暂时在第三个参入传入 meta 信息 - const formattedValue = formatter(fieldValue, data, this.meta); + const formattedValue = + displayFormattedValue ?? formatter(fieldValue, data, this.meta); return { value: fieldValue, diff --git a/packages/s2-core/src/cell/table-data-cell.ts b/packages/s2-core/src/cell/table-data-cell.ts index 436794cc6f..1bceb807b8 100644 --- a/packages/s2-core/src/cell/table-data-cell.ts +++ b/packages/s2-core/src/cell/table-data-cell.ts @@ -13,7 +13,6 @@ import { getOrCreateResizeAreaGroupById, getResizeAreaAttrs, } from '../utils/interaction/resize'; -import { checkIsLinkField } from '../utils/interaction/link-field'; export class TableDataCell extends DataCell { protected getLinkFieldStyle() { diff --git a/packages/s2-core/src/data-set/base-data-set.ts b/packages/s2-core/src/data-set/base-data-set.ts index 2b1dcfc2e2..335cc770a8 100644 --- a/packages/s2-core/src/data-set/base-data-set.ts +++ b/packages/s2-core/src/data-set/base-data-set.ts @@ -53,6 +53,9 @@ export abstract class BaseDataSet { // 透视表入口对象实例 public spreadsheet: SpreadSheet; + // 单元格所对应格式化后的值(用于编辑表) + public displayFormattedValueMap = new Map(); + public constructor(spreadsheet: SpreadSheet) { this.spreadsheet = spreadsheet; } diff --git a/packages/s2-react/playground/config.ts b/packages/s2-react/playground/config.ts index 53cb15349b..10dda84773 100644 --- a/packages/s2-react/playground/config.ts +++ b/packages/s2-react/playground/config.ts @@ -33,7 +33,7 @@ export const tableSheetMultipleColumns: Columns = [ export const tableSheetDataCfg: S2DataConfig = { data, totalData, - meta, + meta: [{ field: 'number', formatter: (v) => `${v}-@` }, ...meta], fields: { columns: tableSheetSingleColumns, }, @@ -77,7 +77,7 @@ export const pivotSheetDataCfgForCompactMode = customMerge(pivotSheetDataCfg, { }); export const s2Options: SheetComponentOptions = { - debug: false, + debug: true, width: 600, height: 400, frozenFirstRow: false, diff --git a/packages/s2-react/playground/index.tsx b/packages/s2-react/playground/index.tsx index 290dcc68a4..e54366583e 100644 --- a/packages/s2-react/playground/index.tsx +++ b/packages/s2-react/playground/index.tsx @@ -1299,6 +1299,7 @@ function MainLayout() { ref={s2Ref} themeCfg={themeCfg} onMounted={onSheetMounted} + onDataCellEditStart={logHandler('onDataCellEditStart')} onDataCellEditEnd={logHandler('onDataCellEditEnd')} /> diff --git a/packages/s2-react/src/components/sheets/editable-sheet/edit-cell/index.tsx b/packages/s2-react/src/components/sheets/editable-sheet/edit-cell/index.tsx index ee41385145..ce6a67059c 100644 --- a/packages/s2-react/src/components/sheets/editable-sheet/edit-cell/index.tsx +++ b/packages/s2-react/src/components/sheets/editable-sheet/edit-cell/index.tsx @@ -1,22 +1,16 @@ import type { Event as CanvasEvent } from '@antv/g-canvas'; import { - BaseCell, S2Event, SpreadSheet, + customMerge, type DataType, type S2CellType, + type TableDataCell, type ViewMeta, } from '@antv/s2'; import { Input } from 'antd'; -import { merge, pick } from 'lodash'; -import React, { - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { pick } from 'lodash'; +import React from 'react'; import { useS2Event } from '../../../../hooks'; import { useSpreadSheetRef } from '../../../../utils/SpreadSheetContext'; import { @@ -34,9 +28,15 @@ export interface CustomProps { cell: S2CellType; } +type DateCellEdit = (meta: ViewMeta, cell: TableDataCell) => void; + type EditCellProps = { + /** + * @deprecated use `onDataCellEditEnd` instead. + */ onChange?: (data: DataType[]) => void; - onDataCellEditEnd?: (meta: ViewMeta) => void; + onDataCellEditStart?: DateCellEdit; + onDataCellEditEnd?: DateCellEdit; trigger?: number; CustomComponent?: React.FunctionComponent; }; @@ -46,30 +46,32 @@ function EditCellComponent( ) { const { params, resolver } = props; const spreadsheet = useSpreadSheetRef(); - const { event, onChange, onDataCellEditEnd, CustomComponent } = params; - const cell: BaseCell = event.target.cfg.parent; + const { + event, + onChange, + onDataCellEditStart, + onDataCellEditEnd, + CustomComponent, + } = params; - const { left, top, width, height } = useMemo(() => { - const rect = ( - spreadsheet?.container.cfg.container as HTMLElement - ).getBoundingClientRect(); + const cell = spreadsheet.getCell(event.target); + const { left, top, width, height } = React.useMemo>(() => { + const rect = spreadsheet.getCanvasElement()?.getBoundingClientRect(); - const modified = { + return { left: window.scrollX + rect.left, top: window.scrollY + rect.top, width: rect.width, height: rect.height, }; - - return modified; - }, [spreadsheet?.container.cfg.container]); + }, [spreadsheet]); const { x: cellLeft, y: cellTop, width: cellWidth, height: cellHeight, - } = useMemo(() => { + } = React.useMemo(() => { const scroll = spreadsheet.facet.getScrollOffset(); const cellMeta = pick(cell.getMeta(), ['x', 'y', 'width', 'height']); @@ -81,45 +83,44 @@ function EditCellComponent( return cellMeta; }, [cell, spreadsheet]); - const [inputVal, setInputVal] = useState(cell.getMeta().fieldValue); - const inputRef = useRef(null); - const containerRef = useRef(null); + const [inputVal, setInputVal] = React.useState(() => cell.getFieldValue()); - useEffect(() => { - setTimeout(() => { - // 防止触发表格全选 - containerRef.current?.click(); - // 开启 preventScroll, 防止页面有滚动条时触发滚动 - inputRef.current?.focus({ preventScroll: true }); - }); - }, []); + const inputRef = React.useRef(null); + const containerRef = React.useRef(null); const onSave = () => { - const { rowIndex, valueField } = cell.getMeta(); + const { rowIndex, valueField, id } = cell.getMeta(); const displayData = spreadsheet.dataSet.getDisplayDataSet(); displayData[rowIndex][valueField] = inputVal; - spreadsheet.render(true); - - onDataCellEditEnd?.( - merge(cell.getMeta(), { - fieldValue: inputVal, - data: { - [valueField]: inputVal, - }, - }), - ); + // 编辑后的值作为格式化后的结果, formatter 不再触发, 避免二次格式化 + spreadsheet.dataSet.displayFormattedValueMap?.set(id, inputVal); + spreadsheet.render(); + + const editedMeta = customMerge(cell.getMeta(), { + fieldValue: inputVal, + data: { + [valueField]: inputVal, + }, + }); + onDataCellEditEnd?.(editedMeta, cell); onChange?.(displayData); resolver(true); }; const onKeyDown: React.KeyboardEventHandler = (e) => { - if (e.keyCode === 13) { + if (e.key === 'Enter') { e.preventDefault(); onSave(); } }; + // 让输入框聚焦时光标在文字的末尾 + const onFocus: React.FocusEventHandler = (e) => { + e.target.selectionStart = e.target.value.length; + e.target.selectionEnd = e.target.value.length; + }; + const styleProps = React.useMemo(() => { return { left: cellLeft, @@ -134,6 +135,16 @@ function EditCellComponent( setInputVal(val); }; + React.useEffect(() => { + onDataCellEditStart?.(cell.getMeta(), cell); + setTimeout(() => { + // 防止触发表格全选 + containerRef.current?.click(); + // 开启 preventScroll, 防止页面有滚动条时触发滚动 + inputRef.current?.focus({ preventScroll: true }); + }); + }, []); + return (
)}
); } -export const EditCell: React.FC = memo( - ({ onChange, onDataCellEditEnd, CustomComponent }) => { - const spreadsheet = useSpreadSheetRef(); - - const cb = useCallback( - (event: CanvasEvent) => { - invokeComponent( - EditCellComponent, - { event, onChange, onDataCellEditEnd, CustomComponent }, - spreadsheet, - ); - }, - [spreadsheet], - ); +export const EditCell: React.FC = React.memo((props) => { + const spreadsheet = useSpreadSheetRef(); + + const cb = React.useCallback( + (event: CanvasEvent) => { + invokeComponent(EditCellComponent, { ...props, event }, spreadsheet); + }, + [spreadsheet], + ); - useS2Event(S2Event.DATA_CELL_DOUBLE_CLICK, cb, spreadsheet); + useS2Event(S2Event.DATA_CELL_DOUBLE_CLICK, cb, spreadsheet); - return null; - }, -); + return null; +}); EditCell.displayName = 'EditCell'; diff --git a/packages/s2-react/src/components/sheets/editable-sheet/index.tsx b/packages/s2-react/src/components/sheets/editable-sheet/index.tsx index 75ef726733..c20a924973 100644 --- a/packages/s2-react/src/components/sheets/editable-sheet/index.tsx +++ b/packages/s2-react/src/components/sheets/editable-sheet/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { BaseSheet } from '../base-sheet'; import type { SheetComponentsProps } from '../interface'; import { DragCopyPoint } from './drag-copy'; @@ -6,11 +6,10 @@ import { EditCell } from './edit-cell'; export const EditableSheet: React.FC = React.memo( (props) => { - const onChange = useCallback(() => {}, []); return ( diff --git a/packages/s2-shared/src/interface.ts b/packages/s2-shared/src/interface.ts index 9a6b4e0529..594efd20b8 100644 --- a/packages/s2-shared/src/interface.ts +++ b/packages/s2-shared/src/interface.ts @@ -19,6 +19,7 @@ import type { S2RenderOptions, SortParams, SpreadSheet, + TableDataCell, TargetCellInfo, ThemeCfg, TooltipContentType, @@ -121,7 +122,8 @@ export interface BaseSheetComponentProps< onDataCellTrendIconClick?: (meta: ViewMeta) => void; onDataCellBrushSelection?: (brushRangeDataCells: DataCell[]) => void; onDataCellSelectMove?: (metas: CellMeta[]) => void; - onDataCellEditEnd?: (meta: ViewMeta) => void; + onDataCellEditStart?: (meta: ViewMeta, cell: TableDataCell) => void; + onDataCellEditEnd?: (meta: ViewMeta, cell: TableDataCell) => void; // ============== Corner Cell ==================== onCornerCellHover?: (data: TargetCellInfo) => void; diff --git a/s2-site/docs/api/basic-class/base-cell.en.md b/s2-site/docs/api/basic-class/base-cell.en.md index 1dd9727128..bcc5984333 100644 --- a/s2-site/docs/api/basic-class/base-cell.en.md +++ b/s2-site/docs/api/basic-class/base-cell.en.md @@ -7,7 +7,7 @@ Function description: cell base class. [details](https://github.com/antvis/S2/bl | parameter | illustrate | type | | ---------------------- | -------------------------------------- | ----------------------------------------------------------------------- | | getMeta | Get cell metadata | () => [ViewMeta](#viewmeta) | -| setMeta | Set cell metadata | (vieMeta: [ViewMeta](#viewmeta) ) => void | +| setMeta | Set cell metadata | (vieMeta: [Partial](#viewmeta) ) => void | | getIconStyle | Get cell icon style | () => [IconTheme](/docs/api/general/S2Theme#icontheme) | | getStyle | get cell style | () => [DefaultCellTheme](/docs/api/general/S2Theme#defaultcelltheme) | | getTextAndIconPosition | Get the position of cell text and icon | (iconCount: `number` ) => [TextAndIconPosition](#textandiconposition) | diff --git a/s2-site/docs/api/basic-class/base-cell.zh.md b/s2-site/docs/api/basic-class/base-cell.zh.md index 45753b1589..3bd8e03483 100644 --- a/s2-site/docs/api/basic-class/base-cell.zh.md +++ b/s2-site/docs/api/basic-class/base-cell.zh.md @@ -12,7 +12,7 @@ cell.getActualText() | 参数 | 说明 | 类型 | | --- | --- | --- | | getMeta | 获取单元格元数据 | () => [ViewMeta](#viewmeta) | -| setMeta | 设置单元格元数据 | (vieMeta: [ViewMeta](#viewmeta)) => void | +| setMeta | 设置单元格元数据 | (vieMeta: [Partial](#viewmeta)) => void | | getIconStyle | 获取单元格图标样式 | () => [IconTheme](/docs/api/general/S2Theme#icontheme) | | getStyle | 获取单元格样式 | () => [DefaultCellTheme](/docs/api/general/S2Theme#defaultcelltheme) | | getTextAndIconPosition | 获取单元格文本和图标的位置 | (iconCount: `number`) => [TextAndIconPosition](#textandiconposition) | diff --git a/s2-site/docs/api/basic-class/base-data-set.zh.md b/s2-site/docs/api/basic-class/base-data-set.zh.md index 1d3d304ca1..90511ff21b 100644 --- a/s2-site/docs/api/basic-class/base-data-set.zh.md +++ b/s2-site/docs/api/basic-class/base-data-set.zh.md @@ -27,9 +27,10 @@ s2.dataSet.getFieldName('type') | getDimensionValues | 获取维值 | (filed: string, query?: [DataType](#datatype) ) => string[] | | | getCellData | 获取单个的单元格数据 | (params: [CellDataParams](#celldataparams)) => [DataType[]](#datatype) | | | getMultiData | 获取批量的单元格数据 | (query: [DataType](#datatype),params?: [MultiDataParams](#multidataparams)) => [DataType[]](#datatype) | | -| getMultiData (已废弃) | 获取批量的单元格数据 | (query: [DataType](#datatype), isTotals?: boolean, isRow?: boolean, drillDownFields?: string[], includeTotalData:boolean) => [DataType[]](#datatype) | | +| getMultiData (已废弃) | 获取批量的单元格数据 | (query: [DataType](#datatype), isTotals?: boolean, isRow?: boolean, drillDownFields?: string[], includeTotalData:boolean) => [DataType[]](#datatype) | | | moreThanOneValue | 是否超过 1 个数值 | () => [ViewMeta](#viewmeta) | | | isEmpty | 是否为空数据集 | () => `boolean` | `@antv/s2-v1.51.1` | +| displayFormattedValueMap | 单元格所对应格式化后的值(用于编辑表) | `Map` | `@antv/s2-v1.54.5` | ### DataType diff --git a/s2-site/docs/api/components/sheet-component.zh.md b/s2-site/docs/api/components/sheet-component.zh.md index d114b3a135..9599998ad6 100644 --- a/s2-site/docs/api/components/sheet-component.zh.md +++ b/s2-site/docs/api/components/sheet-component.zh.md @@ -48,7 +48,8 @@ order: 0 | onDataCellTrendIconClick | 数值单元格的趋势图 icon 点击事件 | (meta: [ViewMeta](/docs/api/basic-class/node)) => void | | | | onDataCellBrushSelection | 数值单元格刷选事件 | ( dataCells: [DataCell](/docs/api/basic-class/base-cell)[] ) => void | | | | onDataCellSelectMove | 数值单元格键盘方向键移动事件 | (metas: CellMeta[]) => void | | | -| onDataCellEditEnd | 数值单元格编辑完成(暂只支持编辑表) | (meta: [ViewMeta](/docs/api/basic-class/node)) => void | | | +| onDataCellEditStart | 数值单元格编辑开始(暂只支持编辑表) | (meta: [ViewMeta](/docs/api/basic-class/node), cell: [S2CellType](/docs/api/basic-class/base-cell)) => void | | | +| onDataCellEditEnd | 数值单元格编辑完成(暂只支持编辑表) | (meta: [ViewMeta](/docs/api/basic-class/node), cell: [S2CellType](/docs/api/basic-class/base-cell)) => void | | | | onCornerCellHover | 角头鼠标悬停事件 | (data: [TargetCellInfo](#targetcellinfo)) => void | | | | onCornerCellClick | 角头鼠标单击事件 | (data: [TargetCellInfo](#targetcellinfo)) => void | | | | onCornerCellDoubleClick | 角头鼠标双击事件 | (data: [TargetCellInfo](#targetcellinfo)) => void | | | diff --git a/s2-site/docs/manual/basic/analysis/editable-mode.zh.md b/s2-site/docs/manual/basic/analysis/editable-mode.zh.md index 813be1d588..165468e54b 100644 --- a/s2-site/docs/manual/basic/analysis/editable-mode.zh.md +++ b/s2-site/docs/manual/basic/analysis/editable-mode.zh.md @@ -167,8 +167,11 @@ ReactDOM.render( sheetType="editable" // 此处指定 sheetType 为 editable dataCfg={s2DataCfg} options={s2Options} - onDataCellEditEnd={(meta) => { - console.log('onDataCellEditEnd', meta); + onDataCellEditStart={(meta, cell) => { + console.log('onDataCellEditStart:', meta, cell); + }} + onDataCellEditEnd={(meta, cell) => { + console.log('onDataCellEditEnd:', meta, cell); }} />, document.getElementById('container') diff --git a/s2-site/examples/react-component/sheet/demo/editable.tsx b/s2-site/examples/react-component/sheet/demo/editable.tsx index fd3025d678..eb053dbfb4 100644 --- a/s2-site/examples/react-component/sheet/demo/editable.tsx +++ b/s2-site/examples/react-component/sheet/demo/editable.tsx @@ -44,8 +44,12 @@ fetch('https://assets.antv.antgroup.com/s2/basic-table-mode.json') frozenTrailingColCount: 1, // 列尾冻结数量 }; - const onDataCellEditEnd = (meta) => { - console.log('onDataCellEditEnd', meta); + const onDataCellEditStart = (meta, cell) => { + console.log('onDataCellEditStart:', meta, cell); + }; + + const onDataCellEditEnd = (meta, cell) => { + console.log('onDataCellEditEnd:', meta, cell); }; ReactDOM.render( @@ -53,6 +57,7 @@ fetch('https://assets.antv.antgroup.com/s2/basic-table-mode.json') dataCfg={s2DataConfig} options={s2Options} sheetType="editable" + onDataCellEditStart={onDataCellEditStart} onDataCellEditEnd={onDataCellEditEnd} />, document.getElementById('container'),