This is an automated email from the ASF dual-hosted git repository.
rebenitez1802 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new c016883970d fix(dataset): preserve pending metric and column edits
(#45506)
c016883970d is described below
commit c016883970daa20b9eab8084c7ff2e29549200a5
Author: Rafael Benitez <[email protected]>
AuthorDate: Fri Oct 23 12:01:04 2026 -0300
fix(dataset): preserve pending metric and column edits (#45506)
---
.../DatasourceModal.pendingEdits.test.tsx | 196 ++++++++++++++
.../CollectionTable.debounce.test.tsx | 287 +++++++++++++++++++++
.../components/CollectionTable/index.tsx | 231 ++++++-----------
.../src/components/Datasource/types.ts | 10 -
4 files changed, 562 insertions(+), 162 deletions(-)
diff --git
a/superset-frontend/src/components/Datasource/DatasourceModal/DatasourceModal.pendingEdits.test.tsx
b/superset-frontend/src/components/Datasource/DatasourceModal/DatasourceModal.pendingEdits.test.tsx
new file mode 100644
index 00000000000..076e0e99b39
--- /dev/null
+++
b/superset-frontend/src/components/Datasource/DatasourceModal/DatasourceModal.pendingEdits.test.tsx
@@ -0,0 +1,196 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { ComponentProps } from 'react';
+import fetchMock from 'fetch-mock';
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ userEvent,
+ waitFor,
+ within,
+} from 'spec/helpers/testing-library';
+import mockDatasource from 'spec/fixtures/mockDatasource';
+import { Constants } from '@superset-ui/core/components';
+import {
+ cleanupAsyncOperations,
+ setupDatasourceEditorMocks,
+} from
'src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.utils';
+import DatasourceModal from 'src/components/Datasource/DatasourceModal';
+
+const datasetEndpoint = 'glob:*/api/v1/dataset/7';
+const saveEndpoint = 'glob:*/api/v1/dataset/7?override_columns=*';
+
+type SavedItem = {
+ metric_name?: string;
+ column_name?: string;
+ expression?: string;
+};
+
+type SavePayload = { metrics: SavedItem[]; columns: SavedItem[] };
+
+type AceNode = HTMLElement & {
+ env: {
+ editor: { getValue: () => string; setValue: (value: string) => void };
+ };
+};
+
+/** Flushes the real controls' debounces while leaving their callbacks intact.
*/
+async function settleInputs() {
+ await act(async () => {
+ await new Promise(resolve =>
+ setTimeout(resolve, Math.max(Constants.FAST_DEBOUNCE, 300) + 25),
+ );
+ });
+}
+
+beforeEach(() => {
+ jest.useRealTimers();
+ fetchMock.clearHistory().removeRoutes();
+ setupDatasourceEditorMocks();
+});
+
+afterEach(async () => {
+ cleanup();
+ await cleanupAsyncOperations();
+ fetchMock.clearHistory().removeRoutes();
+});
+
+test.each(['metric', 'calculated column'] as const)(
+ 'preserves a new %s and the save payload when an earlier row edit is
pending',
+ async itemKind => {
+ const isMetric = itemKind === 'metric';
+ const placeholder = isMetric ? '<new metric>' : '<new column>';
+ const itemName = isMetric ? 'new_metric' : 'new_calculated_column';
+ const expression = isMetric ? 'SUM(num)' : 'num + 2';
+ const existingItemName = isMetric
+ ? mockDatasource['7__table'].metrics[0].metric_name
+ : 'existing_calculated_column';
+ const existingExpression = isMetric ? 'SUM(num) + 1' : 'num + 3';
+ const tabName = isMetric ? /metrics/i : /calculated columns/i;
+ fetchMock.put(saveEndpoint, {});
+ fetchMock.get(datasetEndpoint, { result: mockDatasource['7__table'] });
+ const props = {
+ datasource: {
+ ...mockDatasource['7__table'],
+ metrics: mockDatasource['7__table'].metrics.slice(0, 1),
+ columns: [
+ mockDatasource['7__table'].columns[0],
+ {
+ ...mockDatasource['7__table'].columns[1],
+ column_name: 'existing_calculated_column',
+ expression: 'num + 1',
+ },
+ ],
+ },
+ etag: '"dataset-v1"',
+ show: true,
+ onHide: jest.fn(),
+ onDatasourceSave: jest.fn(),
+ onChange: jest.fn(),
+ addSuccessToast: jest.fn(),
+ addDangerToast: jest.fn(),
+ } as unknown as ComponentProps<typeof DatasourceModal>;
+ render(<DatasourceModal {...props} />, {
+ useRedux: true,
+ initialState: { common: { currencies: ['USD'] } },
+ useRouter: true,
+ });
+ await userEvent.click(await screen.findByRole('tab', { name: tabName }));
+ const panel = screen.getByRole('tabpanel', { name: tabName });
+ await userEvent.click(
+ within(panel).getByRole('button', { name: /expand row/i }),
+ );
+ const existingExpandedRow = within(panel).getByRole('row', {
+ name: /^SQL expression/i,
+ });
+ await waitFor(() =>
+ expect(
+ existingExpandedRow.querySelector('.ace_editor'),
+ ).toBeInTheDocument(),
+ );
+ const existingEditor =
+ existingExpandedRow.querySelector<AceNode>('.ace_editor');
+ fireEvent.change(await within(panel).findByPlaceholderText('Description'),
{
+ target: { value: 'Updated existing item' },
+ });
+ act(() => existingEditor?.env.editor.setValue(existingExpression));
+ fireEvent.click(within(panel).getByRole('button', { name: /add item/i }));
+ expect(within(panel).getByDisplayValue(placeholder)).toBeVisible();
+ await settleInputs();
+ expect(within(panel).getByDisplayValue(placeholder)).toBeVisible();
+
+ await userEvent.click(within(panel).getByDisplayValue(placeholder));
+ // EditableTitle changes its wrapper when entering edit mode.
+ const nameInput = within(panel).getByDisplayValue(placeholder);
+ fireEvent.change(nameInput, { target: { value: itemName } });
+ fireEvent.blur(nameInput);
+ await settleInputs();
+ await userEvent.click(
+ within(
+ within(panel).getByRole('row', { name: new RegExp(existingItemName) }),
+ ).getByRole('button', { name: /collapse row/i }),
+ );
+ if (isMetric) {
+ await userEvent.click(
+ within(
+ within(panel).getByRole('row', { name: new RegExp(itemName) }),
+ ).getByRole('button', { name: /expand row/i }),
+ );
+ }
+ const expandedRow = within(panel).getByRole('row', {
+ name: /^SQL expression/i,
+ });
+ await waitFor(() =>
+ expect(expandedRow.querySelector('.ace_editor')).toBeInTheDocument(),
+ );
+ const editor = expandedRow.querySelector<AceNode>('.ace_editor');
+ act(() => editor?.env.editor.setValue(expression));
+ await settleInputs();
+
+ await userEvent.click(screen.getByTestId('datasource-modal-save'));
+ await userEvent.click(
+ await screen.findByRole('button', { name: 'Confirm' }),
+ );
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls(saveEndpoint)).toHaveLength(1),
+ );
+ const [request] = fetchMock.callHistory.calls(saveEndpoint);
+ const payload = JSON.parse(String(request.options.body)) as SavePayload;
+ const items = isMetric
+ ? payload.metrics
+ : payload.columns.filter(column => column.expression);
+ expect(items).toHaveLength(2);
+ expect(items).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ [isMetric ? 'metric_name' : 'column_name']: itemName,
+ expression,
+ }),
+ expect.objectContaining({
+ [isMetric ? 'metric_name' : 'column_name']: existingItemName,
+ description: 'Updated existing item',
+ expression: existingExpression,
+ }),
+ ]),
+ );
+ },
+);
diff --git
a/superset-frontend/src/components/Datasource/components/CollectionTable/CollectionTable.debounce.test.tsx
b/superset-frontend/src/components/Datasource/components/CollectionTable/CollectionTable.debounce.test.tsx
new file mode 100644
index 00000000000..cf7c3e10f19
--- /dev/null
+++
b/superset-frontend/src/components/Datasource/components/CollectionTable/CollectionTable.debounce.test.tsx
@@ -0,0 +1,287 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ within,
+ waitFor,
+} from 'spec/helpers/testing-library';
+import { Constants } from '@superset-ui/core/components';
+import TextControl from 'src/explore/components/controls/TextControl';
+import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
+import Field from '../Field';
+import Fieldset from '../Fieldset';
+import CollectionTable from '.';
+
+const initialRows = [
+ { id: 1, column_name: 'first', description: '', verbose_name: '' },
+ { id: 2, column_name: 'second', description: '', verbose_name: '' },
+];
+
+const tableProps = {
+ collection: initialRows,
+ tableColumns: ['column_name', 'description', 'verbose_name'],
+ sortColumns: ['column_name'],
+ allowAddItem: true,
+ allowDeletes: true,
+ itemGenerator: () => ({
+ id: 3,
+ column_name: 'added',
+ description: '',
+ verbose_name: '',
+ }),
+ expandFieldset: (
+ <Fieldset>
+ <Field
+ fieldKey="description"
+ label="Description"
+ control={<TextControl placeholder="Description" />}
+ />
+ <Field
+ fieldKey="verbose_name"
+ label="Label"
+ control={<TextControl placeholder="Label" />}
+ />
+ </Fieldset>
+ ),
+};
+
+/** Commits pending text edits together, as controls can do within one render.
*/
+function flushEdits() {
+ act(() => jest.advanceTimersByTime(Constants.FAST_DEBOUNCE));
+}
+
+beforeEach(() => jest.useFakeTimers());
+afterEach(() => jest.useRealTimers());
+
+test('a pending expanded-row edit preserves an added row and its order', () =>
{
+ const onChange = jest.fn();
+ render(<CollectionTable {...tableProps} onChange={onChange} />);
+ fireEvent.click(screen.getAllByRole('button', { name: /expand row/i })[0]);
+ fireEvent.change(screen.getByPlaceholderText('Description'), {
+ target: { value: 'edited' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: /add item/i }));
+ flushEdits();
+
+ expect(screen.getByText('added')).toBeVisible();
+ expect(onChange).toHaveBeenLastCalledWith([
+ expect.objectContaining({ id: 3 }),
+ expect.objectContaining({ id: 1, description: 'edited' }),
+ expect.objectContaining({ id: 2 }),
+ ]);
+});
+
+test('a pending expanded-row edit does not restore another deleted row', () =>
{
+ const onChange = jest.fn();
+ render(<CollectionTable {...tableProps} onChange={onChange} />);
+ fireEvent.click(screen.getAllByRole('button', { name: /expand row/i })[0]);
+ fireEvent.change(screen.getByPlaceholderText('Description'), {
+ target: { value: 'edited' },
+ });
+ const deletedRow = screen.getByRole('row', { name: /second/i });
+ fireEvent.click(
+ within(deletedRow).getByRole('button', {
+ name: /delete item/i,
+ }),
+ );
+ flushEdits();
+
+ expect(screen.queryByText('second')).not.toBeInTheDocument();
+ expect(onChange).toHaveBeenLastCalledWith([
+ expect.objectContaining({ id: 1, description: 'edited' }),
+ ]);
+});
+
+test('pending sibling fields and rows all survive one debounce batch', () => {
+ const onChange = jest.fn();
+ render(<CollectionTable {...tableProps} onChange={onChange} />);
+ screen
+ .getAllByRole('button', { name: /expand row/i })
+ .forEach(button => fireEvent.click(button));
+ fireEvent.change(screen.getAllByPlaceholderText('Description')[0], {
+ target: { value: 'first description' },
+ });
+ fireEvent.change(screen.getAllByPlaceholderText('Label')[0], {
+ target: { value: 'first label' },
+ });
+ fireEvent.change(screen.getAllByPlaceholderText('Description')[1], {
+ target: { value: 'second description' },
+ });
+ flushEdits();
+
+ expect(onChange).toHaveBeenLastCalledWith([
+ expect.objectContaining({
+ id: 1,
+ description: 'first description',
+ verbose_name: 'first label',
+ }),
+ expect.objectContaining({ id: 2, description: 'second description' }),
+ ]);
+});
+
+test('a pending edit preserves externally refreshed values, membership and
order', () => {
+ const onChange = jest.fn();
+ const { rerender } = render(
+ <CollectionTable {...tableProps} onChange={onChange} />,
+ );
+ fireEvent.click(screen.getAllByRole('button', { name: /expand row/i })[0]);
+ fireEvent.change(screen.getByPlaceholderText('Description'), {
+ target: { value: 'edited' },
+ });
+ const refreshed = [
+ { id: 4, column_name: 'external', description: '', verbose_name: '' },
+ { ...initialRows[0], verbose_name: 'refreshed label' },
+ ];
+ onChange.mockClear();
+ rerender(
+ <CollectionTable
+ {...tableProps}
+ collection={refreshed}
+ onChange={onChange}
+ />,
+ );
+ expect(onChange).not.toHaveBeenCalled();
+ flushEdits();
+
+ expect(screen.getByText('external')).toBeVisible();
+ expect(screen.queryByText('second')).not.toBeInTheDocument();
+ expect(onChange).toHaveBeenLastCalledWith([
+ expect.objectContaining({ id: 4 }),
+ expect.objectContaining({
+ id: 1,
+ description: 'edited',
+ verbose_name: 'refreshed label',
+ }),
+ ]);
+});
+
+test('an asynchronous field callback cannot resurrect the row it edited', ()
=> {
+ let pendingChange: (() => void) | undefined;
+ /** Models a control whose asynchronous edit can finish after it unmounts. */
+ function AsyncControl({ onChange }: { onChange?: (value: string) => void }) {
+ return (
+ <button
+ type="button"
+ onClick={() => {
+ pendingChange = () => onChange?.('late edit');
+ }}
+ >
+ Queue edit
+ </button>
+ );
+ }
+ const onChange = jest.fn();
+ render(
+ <CollectionTable
+ {...tableProps}
+ onChange={onChange}
+ expandFieldset={
+ <Fieldset>
+ <Field
+ fieldKey="description"
+ label="Description"
+ control={<AsyncControl />}
+ />
+ </Fieldset>
+ }
+ />,
+ );
+ fireEvent.click(screen.getAllByRole('button', { name: /expand row/i })[0]);
+ fireEvent.click(screen.getByRole('button', { name: 'Queue edit' }));
+ const deletedRow = screen.getByRole('row', { name: /first/i });
+ fireEvent.click(
+ within(deletedRow).getByRole('button', {
+ name: /delete item/i,
+ }),
+ );
+ onChange.mockClear();
+ act(() => pendingChange?.());
+
+ expect(screen.queryByText('first')).not.toBeInTheDocument();
+ expect(onChange).not.toHaveBeenCalled();
+});
+
+test.each([
+ 'expand another row',
+ 'add a row',
+ 'receive a fresh collection array',
+] as const)(
+ 'a pending SQL expression survives when you %s before its debounce fires',
+ async action => {
+ const onChange = jest.fn();
+ const sqlProps = {
+ ...tableProps,
+ onChange,
+ expandFieldset: (
+ <Fieldset>
+ <Field
+ fieldKey="expression"
+ label="SQL expression"
+ control={
+ <TextAreaControl
+ language="sql"
+ offerEditInModal={false}
+ debounceDelay={300}
+ />
+ }
+ />
+ </Fieldset>
+ ),
+ };
+ const { container, rerender } = render(<CollectionTable {...sqlProps} />);
+ fireEvent.click(
+ within(screen.getByRole('row', { name: /first/i })).getByRole('button', {
+ name: /expand row/i,
+ }),
+ );
+ await waitFor(() =>
+ expect(container.querySelector('.ace_editor')).toBeInTheDocument(),
+ );
+ // Ace exposes its live editor on the container; setValue fires the same
+ // change event as typing, without relying on jsdom's text layout.
+ const editor = container.querySelector<
+ HTMLElement & { env: { editor: { setValue: (value: string) => void } } }
+ >('.ace_editor');
+ act(() => editor?.env.editor.setValue('num + 1'));
+ expect(onChange).not.toHaveBeenCalled();
+
+ if (action === 'expand another row') {
+ fireEvent.click(
+ within(screen.getByRole('row', { name: /second/i })).getByRole(
+ 'button',
+ { name: /expand row/i },
+ ),
+ );
+ } else if (action === 'add a row') {
+ fireEvent.click(screen.getByRole('button', { name: /add item/i }));
+ } else {
+ rerender(<CollectionTable {...sqlProps} collection={[...initialRows]}
/>);
+ }
+ act(() => jest.advanceTimersByTime(300));
+
+ expect(onChange).toHaveBeenLastCalledWith([
+ ...(action === 'add a row' ? [expect.objectContaining({ id: 3 })] : []),
+ expect.objectContaining({ id: 1, expression: 'num + 1' }),
+ expect.objectContaining({ id: 2 }),
+ ]);
+ },
+);
diff --git
a/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
b/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
index 2b0eec42686..362d1fd2278 100644
---
a/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
+++
b/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
@@ -36,7 +36,7 @@ import Table, {
type TablePaginationConfig,
TableSize,
} from '@superset-ui/core/components/Table';
-import Fieldset from '../Fieldset';
+import Fieldset, { type FieldsetProps } from '../Fieldset';
import { recurseReactClone } from '../../utils';
import {
type CRUDCollectionProps,
@@ -70,15 +70,7 @@ function createKeyedCollection(arr: Array<object>) {
}) as CollectionItem,
);
- const collection: Record<PropertyKey, CollectionItem> = {};
- collectionArray.forEach((o: CollectionItem) => {
- collection[o.id] = o;
- });
-
- return {
- collection,
- collectionArray,
- };
+ return collectionArray;
}
export default function CRUDCollection({
@@ -103,18 +95,14 @@ export default function CRUDCollection({
const [expandedColumns, setExpandedColumns] = useState<
Record<PropertyKey, boolean>
>({});
- // Seed both pieces of state from a single createKeyedCollection() pass so
- // that items lacking an `id` get one consistent set of synthetic ids
- // (matching the prior class component, which keyed the collection once).
+ // Seed ids for the first render; the props-sync effect re-keys on each
+ // collection prop change.
const initialKeyed = useRef<ReturnType<typeof createKeyedCollection>>();
if (!initialKeyed.current) {
initialKeyed.current = createKeyedCollection(propsCollection);
}
- const [collection, setCollection] = useState<
- Record<PropertyKey, CollectionItem>
- >(() => initialKeyed.current!.collection);
const [collectionArray, setCollectionArray] = useState<CollectionItem[]>(
- () => initialKeyed.current!.collectionArray,
+ () => initialKeyed.current!,
);
const [sortColumn, setSortColumn] = useState<string>('');
const [sort, setSort] = useState<SortOrderEnum>(SortOrderEnum.Unsorted);
@@ -132,20 +120,45 @@ export default function CRUDCollection({
// edits whose onChange round trip to the parent hasn't landed back in
// props yet.
const unsortedOrderRef = useRef<Array<string | number>>(
- initialKeyed.current!.collectionArray.map(item => item.id),
+ initialKeyed.current!.map(item => item.id),
);
const sortRef = useRef<SortOrderEnum>(SortOrderEnum.Unsorted);
sortRef.current = sort;
const collectionArrayRef = useRef<CollectionItem[]>(collectionArray);
- collectionArrayRef.current = collectionArray;
+ const onChangeRef = useRef(onChange);
+ onChangeRef.current = onChange;
+ const fieldChangeHandlers = useRef(
+ new Map<
+ CollectionItem['id'],
+ NonNullable<FieldsetProps['onFieldChange']>
+ >(),
+ );
+
+ const setCurrentCollection = useCallback((items: CollectionItem[]) => {
+ // Advance before notifying the parent so queued edits share the same rows.
+ collectionArrayRef.current = items;
+ const ids = new Set(items.map(item => item.id));
+ fieldChangeHandlers.current.forEach((_handler, id) => {
+ if (!ids.has(id)) fieldChangeHandlers.current.delete(id);
+ });
+ if (sortRef.current === SortOrderEnum.Unsorted) {
+ unsortedOrderRef.current = items.map(item => item.id);
+ }
+ setCollectionArray(items);
+ }, []);
+
+ const changeCollection = useCallback(
+ (items: CollectionItem[]) => {
+ setCurrentCollection(items);
+ onChangeRef.current?.(items);
+ },
+ [setCurrentCollection],
+ );
// Sync with props.collection changes
useEffect(() => {
- const { collection: newCollection, collectionArray: newCollectionArray } =
- createKeyedCollection(propsCollection);
- setCollection(newCollection);
- setCollectionArray(newCollectionArray);
+ const newCollectionArray = createKeyedCollection(propsCollection);
// Refresh the restore order too, so that clearing a sort after an
// external sync (e.g. a source-control-synced column set) reflects the
// synced order and row set instead of stale, pre-sync ids.
@@ -162,102 +175,28 @@ export default function CRUDCollection({
if (!isEcho) {
unsortedOrderRef.current = newIds;
}
- }, [propsCollection]);
-
- useEffect(() => {
- if (sort === SortOrderEnum.Unsorted) {
- unsortedOrderRef.current = collectionArray.map(item => item.id);
- }
- }, [collectionArray, sort]);
+ setCurrentCollection(newCollectionArray);
+ }, [propsCollection, setCurrentCollection]);
const onCellChange = useCallback(
- (id: string | number, col: string, val: unknown) => {
- setCollection(prevCollection => {
- const updatedCollection = {
- ...prevCollection,
- [id]: {
- ...prevCollection[id],
- [col]: val,
- },
- };
- return updatedCollection;
- });
-
- setCollectionArray(prevCollectionArray => {
- const updatedCollectionArray = prevCollectionArray.map(item => {
- if (item.id === id) {
- return {
- ...item,
- [col]: val,
- };
- }
- return item;
- });
-
- if (onChange) {
- onChange(updatedCollectionArray);
- }
-
- return updatedCollectionArray;
- });
- },
- [onChange],
- );
-
- const changeCollection = useCallback(
- (
- newCollection: Record<PropertyKey, CollectionItem>,
- currentCollectionArray: CollectionItem[],
- ) => {
- // Preserve existing order instead of recreating from Object.keys()
- const existingIds = new Set(currentCollectionArray.map(item => item.id));
- const newCollectionArray: CollectionItem[] = [];
-
- // First pass: preserve existing order and update items
- for (const existingItem of currentCollectionArray) {
- if (newCollection[existingItem.id]) {
- newCollectionArray.push(newCollection[existingItem.id]);
- }
- }
-
- // Second pass: add new items
- for (const item of Object.values(newCollection)) {
- if (!existingIds.has(item.id)) {
- newCollectionArray.push(item);
- }
- }
-
- setCollection(newCollection);
- setCollectionArray(newCollectionArray);
-
- if (onChange) {
- onChange(newCollectionArray);
- }
+ (id: string | number, col: string | number, val: unknown) => {
+ const items = collectionArrayRef.current;
+ // A delayed control callback must not restore a deleted row.
+ if (!items.some(item => item.id === id)) return;
+ changeCollection(
+ items.map(item => (item.id === id ? { ...item, [col]: val } : item)),
+ );
},
- [onChange],
+ [changeCollection],
);
const deleteItem = useCallback(
(id: string | number) => {
- setCollection(prevCollection => {
- const newColl = { ...prevCollection };
- delete newColl[id];
- return newColl;
- });
-
- setCollectionArray(prevCollectionArray => {
- const newCollectionArray = prevCollectionArray.filter(
- item => item.id !== id,
- );
-
- if (onChange) {
- onChange(newCollectionArray);
- }
-
- return newCollectionArray;
- });
+ changeCollection(
+ collectionArrayRef.current.filter(item => item.id !== id),
+ );
},
- [onChange],
+ [changeCollection],
);
const onAddItem = useCallback(() => {
@@ -269,39 +208,13 @@ export default function CRUDCollection({
}
delete newItem.expanded;
- setCollection(prevCollection => ({
- ...prevCollection,
- [newItem.id]: newItem,
- }));
-
- setCollectionArray(prevCollectionArray => {
- const newCollectionArray = [newItem, ...prevCollectionArray];
-
- if (onChange) {
- onChange(newCollectionArray);
- }
-
- return newCollectionArray;
- });
+ changeCollection([newItem, ...collectionArrayRef.current]);
if (shouldStartExpanded) {
setExpandedColumns(prev => ({ ...prev, [newItem.id]: true }));
}
}
- }, [itemGenerator, onChange]);
-
- const onFieldsetChange = useCallback(
- (item: CollectionItem) => {
- changeCollection(
- {
- ...collection,
- [item.id]: item,
- },
- collectionArray,
- );
- },
- [changeCollection, collection, collectionArray],
- );
+ }, [itemGenerator, changeCollection]);
const getLabel = useCallback(
(col: string): string => {
@@ -384,7 +297,7 @@ export default function CRUDCollection({
// Sort the live, edited collection rather than propsCollection, so
// an edit that hasn't round-tripped back through onChange yet
// isn't dropped when a sort is applied.
- sortedArray = [...collectionArray];
+ sortedArray = [...collectionArrayRef.current];
// Negate the comparator for descending order (rather than reversing
// the array) so equal-key rows keep their relative order.
const direction = newSortOrder === SortOrderEnum.Desc ? -1 : 1;
@@ -393,35 +306,49 @@ export default function CRUDCollection({
direction * compareSort(a[col] as Sort, b[col] as Sort),
);
} else {
- // Restore the pre-sort order, but take each row's current value
- // from the live `collection` map (not propsCollection) so an edit
- // made while sorted survives clearing the sort. Any row not part
- // of the tracked order (e.g. added while sorted) is appended.
+ // Restore the pre-sort order using the latest row values. Any row
+ // added while sorted is appended after the tracked rows.
+ const itemsById = new Map(
+ collectionArrayRef.current.map(item => [item.id, item]),
+ );
const trackedIds = new Set(unsortedOrderRef.current);
- sortedArray = unsortedOrderRef.current
- .filter(id => collection[id])
- .map(id => collection[id]);
- collectionArray.forEach(item => {
+ sortedArray = unsortedOrderRef.current.flatMap(id => {
+ const item = itemsById.get(id);
+ return item ? [item] : [];
+ });
+ collectionArrayRef.current.forEach(item => {
if (!trackedIds.has(item.id)) {
sortedArray.push(item);
}
});
}
- setCollectionArray(sortedArray);
+ sortRef.current = newSortOrder;
+ setCurrentCollection(sortedArray);
setSortColumn(newSortColumn);
setSort(newSortOrder);
}
},
- [collection, collectionArray, sortColumns, sortColumn, sort],
+ [setCurrentCollection, sortColumns, sortColumn, sort],
);
const renderExpandableSection = useCallback(
(item: CollectionItem): ReactNode => {
- const propsGenerator = () => ({ item, onChange: onFieldsetChange });
+ const { id } = item;
+ // SQL controls cancel their pending debounce when this callback changes.
+ // Keep each row's handler stable across collection edits and expansion.
+ if (!fieldChangeHandlers.current.has(id)) {
+ fieldChangeHandlers.current.set(id, (fieldKey, value) =>
+ onCellChange(id, fieldKey, value),
+ );
+ }
+ const propsGenerator = () => ({
+ item,
+ onFieldChange: fieldChangeHandlers.current.get(id),
+ });
return recurseReactClone(expandFieldset, Fieldset, propsGenerator);
},
- [expandFieldset, onFieldsetChange],
+ [expandFieldset, onCellChange],
);
const renderCell = useCallback(
diff --git a/superset-frontend/src/components/Datasource/types.ts
b/superset-frontend/src/components/Datasource/types.ts
index e770bae314d..e4edcd8bca3 100644
--- a/superset-frontend/src/components/Datasource/types.ts
+++ b/superset-frontend/src/components/Datasource/types.ts
@@ -104,16 +104,6 @@ export enum SortOrder {
Unsorted = 0,
}
-export interface CRUDCollectionState {
- collection: Record<PropertyKey, any>;
- collectionArray: Record<PropertyKey, any>[];
- expandedColumns: Record<PropertyKey, any>;
- sortColumn: string;
- sort: SortOrder;
- currentPage: number;
- pageSize: number;
-}
-
export enum FoldersEditorItemType {
Folder = 'folder',
Column = 'column',