@nocobase/client-v2 2.1.0-alpha.24 → 2.1.0-alpha.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,12 +14,11 @@ import { useTranslation } from 'react-i18next';
14
14
  import { PlusOutlined } from '@ant-design/icons';
15
15
  import React, { useEffect, useMemo, useState } from 'react';
16
16
  import { ActionWithoutPermission } from '../../../base/ActionModel';
17
- import { getRowKey } from '../../../blocks/table/utils';
17
+ import { getSubTableRowIdentity, normalizeSubTableRows } from './rowIdentity';
18
18
 
19
19
  export function SubTableField(props) {
20
20
  const { t } = useTranslation();
21
21
  const {
22
- value = [],
23
22
  onChange,
24
23
  columns,
25
24
  disabled,
@@ -39,12 +38,25 @@ export function SubTableField(props) {
39
38
  } = props;
40
39
  const [currentPage, setCurrentPage] = useState(1);
41
40
  const [currentPageSize, setCurrentPageSize] = useState(pageSize);
41
+ const rawCurrentValue = getCurrentValue();
42
+ const currentValue = useMemo(() => normalizeSubTableRows(rawCurrentValue), [rawCurrentValue]);
43
+ const getRecordIdentity = React.useCallback(
44
+ (record: any) => getSubTableRowIdentity(record, filterTargetKey),
45
+ [filterTargetKey],
46
+ );
42
47
  useEffect(() => {
43
48
  setCurrentPageSize(pageSize);
44
49
  }, [pageSize]);
45
50
  useEffect(() => {
46
51
  resetPage && setCurrentPage(1);
47
52
  }, [resetPage]);
53
+ const applyValue = React.useCallback((nextValue: any) => onChange?.(normalizeSubTableRows(nextValue)), [onChange]);
54
+ const getLatestValue = React.useCallback(() => normalizeSubTableRows(getCurrentValue()), [getCurrentValue]);
55
+ useEffect(() => {
56
+ if (currentValue !== rawCurrentValue) {
57
+ applyValue(currentValue);
58
+ }
59
+ }, [applyValue, currentValue, rawCurrentValue]);
48
60
 
49
61
  // 前端分页
50
62
  const pagination = useMemo(() => {
@@ -56,7 +68,7 @@ export function SubTableField(props) {
56
68
  },
57
69
  current: currentPage,
58
70
  pageSize: currentPageSize,
59
- total: value?.length,
71
+ total: currentValue.length,
60
72
  onChange: (page, size) => {
61
73
  setCurrentPage(page);
62
74
  setCurrentPageSize(size);
@@ -66,38 +78,36 @@ export function SubTableField(props) {
66
78
  return t('Total {{count}} items', { count: total });
67
79
  },
68
80
  } as any;
69
- }, [currentPage, currentPageSize, value]);
81
+ }, [currentPage, currentPageSize, currentValue.length]);
70
82
 
71
83
  // 新增一行
72
84
  const handleAdd = () => {
73
85
  if (allowCreate === false) return;
74
86
 
75
- const currentValue = getCurrentValue();
76
87
  const newRow = {
77
88
  __is_new__: true,
78
89
  };
79
90
  columns.forEach((col) => {
80
91
  newRow[col.dataIndex] = undefined;
81
92
  });
82
- const newValue = [...currentValue, newRow];
93
+ const newValue = [...getLatestValue(), newRow];
83
94
  setCurrentPage(Math.ceil(newValue.length / currentPageSize));
84
- onChange?.(newValue);
95
+ applyValue(newValue);
85
96
  };
86
97
 
87
98
  // 删除行
88
99
  const handleDelete = (index: number) => {
89
- const newValue = [...getCurrentValue()];
100
+ const newValue = [...getLatestValue()];
90
101
  newValue.splice(index, 1);
91
102
  const lastPage = Math.ceil(newValue.length / currentPageSize);
92
103
  setCurrentPage(currentPage > lastPage ? lastPage : currentPage);
93
- onChange?.(newValue);
104
+ applyValue(newValue);
94
105
  };
