feat: AI-powered TypeScript migration framework with parallel processing (#35045)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
This commit is contained in:
Maxime Beauchemin
2025-09-20 15:47:42 -07:00
committed by GitHub
parent 076e477fd4
commit ecb3ac68ff
77 changed files with 3883 additions and 447 deletions

View File

@@ -0,0 +1,66 @@
/**
* 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 DebouncedMessageQueue from './DebouncedMessageQueue';
describe('DebouncedMessageQueue', () => {
it('should create a queue with default options', () => {
const queue = new DebouncedMessageQueue();
expect(queue).toBeDefined();
expect(queue.trigger).toBeInstanceOf(Function);
});
it('should accept custom configuration options', () => {
const mockCallback = jest.fn();
const queue = new DebouncedMessageQueue({
callback: mockCallback,
sizeThreshold: 500,
delayThreshold: 2000,
});
expect(queue).toBeDefined();
});
it('should append items to the queue', () => {
const mockCallback = jest.fn();
const queue = new DebouncedMessageQueue({ callback: mockCallback });
const testEvent = { id: 1, message: 'test' };
queue.append(testEvent);
// Verify the append method doesn't throw
expect(() => queue.append(testEvent)).not.toThrow();
});
it('should handle generic types properly', () => {
interface TestEvent {
id: number;
data: string;
}
const mockCallback = jest.fn();
const queue = new DebouncedMessageQueue<TestEvent>({
callback: mockCallback,
});
const testEvent: TestEvent = { id: 1, data: 'test' };
queue.append(testEvent);
expect(() => queue.append(testEvent)).not.toThrow();
});
});

View File

@@ -18,26 +18,45 @@
*/
import { debounce } from 'lodash';
class DebouncedMessageQueue {
export interface DebouncedMessageQueueOptions<T> {
callback?: (events: T[]) => void;
sizeThreshold?: number;
delayThreshold?: number;
}
class DebouncedMessageQueue<T = Record<string, unknown>> {
private queue: T[];
private readonly sizeThreshold: number;
private readonly delayThreshold: number;
private readonly callback: (events: T[]) => void;
public readonly trigger: () => void;
constructor({
callback = () => {},
sizeThreshold = 1000,
delayThreshold = 1000,
}) {
}: DebouncedMessageQueueOptions<T> = {}) {
this.queue = [];
this.sizeThreshold = sizeThreshold;
this.delayThreshold = delayThreshold;
this.trigger = debounce(this.trigger.bind(this), this.delayThreshold);
this.callback = callback;
this.trigger = debounce(
this.triggerInternal.bind(this),
this.delayThreshold,
);
}
append(eventData) {
append(eventData: T): void {
this.queue.push(eventData);
this.trigger();
}
trigger() {
private triggerInternal(): void {
if (this.queue.length > 0) {
const events = this.queue.splice(0, this.sizeThreshold);
this.callback.call(null, events);

View File

@@ -1,27 +0,0 @@
/**
* 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.
*/
export const getDatasourceAsSaveableDataset = source => ({
columns: source.columns,
name: source?.datasource_name || source?.name || 'Untitled',
dbId: source?.database?.id || source?.dbId,
sql: source?.sql || '',
catalog: source?.catalog,
schema: source?.schema,
templateParams: source?.templateParams,
});

View File

@@ -0,0 +1,190 @@
/**
* 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 { ColumnMeta, Metric } from '@superset-ui/chart-controls';
import { DatasourceType } from '@superset-ui/core';
import type { Datasource } from 'src/explore/types';
import type { QueryEditor } from 'src/SqlLab/types';
import { getDatasourceAsSaveableDataset } from './datasourceUtils';
const mockColumnMeta: ColumnMeta = {
column_name: 'test_column',
type: 'VARCHAR',
is_dttm: false,
verbose_name: 'Test Column',
description: 'A test column',
expression: '',
filterable: true,
groupby: true,
id: 1,
type_generic: 1,
python_date_format: null,
optionName: 'test_column',
};
const mockMetric: Metric = {
id: 1,
uuid: 'metric-1',
metric_name: 'count',
verbose_name: 'Count',
description: 'Count of records',
d3format: null,
currency: null,
warning_text: null,
// optionName removed - not part of Metric interface
};
const mockDatasource: Datasource = {
id: 1,
type: DatasourceType.Table,
columns: [mockColumnMeta],
metrics: [mockMetric],
column_formats: {},
verbose_map: {},
main_dttm_col: '',
order_by_choices: null,
datasource_name: 'Test Datasource',
name: 'test_table',
catalog: 'test_catalog',
schema: 'test_schema',
description: 'Test datasource',
database: {
id: 123,
database_name: 'test_db',
sqlalchemy_uri: 'postgresql://test',
},
};
const mockQueryEditor: QueryEditor = {
id: 'query-1',
immutableId: 'immutable-query-1',
version: 1,
name: 'Test Query',
sql: 'SELECT * FROM users',
dbId: 456,
autorun: false,
remoteId: null,
catalog: 'prod_catalog',
schema: 'public',
templateParams: '{"param1": "value1"}',
};
describe('getDatasourceAsSaveableDataset', () => {
test('should convert Datasource object correctly', () => {
const result = getDatasourceAsSaveableDataset(mockDatasource);
expect(result).toEqual({
columns: [mockColumnMeta],
name: 'Test Datasource',
dbId: 123,
sql: '',
catalog: 'test_catalog',
schema: 'test_schema',
templateParams: null,
});
});
test('should convert QueryEditor object correctly', () => {
const queryWithColumns = { ...mockQueryEditor, columns: [mockColumnMeta] };
const result = getDatasourceAsSaveableDataset(queryWithColumns);
expect(result).toEqual({
columns: [mockColumnMeta],
name: 'Test Query',
dbId: 456,
sql: 'SELECT * FROM users',
catalog: 'prod_catalog',
schema: 'public',
templateParams: '{"param1": "value1"}',
});
});
test('should handle datasource with fallback name from name property', () => {
const datasourceWithoutDatasourceName: Datasource = {
...mockDatasource,
datasource_name: null,
name: 'fallback_name',
};
const result = getDatasourceAsSaveableDataset(
datasourceWithoutDatasourceName,
);
expect(result.name).toBe('fallback_name');
});
test('should use "Untitled" as fallback when no name is available', () => {
const datasourceWithoutName: Datasource = {
...mockDatasource,
datasource_name: null,
name: '',
};
const result = getDatasourceAsSaveableDataset(datasourceWithoutName);
expect(result.name).toBe('Untitled');
});
test('should handle missing database object', () => {
const datasourceWithoutDatabase: Datasource = {
...mockDatasource,
database: undefined,
};
const result = getDatasourceAsSaveableDataset(datasourceWithoutDatabase);
expect(result.dbId).toBe(0);
});
test('should handle QueryEditor with missing dbId', () => {
const queryEditorWithoutDbId: QueryEditor = {
...mockQueryEditor,
dbId: undefined,
};
const result = getDatasourceAsSaveableDataset(queryEditorWithoutDbId);
expect(result.dbId).toBe(0);
});
test('should handle QueryEditor without sql property', () => {
const queryEditorWithoutSql: QueryEditor = {
...mockQueryEditor,
sql: '',
};
const result = getDatasourceAsSaveableDataset(queryEditorWithoutSql);
expect(result.sql).toBe('');
});
test('should handle null values for optional properties', () => {
const minimalQueryEditor: QueryEditor = {
...mockQueryEditor,
catalog: null,
schema: undefined,
templateParams: '',
};
const result = getDatasourceAsSaveableDataset(minimalQueryEditor);
expect(result.catalog).toBe(null);
expect(result.schema).toBe(null);
expect(result.templateParams).toBe(null);
});
});

View File

@@ -0,0 +1,57 @@
/**
* 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 { ColumnMeta } from '@superset-ui/chart-controls';
import type { ISaveableDatasource } from 'src/SqlLab/components/SaveDatasetModal';
// Flexible interface that captures what this function actually needs to work
// This allows it to accept various datasource-like objects from different parts of the codebase
interface DatasourceInput {
// Common properties that all datasource-like objects should have
name?: string | null; // Allow null for compatibility
// Optional properties that may exist on different datasource variants
datasource_name?: string | null; // Allow null for compatibility
columns?: any[]; // Can be ColumnMeta[], DatasourcePanelColumn[], ISimpleColumn[], etc.
database?: { id?: number };
dbId?: number;
sql?: string | null; // Allow null for compatibility
catalog?: string | null;
schema?: string | null;
templateParams?: string;
// Type discriminator for QueryEditor-like objects
version?: number;
}
export const getDatasourceAsSaveableDataset = (
source: DatasourceInput,
): ISaveableDatasource => {
// Type guard: QueryEditor-like objects have version property
const isQueryEditorLike = typeof source.version === 'number';
return {
columns: (source.columns as ColumnMeta[]) || [],
name: source.datasource_name || source.name || 'Untitled',
dbId: source.database?.id || source.dbId || 0,
sql: source.sql || '',
catalog: source.catalog || null,
schema: source.schema || null,
templateParams: isQueryEditorLike ? source.templateParams || null : null,
};
};

View File

@@ -0,0 +1,101 @@
/**
* 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 { getChartControlPanelRegistry, JsonObject } from '@superset-ui/core';
import getControlsForVizType from 'src/utils/getControlsForVizType';
const fakePluginControls: JsonObject = {
controlPanelSections: [
{
label: 'Fake Control Panel Sections',
expanded: true,
controlSetRows: [
[
{
name: 'y_axis_bounds',
config: {
type: 'BoundsControl',
label: 'Value bounds',
default: [null, null],
description: 'Value bounds for the y axis',
},
},
],
[
{
name: 'adhoc_filters',
config: {
type: 'AdhocFilterControl',
label: 'Fake Filters',
default: null,
},
},
],
],
},
{
label: 'Fake Control Panel Sections 2',
expanded: true,
controlSetRows: [
[
{
name: 'column_collection',
config: {
type: 'CollectionControl',
label: 'Fake Collection Control',
},
},
],
],
},
],
};
describe('getControlsForVizType', () => {
beforeEach(() => {
getChartControlPanelRegistry().registerValue(
'chart_controls_inventory_fake',
fakePluginControls,
);
});
it('returns a map of the controls', () => {
expect(
JSON.stringify(getControlsForVizType('chart_controls_inventory_fake')),
).toEqual(
JSON.stringify({
y_axis_bounds: {
type: 'BoundsControl',
label: 'Value bounds',
default: [null, null],
description: 'Value bounds for the y axis',
},
adhoc_filters: {
type: 'AdhocFilterControl',
label: 'Fake Filters',
default: null,
},
column_collection: {
type: 'CollectionControl',
label: 'Fake Collection Control',
},
}),
);
});
});

View File

@@ -0,0 +1,74 @@
/**
* 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 memoizeOne from 'memoize-one';
import { isControlPanelSectionConfig } from '@superset-ui/chart-controls';
import { getChartControlPanelRegistry, JsonObject } from '@superset-ui/core';
import type { ControlMap } from 'src/components/AlteredSliceTag/types';
import { controls } from '../explore/controls';
const memoizedControls = memoizeOne(
(vizType: string, controlPanel: JsonObject | undefined): ControlMap => {
const controlsMap: ControlMap = {};
if (!controlPanel) return controlsMap;
const sections = controlPanel.controlPanelSections || [];
(Array.isArray(sections) ? sections : [])
.filter(isControlPanelSectionConfig)
.forEach(section => {
if (section.controlSetRows && Array.isArray(section.controlSetRows)) {
section.controlSetRows.forEach(row => {
if (Array.isArray(row)) {
row.forEach(control => {
if (!control) return;
if (typeof control === 'string') {
// For now, we have to look in controls.jsx to get the config for some controls.
// Once everything is migrated out, delete this if statement.
const controlConfig = (controls as any)[control];
if (controlConfig) {
controlsMap[control] = controlConfig;
}
} else if (
typeof control === 'object' &&
control &&
'name' in control &&
'config' in control
) {
// condition needed because there are elements, e.g. <hr /> in some control configs (I'm looking at you, FilterBox!)
const controlObj = control as {
name: string;
config: JsonObject;
};
controlsMap[controlObj.name] = controlObj.config;
}
});
}
});
}
});
return controlsMap;
},
);
const getControlsForVizType = (vizType: string): ControlMap => {
const controlPanel = getChartControlPanelRegistry().get(vizType);
return memoizedControls(vizType, controlPanel);
};
export default getControlsForVizType;

View File

@@ -0,0 +1,58 @@
/**
* 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 { availableDomains, allowCrossDomain } from './hostNamesConfig';
describe('hostNamesConfig', () => {
beforeEach(() => {
// Reset DOM
document.body.innerHTML = '';
// Mock window.location
Object.defineProperty(window, 'location', {
value: {
hostname: 'localhost',
search: '',
},
writable: true,
});
});
test('should export availableDomains as array of strings', () => {
expect(Array.isArray(availableDomains)).toBe(true);
availableDomains.forEach(domain => {
expect(typeof domain).toBe('string');
});
});
test('should export allowCrossDomain as boolean', () => {
expect(typeof allowCrossDomain).toBe('boolean');
});
test('should determine allowCrossDomain based on availableDomains length', () => {
const expectedValue = availableDomains.length > 1;
expect(allowCrossDomain).toBe(expectedValue);
});
test('availableDomains should contain at least the current hostname', () => {
// Since we're testing the already computed values, we check they contain localhost
// or the configuration returns empty array if app container is missing
expect(availableDomains.length >= 0).toBe(true);
});
});

View File

@@ -19,7 +19,7 @@
import { initFeatureFlags } from '@superset-ui/core';
import getBootstrapData from './getBootstrapData';
function getDomainsConfig() {
function getDomainsConfig(): string[] {
const appContainer = document.getElementById('app');
if (!appContainer) {
return [];
@@ -42,13 +42,15 @@ function getDomainsConfig() {
initFeatureFlags(bootstrapData.common.feature_flags);
if (bootstrapData?.common?.conf?.SUPERSET_WEBSERVER_DOMAINS) {
bootstrapData.common.conf.SUPERSET_WEBSERVER_DOMAINS.forEach(hostName => {
const domains = bootstrapData.common.conf
.SUPERSET_WEBSERVER_DOMAINS as string[];
domains.forEach((hostName: string) => {
availableDomains.add(hostName);
});
}
return Array.from(availableDomains);
}
export const availableDomains = getDomainsConfig();
export const availableDomains: string[] = getDomainsConfig();
export const allowCrossDomain = availableDomains.length > 1;
export const allowCrossDomain: boolean = availableDomains.length > 1;

View File

@@ -0,0 +1,129 @@
/**
* 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 {
addToObject,
alterInObject,
alterInArr,
removeFromArr,
addToArr,
} from './reducerUtils';
interface TestItem {
id?: string;
name: string;
value: number;
}
const mockState = {
objects: {
'item-1': { id: 'item-1', name: 'Item 1', value: 10 },
'item-2': { id: 'item-2', name: 'Item 2', value: 20 },
},
items: [
{ id: 'item-1', name: 'Item 1', value: 10 },
{ id: 'item-2', name: 'Item 2', value: 20 },
],
};
test('addToObject adds new object to state with generated id', () => {
const newItem: TestItem = { name: 'New Item', value: 30 };
const result = addToObject(mockState, 'objects', newItem);
expect(result).not.toBe(mockState);
expect(result.objects).not.toBe(mockState.objects);
expect(Object.keys(result.objects)).toHaveLength(3);
const addedItems = Object.values(result.objects).filter(
item => (item as TestItem).name === 'New Item',
);
expect(addedItems).toHaveLength(1);
expect((addedItems[0] as TestItem).id).toBeTruthy();
});
test('addToObject adds new object with existing id', () => {
const newItem: TestItem = { id: 'item-3', name: 'Item 3', value: 30 };
const result = addToObject(mockState, 'objects', newItem);
expect(result.objects['item-3']).toEqual(newItem);
});
test('alterInObject modifies existing object', () => {
const targetItem: TestItem = { id: 'item-1', name: 'Item 1', value: 10 };
const alterations = { value: 15 };
const result = alterInObject(mockState, 'objects', targetItem, alterations);
expect(result.objects['item-1'].value).toBe(15);
expect(result.objects['item-1'].name).toBe('Item 1');
expect(result.objects['item-2']).toBe(mockState.objects['item-2']);
});
test('alterInArr modifies existing array item', () => {
const targetItem: TestItem = { id: 'item-1', name: 'Item 1', value: 10 };
const alterations = { value: 15 };
const result = alterInArr(mockState, 'items', targetItem, alterations);
expect(result.items[0].value).toBe(15);
expect(result.items[0].name).toBe('Item 1');
expect(result.items[1]).toBe(mockState.items[1]);
});
test('removeFromArr removes item from array', () => {
const targetItem: TestItem = { id: 'item-1', name: 'Item 1', value: 10 };
const result = removeFromArr(mockState, 'items', targetItem);
expect(result.items).toHaveLength(1);
expect(result.items[0].id).toBe('item-2');
});
test('removeFromArr with custom idKey', () => {
const stateWithCustomKey = {
items: [
{ customId: 'a', name: 'Item A' },
{ customId: 'b', name: 'Item B' },
],
};
const targetItem = { customId: 'a', name: 'Item A' };
const result = removeFromArr(
stateWithCustomKey,
'items',
targetItem,
'customId',
);
expect(result.items).toHaveLength(1);
expect(result.items[0].customId).toBe('b');
});
test('addToArr adds new item to array with generated id', () => {
const newItem: TestItem = { name: 'New Item', value: 30 };
const result = addToArr(mockState, 'items', newItem);
expect(result.items).toHaveLength(3);
expect(result.items[2].name).toBe('New Item');
expect(result.items[2].id).toBeTruthy();
});
test('addToArr adds new item with existing id', () => {
const newItem: TestItem = { id: 'item-3', name: 'Item 3', value: 30 };
const result = addToArr(mockState, 'items', newItem);
expect(result.items).toHaveLength(3);
expect(result.items[2]).toEqual(newItem);
});

View File

@@ -0,0 +1,107 @@
/**
* 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 { nanoid } from 'nanoid';
interface ObjectWithId {
id?: string;
[key: string]: any;
}
interface StateWithObject {
[key: string]: { [id: string]: ObjectWithId } | any;
}
interface StateWithArray {
[key: string]: ObjectWithId[] | any;
}
export function addToObject<T extends ObjectWithId>(
state: StateWithObject,
arrKey: string,
obj: T,
): StateWithObject {
const newObject = { ...state[arrKey] };
const copiedObject = { ...obj };
if (!copiedObject.id) {
copiedObject.id = nanoid();
}
newObject[copiedObject.id] = copiedObject;
return { ...state, [arrKey]: newObject };
}
export function alterInObject<T extends ObjectWithId>(
state: StateWithObject,
arrKey: string,
obj: T,
alterations: Partial<T>,
): StateWithObject {
const newObject = { ...state[arrKey] };
newObject[obj.id!] = { ...newObject[obj.id!], ...alterations };
return { ...state, [arrKey]: newObject };
}
export function alterInArr<T extends ObjectWithId>(
state: StateWithArray,
arrKey: string,
obj: T,
alterations: Partial<T>,
): StateWithArray {
// Finds an item in an array in the state and replaces it with a
// new object with an altered property
const idKey = 'id';
const newArr: T[] = [];
state[arrKey].forEach((arrItem: T) => {
if (obj[idKey] === arrItem[idKey]) {
newArr.push({ ...arrItem, ...alterations });
} else {
newArr.push(arrItem);
}
});
return { ...state, [arrKey]: newArr };
}
export function removeFromArr<T extends ObjectWithId>(
state: StateWithArray,
arrKey: string,
obj: T,
idKey = 'id',
): StateWithArray {
const newArr: T[] = [];
state[arrKey].forEach((arrItem: T) => {
if (!(obj[idKey as keyof T] === arrItem[idKey as keyof T])) {
newArr.push(arrItem);
}
});
return { ...state, [arrKey]: newArr };
}
export function addToArr<T extends ObjectWithId>(
state: StateWithArray,
arrKey: string,
obj: T,
): StateWithArray {
const newObj = { ...obj };
if (!newObj.id) {
newObj.id = nanoid();
}
const newState: { [key: string]: T[] } = {};
newState[arrKey] = [...state[arrKey], newObj];
return { ...state, ...newState };
}