95
106
 
96
107
  // 编辑单元格
97
108
  const handleCellChange = (rowIdx, dataIndex, cellValue) => {
98
- const currentValue = getCurrentValue();
99
- const newData = currentValue.map((row, idx) => (idx === rowIdx ? { ...row, [dataIndex]: cellValue } : row));
100
- onChange?.(newData);
109
+ const newData = getLatestValue().map((row, idx) => (idx === rowIdx ? { ...row, [dataIndex]: cellValue } : row));
110
+ applyValue(newData);
101
111
  };
102
112
 
103
113
  // 渲染可编辑单元格
@@ -106,20 +116,25 @@ export function SubTableField(props) {
106
116
  ...col,
107
117
  render: (text, record, rowIdx) => {
108
118
  const pageRowIdx = (currentPage - 1) * currentPageSize + rowIdx;
119
+ const rowIdentity = getRecordIdentity(record) ?? `row:${pageRowIdx}`;
120
+ // row identity keeps logical rows stable, while binding key still follows
121
+ // the current array index so Form.Item can rebind after reordering/removal.
122
+ const rowBindingKey = `${rowIdentity}:${pageRowIdx}`;
123
+ const columnKey = col.dataIndex ?? col.key ?? 'cell';
109
124
  if (!col.render) {
110
125
  return;
111
126
  }
112
- return col?.render({
127
+ return col.render({
113
128
  record,
114
129
  rowIdx: pageRowIdx,
115
- id: `field-${col.dataIndex}-${rowIdx}`,
130
+ id: `field-${String(columnKey)}-${rowBindingKey}`,
116
131
  value: text,
117
132
  parentFieldIndex,
118
133
  parentItem,
119
134
  onChange: (value) => {
120
- handleCellChange(pageRowIdx, col.dataIndex, value?.target?.value || value);
135
+ handleCellChange(pageRowIdx, col.dataIndex, value?.target?.value ?? value);
121
136
  },
122
- ['aria-describedby']: `field-${col.dataIndex}-${rowIdx}`,
137
+ ['aria-describedby']: `field-${String(columnKey)}-${rowBindingKey}`,
123
138
  });
124
139
  },
125
140
  }))
@@ -137,8 +152,17 @@ export function SubTableField(props) {
137
152
  }
138
153
  return (
139
154
  <div
155
+ onMouseDown={(event) => {
156
+ const activeElement = document.activeElement as HTMLElement | null;
157
+ if (!activeElement || event.currentTarget.contains(activeElement)) {
158
+ return;
159
+ }
160
+ activeElement.blur?.();
161
+ }}
140
162
  onClick={() => {
141
- handleDelete(pageRowIdx);
163
+ setTimeout(() => {
164
+ handleDelete(pageRowIdx);
165
+ });
142
166
  }}
143
167
  >
144
168
  <CloseOutlined style={{ cursor: 'pointer', color: 'gray' }} />
@@ -150,17 +174,17 @@ export function SubTableField(props) {
150
174
  .filter(Boolean);
151
175
 
152
176
  const pagedDataSource = useMemo(() => {
153
- if (!value?.length) return [];
177
+ if (!currentValue.length) return [];
154
178
 
155
179
  const start = (currentPage - 1) * currentPageSize;
156
- return value.slice(start, start + currentPageSize);
157
- }, [value, currentPage, currentPageSize]);
180
+ return currentValue.slice(start, start + currentPageSize);
181
+ }, [currentValue, currentPage, currentPageSize]);
158
182
  return (
159
183
  <Form.Item>
160
184
  <Table
161
185
  dataSource={pagedDataSource}
162
186
  columns={editableColumns}
163
- rowKey={(record) => getRowKey(record, filterTargetKey)}
187
+ rowKey={(record) => getRecordIdentity(record) ?? ''}
164
188
  tableLayout="fixed"
165
189
  scroll={{ x: 'max-content' }}
166
190
  pagination={pagination}
@@ -179,7 +203,7 @@ export function SubTableField(props) {
179
203
  </span>
180
204
  ),
181
205
  }}
182
- components={components || {}}
206
+ components={components ?? {}}
183
207
  className={css`
184
208
  .ant-table-cell-ellipsis.ant-table-cell-fix-right-first .ant-table-cell-content {
185
209
  display: inline;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { uid } from '@formily/shared';
11
+
12
+ type FilterTargetKey = string | string[] | null | undefined;
13
+ type SubTableRow = {
14
+ __is_new__?: boolean;
15
+ id?: any;
16
+ [key: string]: any;
17
+ };
18
+
19
+ export const SUB_TABLE_TEMP_ROW_KEY = '__index__';
20
+
21
+ function getPersistedRowKey(record: SubTableRow, filterTargetKey: FilterTargetKey) {
22
+ if (!filterTargetKey) return null;
23
+
24
+ if (Array.isArray(filterTargetKey)) {
25
+ const values = filterTargetKey.map((k) => record?.[k]);
26
+ if (values.some((v) => v == null)) return null;
27
+ return values.map((v) => String(v)).join('__');
28
+ }
29
+
30
+ const value = record?.[filterTargetKey];
31
+ return value == null ? null : String(value);
32
+ }
33
+
34
+ export function getSubTableRowIdentity(record: SubTableRow, filterTargetKey: FilterTargetKey) {
35
+ const tempKey = record?.[SUB_TABLE_TEMP_ROW_KEY];
36
+ if (record?.__is_new__ && tempKey != null && tempKey !== '') {
37
+ return `tmp:${String(tempKey)}`;
38
+ }
39
+
40
+ const persistedKey = getPersistedRowKey(record, filterTargetKey);
41
+ if (persistedKey != null) {
42
+ return `pk:${persistedKey}`;
43
+ }
44
+
45
+ if (tempKey != null && tempKey !== '') {
46
+ return `tmp:${String(tempKey)}`;
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ export function normalizeSubTableRows(rows: SubTableRow[]) {
53
+ if (!rows.length) return rows;
54
+
55
+ let changed = false;
56
+ const normalized = rows.map((row) => {
57
+ const tempKey = row?.[SUB_TABLE_TEMP_ROW_KEY];
58
+ if (!row.__is_new__ || (tempKey != null && tempKey !== '')) {
59
+ return row;
60
+ }
61
+
62
+ changed = true;
63
+ return {
64
+ ...row,
65
+ [SUB_TABLE_TEMP_ROW_KEY]: uid(),
66
+ };
67
+ });
68
+
69
+ return changed ? normalized : rows;
70
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+ import {
12
+ getSubTableRowIdentity,
13
+ normalizeSubTableRows,
14
+ SUB_TABLE_TEMP_ROW_KEY,
15
+ } from '../SubTableFieldModel/rowIdentity';
16
+
17
+ describe('SubTable row identity', () => {
18
+ it('keeps temp identity for unsaved rows and fills it when missing', () => {
19
+ const rows = [
20
+ {
21
+ __is_new__: true,
22
+ [SUB_TABLE_TEMP_ROW_KEY]: 'tmp-1',
23
+ name: 'admin',
24
+ },
25
+ {
26
+ __is_new__: true,
27
+ [SUB_TABLE_TEMP_ROW_KEY]: 'tmp-existing',
28
+ title: 'kept',
29
+ },
30
+ {
31
+ __is_new__: true,
32
+ title: 'needs-temp-key',
33
+ },
34
+ ];
35
+
36
+ expect(getSubTableRowIdentity(rows[0], 'name')).toBe('tmp:tmp-1');
37
+
38
+ const normalized = normalizeSubTableRows(rows);
39
+
40
+ expect(normalized[1]).toBe(rows[1]);
41
+ expect(normalized[2]).not.toBe(rows[2]);
42
+ expect(normalized[2][SUB_TABLE_TEMP_ROW_KEY]).toBeTruthy();
43
+ expect(getSubTableRowIdentity(normalized[2], 'id')).toMatch(/^tmp:/);
44
+ });
45
+ });