feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
31
frontend/packages/studio/autosave/.storybook/main.js
Normal file
31
frontend/packages/studio/autosave/.storybook/main.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { mergeConfig } from 'vite';
|
||||
import svgr from 'vite-plugin-svgr';
|
||||
|
||||
/** @type { import('@storybook/react-vite').StorybookConfig } */
|
||||
const config = {
|
||||
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.tsx'],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-onboarding',
|
||||
'@storybook/addon-interactions',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: 'tag',
|
||||
},
|
||||
viteFinal: config =>
|
||||
mergeConfig(config, {
|
||||
plugins: [
|
||||
svgr({
|
||||
svgrOptions: {
|
||||
native: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
};
|
||||
export default config;
|
||||
14
frontend/packages/studio/autosave/.storybook/preview.js
Normal file
14
frontend/packages/studio/autosave/.storybook/preview.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/** @type { import('@storybook/react').Preview } */
|
||||
const preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
5
frontend/packages/studio/autosave/.stylelintrc.js
Normal file
5
frontend/packages/studio/autosave/.stylelintrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const { defineConfig } = require('@coze-arch/stylelint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
extends: [],
|
||||
});
|
||||
16
frontend/packages/studio/autosave/README.md
Normal file
16
frontend/packages/studio/autosave/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# @coze-studio/autosave
|
||||
|
||||
> Project template for react component with storybook and supports publish independently.
|
||||
|
||||
## Features
|
||||
|
||||
- [x] eslint & ts
|
||||
- [x] esm bundle
|
||||
- [x] umd bundle
|
||||
- [x] storybook
|
||||
|
||||
## Commands
|
||||
|
||||
- init: `rush update`
|
||||
- dev: `npm run dev`
|
||||
- build: `npm run build`
|
||||
72
frontend/packages/studio/autosave/__mocks__/zustand.ts
Normal file
72
frontend/packages/studio/autosave/__mocks__/zustand.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed 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.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @coze-arch/no-batch-import-or-export
|
||||
import type * as zustand from 'zustand';
|
||||
import { act } from '@testing-library/react';
|
||||
|
||||
const { create: actualCreate, createStore: actualCreateStore } =
|
||||
await vi.importActual<typeof zustand>('zustand');
|
||||
|
||||
// a variable to hold reset functions for all stores declared in the app
|
||||
export const storeResetFns = new Set<() => void>();
|
||||
|
||||
const createUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
|
||||
const store = actualCreate(stateCreator);
|
||||
const initialState = store.getState();
|
||||
storeResetFns.add(() => {
|
||||
store.setState(initialState, true);
|
||||
});
|
||||
return store;
|
||||
};
|
||||
|
||||
// when creating a store, we get its initial state, create a reset function and add it in the set
|
||||
export const create = (<T>(stateCreator: zustand.StateCreator<T>) => {
|
||||
console.log('zustand create mock');
|
||||
|
||||
// to support curried version of create
|
||||
return typeof stateCreator === 'function'
|
||||
? createUncurried(stateCreator)
|
||||
: createUncurried;
|
||||
}) as typeof zustand.create;
|
||||
|
||||
const createStoreUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
|
||||
const store = actualCreateStore(stateCreator);
|
||||
const initialState = store.getState();
|
||||
storeResetFns.add(() => {
|
||||
store.setState(initialState, true);
|
||||
});
|
||||
return store;
|
||||
};
|
||||
|
||||
// when creating a store, we get its initial state, create a reset function and add it in the set
|
||||
export const createStore = (<T>(stateCreator: zustand.StateCreator<T>) => {
|
||||
console.log('zustand createStore mock');
|
||||
|
||||
// to support curried version of createStore
|
||||
return typeof stateCreator === 'function'
|
||||
? createStoreUncurried(stateCreator)
|
||||
: createStoreUncurried;
|
||||
}) as typeof zustand.createStore;
|
||||
|
||||
// reset all stores after each test run
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
storeResetFns.forEach(resetFn => {
|
||||
resetFn();
|
||||
});
|
||||
});
|
||||
});
|
||||
234
frontend/packages/studio/autosave/__tests__/core/manager.test.ts
Normal file
234
frontend/packages/studio/autosave/__tests__/core/manager.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed 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 StoreApi, type UseBoundStore } from 'zustand';
|
||||
import { describe, beforeEach, afterEach, vi, expect } from 'vitest';
|
||||
|
||||
import { DebounceTime } from '../../src/type';
|
||||
import { AutosaveManager } from '../../src/core/manager';
|
||||
|
||||
vi.mock('lodash-es', () => ({
|
||||
debounce: vi.fn((func, wait) => {
|
||||
let timeout;
|
||||
const debounced = (...args) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
debounced.cancel = vi.fn(() => clearTimeout(timeout));
|
||||
debounced.flush = vi.fn(() => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
func();
|
||||
}
|
||||
});
|
||||
return debounced;
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockStore = {
|
||||
subscribe: vi.fn().mockReturnValue(() => {
|
||||
console.log('Unsubscribed');
|
||||
}),
|
||||
} as unknown as UseBoundStore<StoreApi<any>>;
|
||||
|
||||
describe('AutosaveManager', () => {
|
||||
let manager;
|
||||
const saveRequest = vi.fn().mockResolvedValue(Promise.resolve());
|
||||
|
||||
const registers = [
|
||||
{
|
||||
key: 'testKey',
|
||||
debounce: DebounceTime.Immediate,
|
||||
selector: state => state,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new AutosaveManager({
|
||||
store: mockStore,
|
||||
registers,
|
||||
saveRequest,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize and set initial values correctly', () => {
|
||||
expect(manager.observerList.length).toBe(0);
|
||||
expect(manager.configList).toMatchObject(
|
||||
registers.map(r => ({
|
||||
...r,
|
||||
eventCallBacks: undefined,
|
||||
saveRequest,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it('should start observers correctly', () => {
|
||||
manager.start();
|
||||
|
||||
expect(manager.observerList.length).toBe(1);
|
||||
const observer = manager.observerList[0];
|
||||
expect(observer.config.key).toBe('testKey');
|
||||
expect(observer.config.saveRequest).toBe(saveRequest);
|
||||
});
|
||||
|
||||
it('should not start observers if already started', () => {
|
||||
manager.start();
|
||||
expect(manager.observerList.length).toBe(1);
|
||||
|
||||
manager.start(); // 再次调用 start
|
||||
expect(manager.observerList.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle manualSave correctly when config is undefined', async () => {
|
||||
await manager.manualSave('undefinedKey', { value: 'test' });
|
||||
|
||||
expect(saveRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close observers correctly', () => {
|
||||
manager.start();
|
||||
const observer = manager.observerList[0];
|
||||
const closeSpy = vi.spyOn(observer, 'close');
|
||||
|
||||
manager.close();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
expect(manager.observerList.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle manualSave correctly', async () => {
|
||||
const params = { value: 'test' };
|
||||
|
||||
await manager.manualSave('testKey', params);
|
||||
|
||||
expect(saveRequest).toHaveBeenCalledWith(params, 'testKey', []);
|
||||
});
|
||||
|
||||
it('call middleware correctly', async () => {
|
||||
const mockOnBeforeSave = vi.fn().mockResolvedValue({ value: 'before' });
|
||||
const mockOnAfterSave = vi.fn().mockResolvedValue({ value: 'after' });
|
||||
|
||||
const registersWithMiddleware = [
|
||||
{
|
||||
key: 'testWithMiddlewareKey',
|
||||
debounce: DebounceTime.Immediate,
|
||||
selector: state => state,
|
||||
middleware: {
|
||||
onBeforeSave: mockOnBeforeSave,
|
||||
onAfterSave: mockOnAfterSave,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
manager = new AutosaveManager({
|
||||
store: mockStore,
|
||||
registers: registersWithMiddleware,
|
||||
saveRequest,
|
||||
});
|
||||
|
||||
const params = { value: 'test' };
|
||||
|
||||
await manager.manualSave('testWithMiddlewareKey', params);
|
||||
|
||||
expect(mockOnBeforeSave).toHaveBeenCalledWith(params);
|
||||
expect(saveRequest).toHaveBeenCalledWith(
|
||||
{ value: 'before' },
|
||||
'testWithMiddlewareKey',
|
||||
[],
|
||||
);
|
||||
expect(mockOnAfterSave).toHaveBeenCalledWith(params);
|
||||
});
|
||||
|
||||
it('should call eventCallBacks', async () => {
|
||||
const onBeforeSaveCallback = vi.fn();
|
||||
const onAfterSaveCallback = vi.fn();
|
||||
const eventCallBacks = {
|
||||
onBeforeSave: onBeforeSaveCallback,
|
||||
onAfterSave: onAfterSaveCallback,
|
||||
};
|
||||
manager = new AutosaveManager({
|
||||
store: mockStore,
|
||||
registers,
|
||||
saveRequest,
|
||||
eventCallBacks,
|
||||
});
|
||||
|
||||
await manager.manualSave('testKey', { value: 'test' });
|
||||
|
||||
expect(onBeforeSaveCallback).toHaveBeenCalledWith({ value: 'test' });
|
||||
expect(onAfterSaveCallback).toHaveBeenCalledWith({ value: 'test' });
|
||||
});
|
||||
|
||||
it('should save without auto save', async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
manager.start();
|
||||
|
||||
await manager.handleWithoutAutosave({
|
||||
key: 'testKey',
|
||||
handler,
|
||||
});
|
||||
|
||||
const observer = manager.getObserver('testKey');
|
||||
// 确保所有异步操作完成
|
||||
await Promise.resolve();
|
||||
|
||||
expect(observer.lock).toBe(false);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save flush correctly', async () => {
|
||||
manager.start();
|
||||
|
||||
vi.spyOn(manager, 'getObserver').mockReturnValue({
|
||||
debouncedSaveFunc: {
|
||||
flush: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await manager.saveFlush('testKey');
|
||||
|
||||
const observer = manager.getObserver('testKey');
|
||||
expect(observer.debouncedSaveFunc.flush).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save flush all correctly', async () => {
|
||||
manager.start();
|
||||
const observer = manager.getObserver('testKey');
|
||||
const nextState = { value: 'next' };
|
||||
const prevState = { value: 'prev' };
|
||||
|
||||
vi.spyOn(observer, 'getMemoizeSelector').mockReturnValue(() => nextState);
|
||||
vi.spyOn(observer, 'getTriggerDelayTime').mockReturnValue(1000);
|
||||
|
||||
await observer.subscribeCallback(nextState, prevState);
|
||||
|
||||
manager.observerList.forEach(ob => {
|
||||
ob.debouncedSaveFunc.flush = vi.fn();
|
||||
});
|
||||
|
||||
manager.saveFlushAll();
|
||||
|
||||
manager.observerList.forEach(ob => {
|
||||
expect(ob.debouncedSaveFunc.flush).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed 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 StoreApi, type UseBoundStore } from 'zustand';
|
||||
import { describe, beforeEach, afterEach, vi, expect } from 'vitest';
|
||||
import { diff, type Diff } from 'deep-diff';
|
||||
|
||||
import { DebounceTime, type AutosaveObserverConfig } from '../../src/type';
|
||||
import { AutosaveObserver } from '../../src/core/observer';
|
||||
|
||||
const debounceMock = vi.fn((func, wait) => {
|
||||
let timeout;
|
||||
const debounced = (...args) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
debounced.cancel = vi.fn(() => clearTimeout(timeout));
|
||||
debounced.flush = vi.fn(() => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
func();
|
||||
}
|
||||
});
|
||||
return debounced;
|
||||
});
|
||||
|
||||
vi.mock('lodash-es/debounce', () => {
|
||||
const originalModule = vi.importActual('lodash-es/debounce');
|
||||
return {
|
||||
__esModule: true,
|
||||
default: debounceMock,
|
||||
...originalModule,
|
||||
};
|
||||
});
|
||||
|
||||
const mockStore = {
|
||||
subscribe: vi.fn(),
|
||||
} as unknown as UseBoundStore<StoreApi<any>>;
|
||||
|
||||
describe('AutosaveObserver', () => {
|
||||
let observer;
|
||||
const saveRequest = vi.fn().mockResolvedValue(Promise.resolve());
|
||||
|
||||
const config: AutosaveObserverConfig<any, any, any> = {
|
||||
key: 'testKey',
|
||||
debounce: DebounceTime.Immediate,
|
||||
selector: state => state,
|
||||
saveRequest,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers(); // 使用假定时器
|
||||
|
||||
observer = new AutosaveObserver({
|
||||
store: mockStore,
|
||||
...config,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.useRealTimers(); // 恢复真实的定时器
|
||||
});
|
||||
|
||||
it('should initialize and set initial values correctly', () => {
|
||||
expect(observer.lock).toBe(false);
|
||||
expect(observer.config).toStrictEqual(config);
|
||||
expect(observer.store).toBe(mockStore);
|
||||
expect(mockStore.subscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger callback and parsedSaveFunc', async () => {
|
||||
const nextState = { value: 'next' };
|
||||
const prevState = { value: 'prev' };
|
||||
const diffChange: Diff<any, any>[] = [
|
||||
{
|
||||
kind: 'E',
|
||||
path: ['value'],
|
||||
lhs: 'prev',
|
||||
rhs: 'next',
|
||||
},
|
||||
];
|
||||
|
||||
vi.spyOn(observer, 'getMemoizeSelector').mockReturnValue(() => nextState);
|
||||
vi.spyOn(observer, 'getTriggerDelayTime').mockReturnValue(0);
|
||||
|
||||
await observer.subscribeCallback(nextState, prevState);
|
||||
|
||||
expect(observer.nextState).toBe(nextState);
|
||||
expect(observer.prevState).toBe(prevState);
|
||||
expect(observer.diff).toEqual(diffChange);
|
||||
expect(saveRequest).toHaveBeenCalledWith(nextState, 'testKey', diffChange);
|
||||
});
|
||||
|
||||
it('should handle array diff change', async () => {
|
||||
const nextState = { value: [1, 2, 3] };
|
||||
const prevState = { value: [1, 2] };
|
||||
const diffChange = diff(prevState, nextState);
|
||||
|
||||
vi.spyOn(observer, 'getMemoizeSelector').mockReturnValue(() => nextState);
|
||||
vi.spyOn(observer, 'getTriggerDelayTime').mockReturnValue(500);
|
||||
|
||||
await observer.subscribeCallback(nextState, prevState);
|
||||
|
||||
expect(observer.debouncedSaveFunc).toBeInstanceOf(Function);
|
||||
|
||||
vi.runAllTimers(); // 手动推进定时器时间以触发防抖函数
|
||||
|
||||
await vi.runAllTimersAsync(); // 确保所有异步操作完成
|
||||
|
||||
expect(saveRequest).toHaveBeenCalledWith(nextState, 'testKey', diffChange);
|
||||
});
|
||||
|
||||
it('should cancel and unsubscribe correctly', async () => {
|
||||
const prevState = {
|
||||
value: {
|
||||
a: 1,
|
||||
},
|
||||
};
|
||||
const nextState = {
|
||||
value: {
|
||||
a: 2,
|
||||
},
|
||||
};
|
||||
vi.spyOn(observer, 'getMemoizeSelector').mockReturnValue(() => nextState);
|
||||
vi.spyOn(observer, 'getTriggerDelayTime').mockReturnValue(1000);
|
||||
|
||||
await observer.subscribeCallback(nextState, prevState);
|
||||
|
||||
const cancelSpy = vi.spyOn(observer.debouncedSaveFunc, 'flush');
|
||||
const unsubscribeSpy = vi.spyOn(observer, 'unsubscribe');
|
||||
|
||||
observer.close();
|
||||
|
||||
expect(cancelSpy).toHaveBeenCalled();
|
||||
expect(unsubscribeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selector with deps', () => {
|
||||
const deps = [state => state.value];
|
||||
const transformer = value => value * 2;
|
||||
|
||||
const observerConfig: AutosaveObserverConfig<any, any, any> = {
|
||||
...config,
|
||||
selector: { deps, transformer },
|
||||
};
|
||||
|
||||
observer = new AutosaveObserver({
|
||||
store: mockStore,
|
||||
...observerConfig,
|
||||
});
|
||||
|
||||
const selector = observer.getMemoizeSelector();
|
||||
|
||||
const state = { value: 3 };
|
||||
const result = selector(state);
|
||||
|
||||
expect(result).toBe(transformer(3));
|
||||
});
|
||||
|
||||
it('should return in callback if lock is true', async () => {
|
||||
observer.lock = true;
|
||||
const nextState = { value: 'next' };
|
||||
const prevState = { value: 'prev' };
|
||||
|
||||
const getTriggerDelayTimeSpy = vi.spyOn(observer, 'getTriggerDelayTime');
|
||||
const parsedSaveFuncSpy = vi.spyOn(observer, 'parsedSaveFunc');
|
||||
|
||||
await observer.subscribeCallback(nextState, prevState);
|
||||
|
||||
expect(observer.nextState).toBe(nextState);
|
||||
expect(observer.prevState).toBe(prevState);
|
||||
expect(getTriggerDelayTimeSpy).not.toHaveBeenCalled();
|
||||
expect(parsedSaveFuncSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return in callback if diffChange is undefined', async () => {
|
||||
const nextState = { value: 'prev' };
|
||||
const prevState = { value: 'prev' };
|
||||
|
||||
const getTriggerDelayTimeSpy = vi.spyOn(observer, 'getTriggerDelayTime');
|
||||
const parsedSaveFuncSpy = vi.spyOn(observer, 'parsedSaveFunc');
|
||||
|
||||
await observer.subscribeCallback(nextState, prevState);
|
||||
|
||||
expect(observer.nextState).toBe(nextState);
|
||||
expect(observer.prevState).toBe(prevState);
|
||||
expect(getTriggerDelayTimeSpy).not.toHaveBeenCalled();
|
||||
expect(parsedSaveFuncSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onBeforeSave lifecycle callback', async () => {
|
||||
const onBeforeSave = vi.fn();
|
||||
observer.config.eventCallBacks = { onBeforeSave };
|
||||
observer.nextState = { value: 'next' };
|
||||
|
||||
await observer.parsedSaveFunc();
|
||||
|
||||
expect(onBeforeSave).toHaveBeenCalledWith({
|
||||
key: observer.config.key,
|
||||
data: observer.nextState,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onAfterSave lifecycle callback', async () => {
|
||||
const onAfterSave = vi.fn();
|
||||
observer.config.eventCallBacks = { onAfterSave };
|
||||
observer.nextState = { value: 'next' };
|
||||
|
||||
await observer.parsedSaveFunc();
|
||||
|
||||
expect(onAfterSave).toHaveBeenCalledWith({
|
||||
key: observer.config.key,
|
||||
data: observer.nextState,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onError lifecycle method on error', async () => {
|
||||
const onError = vi.fn();
|
||||
observer.config.eventCallBacks = { onError };
|
||||
|
||||
saveRequest.mockRejectedValueOnce(new Error('Failed request'));
|
||||
observer.nextState = { value: 'next' };
|
||||
|
||||
await observer.parsedSaveFunc();
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: observer.config.key,
|
||||
error: expect.any(Error),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the correct delay time based on debounce configuration', () => {
|
||||
const prevState = { value: 'prev' };
|
||||
const diffChange = [{ kind: 'E', path: ['value'] }];
|
||||
|
||||
observer.config.debounce = () => 200;
|
||||
expect(observer.getTriggerDelayTime(prevState, diffChange)).toBe(200);
|
||||
|
||||
observer.config.debounce = { default: 300 };
|
||||
expect(observer.getTriggerDelayTime(prevState, diffChange)).toBe(300);
|
||||
|
||||
observer.config.debounce = 100;
|
||||
expect(observer.getTriggerDelayTime(prevState, diffChange)).toBe(100);
|
||||
|
||||
observer.config.debounce = null;
|
||||
expect(observer.getTriggerDelayTime(prevState, diffChange)).toBe(
|
||||
DebounceTime.Immediate,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return default debounce time when diffChange is empty or undefined', () => {
|
||||
const prevState = {};
|
||||
const diffChange = undefined;
|
||||
|
||||
const delayTime = observer.getTriggerDelayTime(prevState, diffChange);
|
||||
expect(delayTime).toBe(DebounceTime.Immediate);
|
||||
});
|
||||
|
||||
it('should push default debounce time when changePath does not exist or is a number', () => {
|
||||
const prevState = { value: 'prev' };
|
||||
const diffChange: Diff<any, any>[] = [
|
||||
{
|
||||
kind: 'E',
|
||||
path: [123],
|
||||
lhs: 'prev',
|
||||
rhs: 'next',
|
||||
},
|
||||
];
|
||||
|
||||
const delayTime = observer.getTriggerDelayTime(prevState, diffChange);
|
||||
expect(delayTime).toBe(DebounceTime.Immediate);
|
||||
});
|
||||
|
||||
it('should push action delay time when DebounceConfig.action is not an object', () => {
|
||||
const prevState = { value: 'prev' };
|
||||
const diffChange = [
|
||||
{
|
||||
kind: 'E',
|
||||
path: ['value'],
|
||||
lhs: 'prev',
|
||||
rhs: 'next',
|
||||
},
|
||||
];
|
||||
|
||||
const debounceActionTime = DebounceTime.Long;
|
||||
|
||||
observer.config.debounce = {
|
||||
default: DebounceTime.Long,
|
||||
value: {
|
||||
action: debounceActionTime,
|
||||
},
|
||||
};
|
||||
|
||||
const delayTime = observer.getTriggerDelayTime(prevState, diffChange);
|
||||
expect(delayTime).toBe(debounceActionTime);
|
||||
});
|
||||
|
||||
it('should return an empty string if changePath does not exist', () => {
|
||||
const changePath = undefined;
|
||||
const debouncePath = observer.getdebouncePath(changePath);
|
||||
expect(debouncePath).toBe('');
|
||||
});
|
||||
|
||||
it('should return the first element of changePath', () => {
|
||||
const changePath = ['value', 0, 'key'];
|
||||
const debouncePath = observer.getdebouncePath(changePath);
|
||||
expect(debouncePath).toBe('value');
|
||||
});
|
||||
|
||||
it('should return default debounce time when diffChange is undefined or empty', () => {
|
||||
const prevState = {};
|
||||
let diffChange: Diff<any, any>[] | undefined = undefined;
|
||||
|
||||
let delayTime = observer.getTriggerDelayTime(prevState, diffChange);
|
||||
expect(delayTime).toBe(DebounceTime.Immediate);
|
||||
|
||||
diffChange = [];
|
||||
delayTime = observer.getTriggerDelayTime(prevState, diffChange);
|
||||
expect(delayTime).toBe(DebounceTime.Immediate);
|
||||
});
|
||||
|
||||
it('should push default debounce time when changePath does not exist or has conditions', () => {
|
||||
const prevState = { value: 'prev' };
|
||||
observer.config.debounce = { default: DebounceTime.Long };
|
||||
const diffChange = [
|
||||
{ kind: 'E', path: undefined },
|
||||
{ kind: 'E', path: ['nonexistentPath'] },
|
||||
{ kind: 'E', path: [123] },
|
||||
];
|
||||
|
||||
vi.spyOn(observer, 'getdebouncePath').mockReturnValue(123);
|
||||
|
||||
const delayTime = observer.getTriggerDelayTime(prevState, diffChange);
|
||||
expect(delayTime).toBe(DebounceTime.Long);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed 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 {
|
||||
isFunction,
|
||||
isObject,
|
||||
getPayloadByFormatter,
|
||||
} from '../../src/utils/index';
|
||||
import { type DebounceConfig, DebounceTime } from '../../src/type/index';
|
||||
|
||||
describe('isFunction', () => {
|
||||
const fn: DebounceConfig = () => DebounceTime.Immediate;
|
||||
|
||||
const ob: DebounceConfig = {
|
||||
default: DebounceTime.Immediate,
|
||||
};
|
||||
|
||||
it('isFunction should return true when the input is function', () => {
|
||||
const result = isFunction(fn);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('isFunction should return false when the input is object', () => {
|
||||
const result = isFunction(ob);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('isObject should return true when the input is object', () => {
|
||||
const result = isObject(ob);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPayloadByFormatter', () => {
|
||||
it('should return state directly if formatter is not provided', async () => {
|
||||
const state = { key: 'value' };
|
||||
|
||||
const result = await getPayloadByFormatter(state);
|
||||
|
||||
expect(result).toEqual(state);
|
||||
});
|
||||
|
||||
it('should call formatter and return its result if formatter is provided', async () => {
|
||||
const state = { key: 'value' };
|
||||
const formatter = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ formattedKey: 'formattedValue' });
|
||||
|
||||
const result = await getPayloadByFormatter(state, formatter);
|
||||
|
||||
expect(formatter).toHaveBeenCalledWith(state);
|
||||
expect(result).toEqual({ formattedKey: 'formattedValue' });
|
||||
});
|
||||
});
|
||||
12
frontend/packages/studio/autosave/config/rush-project.json
Normal file
12
frontend/packages/studio/autosave/config/rush-project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"operationSettings": [
|
||||
{
|
||||
"operationName": "test:cov",
|
||||
"outputFolderNames": ["coverage"]
|
||||
},
|
||||
{
|
||||
"operationName": "ts-check",
|
||||
"outputFolderNames": ["./dist"]
|
||||
}
|
||||
]
|
||||
}
|
||||
7
frontend/packages/studio/autosave/eslint.config.js
Normal file
7
frontend/packages/studio/autosave/eslint.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const { defineConfig } = require('@coze-arch/eslint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
packageRoot: __dirname,
|
||||
preset: 'web',
|
||||
rules: {},
|
||||
});
|
||||
59
frontend/packages/studio/autosave/package.json
Normal file
59
frontend/packages/studio/autosave/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@coze-studio/autosave",
|
||||
"version": "0.0.1",
|
||||
"description": "autosave",
|
||||
"license": "Apache-2.0",
|
||||
"author": "zhangxiang.01@bytedance.com",
|
||||
"maintainers": [],
|
||||
"main": "src/index.ts",
|
||||
"unpkg": "./dist/umd/index.js",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "exit 0",
|
||||
"lint": "eslint ./ --cache",
|
||||
"test": "vitest --run --passWithNoTests",
|
||||
"test:cov": "npm run test -- --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coze-arch/bot-api": "workspace:*",
|
||||
"@coze-arch/logger": "workspace:*",
|
||||
"ahooks": "^3.7.8",
|
||||
"dayjs": "^1.11.7",
|
||||
"deep-diff": "~1.0.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"reselect": "^5.1.1",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@coze-arch/eslint-config": "workspace:*",
|
||||
"@coze-arch/stylelint-config": "workspace:*",
|
||||
"@coze-arch/ts-config": "workspace:*",
|
||||
"@coze-arch/vitest-config": "workspace:*",
|
||||
"@swc/core": "^1.3.35",
|
||||
"@swc/helpers": "^0.4.12",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/deep-diff": "^1.0.5",
|
||||
"@types/react": "18.2.37",
|
||||
"@types/react-dom": "18.2.15",
|
||||
"@vitest/coverage-v8": "~3.0.5",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"less-loader": "~11.1.3",
|
||||
"postcss": "^8.4.32",
|
||||
"react": "~18.2.0",
|
||||
"react-dom": "~18.2.0",
|
||||
"stylelint": "^15.11.0",
|
||||
"vite-plugin-svgr": "~3.3.0",
|
||||
"vitest": "~3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.2.0",
|
||||
"react-dom": ">=18.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
17
frontend/packages/studio/autosave/setup-vitest.ts
Normal file
17
frontend/packages/studio/autosave/setup-vitest.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed 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.
|
||||
*/
|
||||
|
||||
vi.mock('zustand');
|
||||
179
frontend/packages/studio/autosave/src/core/manager.ts
Normal file
179
frontend/packages/studio/autosave/src/core/manager.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed 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 StoreApi, type UseBoundStore } from 'zustand';
|
||||
|
||||
import {
|
||||
type AutosaveObserverConfig,
|
||||
type HostedObserverConfig,
|
||||
type EventCallBacks,
|
||||
type SaveRequest,
|
||||
} from '../type';
|
||||
import { AutosaveObserver } from './observer';
|
||||
|
||||
export interface AutosaveManagerProps<StoreType, ScopeKey, ScopeStateType> {
|
||||
store: UseBoundStore<StoreApi<StoreType>>;
|
||||
registers: HostedObserverConfig<StoreType, ScopeKey, ScopeStateType>[];
|
||||
saveRequest: SaveRequest<ScopeStateType, ScopeKey>;
|
||||
eventCallBacks?: EventCallBacks<ScopeStateType, ScopeKey>;
|
||||
}
|
||||
|
||||
export class AutosaveManager<StoreType, ScopeKey, ScopeStateType> {
|
||||
private configList: AutosaveObserverConfig<
|
||||
StoreType,
|
||||
ScopeKey,
|
||||
ScopeStateType
|
||||
>[];
|
||||
private observerList: AutosaveObserver<StoreType, ScopeKey, ScopeStateType>[];
|
||||
private store: UseBoundStore<StoreApi<StoreType>>;
|
||||
|
||||
private eventCallBacks?: EventCallBacks<ScopeStateType, ScopeKey>;
|
||||
private saveRequest: SaveRequest<ScopeStateType, ScopeKey>;
|
||||
|
||||
constructor(
|
||||
props: AutosaveManagerProps<StoreType, ScopeKey, ScopeStateType>,
|
||||
) {
|
||||
this.configList = [];
|
||||
this.observerList = [];
|
||||
this.saveRequest = props.saveRequest;
|
||||
this.eventCallBacks = props.eventCallBacks;
|
||||
this.store = props.store;
|
||||
|
||||
this.register(props.registers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册数据源和定义对应的 Observer 配置
|
||||
* @param _config
|
||||
*/
|
||||
public register = (
|
||||
registers: HostedObserverConfig<StoreType, ScopeKey, ScopeStateType>[],
|
||||
) => {
|
||||
this.close();
|
||||
this.configList = [];
|
||||
|
||||
registers.forEach(register => {
|
||||
const config: AutosaveObserverConfig<
|
||||
StoreType,
|
||||
ScopeKey,
|
||||
ScopeStateType
|
||||
> = {
|
||||
...register,
|
||||
eventCallBacks: this.eventCallBacks,
|
||||
saveRequest: this.saveRequest,
|
||||
};
|
||||
this.configList.push(config);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 启动 Manager 模块
|
||||
*/
|
||||
public start = () => {
|
||||
if (this.observerList.length > 0) {
|
||||
return;
|
||||
}
|
||||
this.observerList = this.configList.map(
|
||||
config =>
|
||||
new AutosaveObserver({
|
||||
store: this.store,
|
||||
...config,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭 Manager 模块下的所有属性监听
|
||||
*/
|
||||
public close = () => {
|
||||
this.observerList.forEach(observer => observer.close());
|
||||
this.observerList = [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 手动保存
|
||||
* @param params
|
||||
*/
|
||||
public manualSave = async (key: ScopeKey, params: ScopeStateType) => {
|
||||
const config = this.getConfig(key);
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
const { middleware, eventCallBacks, saveRequest } = config;
|
||||
const beforeSavePayload = middleware?.onBeforeSave
|
||||
? await middleware?.onBeforeSave(params)
|
||||
: params;
|
||||
eventCallBacks?.onBeforeSave?.(beforeSavePayload);
|
||||
|
||||
await saveRequest(beforeSavePayload as ScopeStateType, key, []);
|
||||
|
||||
const afterSavePayload = middleware?.onAfterSave
|
||||
? await middleware?.onAfterSave(params)
|
||||
: params;
|
||||
eventCallBacks?.onAfterSave?.(afterSavePayload);
|
||||
};
|
||||
|
||||
/**
|
||||
* 回调过程中关闭自动保存
|
||||
* @param params
|
||||
*/
|
||||
public handleWithoutAutosave = async (params: {
|
||||
key: ScopeKey;
|
||||
handler: () => Promise<void>;
|
||||
}) => {
|
||||
const { key, handler } = params;
|
||||
|
||||
const observers = this.observerList.filter(o => o.config.key === key);
|
||||
if (observers.length) {
|
||||
observers.forEach(o => (o.lock = true));
|
||||
await handler();
|
||||
observers.forEach(o => (o.lock = false));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 立即触发保存
|
||||
* @param key
|
||||
*/
|
||||
public saveFlush = (key: ScopeKey) => {
|
||||
const observer = this.getObserver(key);
|
||||
observer?.debouncedSaveFunc?.flush?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* 立即触发所有保存
|
||||
* @param key
|
||||
*/
|
||||
public saveFlushAll = () => {
|
||||
this.observerList.forEach(observer =>
|
||||
observer?.debouncedSaveFunc?.flush?.(),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取目标 observer 配置
|
||||
* @param key
|
||||
*/
|
||||
private getObserver = (key: ScopeKey) =>
|
||||
this.observerList.find(i => i.config.key === key);
|
||||
|
||||
/**
|
||||
* 获取目标配置项
|
||||
* @param key
|
||||
*/
|
||||
private getConfig = (key: ScopeKey) =>
|
||||
this.configList.find(i => i.key === key);
|
||||
}
|
||||
253
frontend/packages/studio/autosave/src/core/observer.ts
Normal file
253
frontend/packages/studio/autosave/src/core/observer.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed 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 StoreApi, type UseBoundStore } from 'zustand';
|
||||
import { createSelector } from 'reselect';
|
||||
import { debounce, has, get, type DebouncedFunc } from 'lodash-es';
|
||||
import diff, { type Diff } from 'deep-diff';
|
||||
|
||||
import { isFunction, isObject, getPayloadByFormatter } from '../utils';
|
||||
import {
|
||||
DebounceTime,
|
||||
type AutosaveObserverConfig,
|
||||
type UseStoreType,
|
||||
type PathType,
|
||||
type AutosaveObserverProps,
|
||||
} from '../type/index';
|
||||
|
||||
export class AutosaveObserver<StoreType, ScopeKey, ScopeStateType> {
|
||||
private store: UseBoundStore<StoreApi<StoreType>>;
|
||||
|
||||
public lock: boolean;
|
||||
public debouncedSaveFunc: DebouncedFunc<() => Promise<void>>;
|
||||
public nextState!: ScopeStateType;
|
||||
public prevState!: ScopeStateType;
|
||||
private diff!: Diff<ScopeStateType, ScopeStateType>[];
|
||||
|
||||
private unobserver?: () => void;
|
||||
private unsubscribe!: () => void;
|
||||
public config: AutosaveObserverConfig<StoreType, ScopeKey, ScopeStateType>;
|
||||
|
||||
constructor(
|
||||
props: AutosaveObserverProps<StoreType, ScopeKey, ScopeStateType>,
|
||||
) {
|
||||
const { store, ...config } = props;
|
||||
this.store = store;
|
||||
this.lock = false;
|
||||
this.config = config;
|
||||
|
||||
// 订阅字段初始化
|
||||
this.initSubscribe();
|
||||
}
|
||||
|
||||
private initSubscribe = () => {
|
||||
const memoizeSelector = this.getMemoizeSelector();
|
||||
|
||||
this.unsubscribe = (
|
||||
this.store as unknown as UseStoreType<StoreType, ScopeStateType>
|
||||
).subscribe(memoizeSelector, this.subscribeCallback);
|
||||
};
|
||||
|
||||
private getMemoizeSelector = () => {
|
||||
if (typeof this.config.selector === 'function') {
|
||||
return this.config.selector;
|
||||
} else {
|
||||
// 使用createSelector创建可记忆化的选择器
|
||||
const { deps, transformer } = this.config.selector;
|
||||
return createSelector(deps, transformer);
|
||||
}
|
||||
};
|
||||
|
||||
private subscribeCallback = async (nextState, prevState) => {
|
||||
console.log('nextState :>> ', nextState);
|
||||
console.log('prevState :>> ', prevState);
|
||||
|
||||
// selector 返回的 state
|
||||
this.nextState = nextState;
|
||||
this.prevState = prevState;
|
||||
|
||||
if (this.lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diffChange: Diff<ScopeStateType, ScopeStateType>[] | undefined = diff(
|
||||
prevState,
|
||||
nextState,
|
||||
);
|
||||
|
||||
console.log('diffChange:>>', diffChange);
|
||||
if (!diffChange) {
|
||||
return;
|
||||
}
|
||||
this.debouncedSaveFunc?.cancel?.();
|
||||
|
||||
this.diff = diffChange;
|
||||
|
||||
const delayTime = this.getTriggerDelayTime(prevState, diffChange);
|
||||
|
||||
console.log('delayTime:>>>>>', delayTime);
|
||||
|
||||
if (delayTime === 0 || this.config.immediate) {
|
||||
await this.parsedSaveFunc();
|
||||
return;
|
||||
}
|
||||
this.debouncedSaveFunc = debounce(this.parsedSaveFunc, delayTime);
|
||||
|
||||
await this.debouncedSaveFunc();
|
||||
};
|
||||
|
||||
private parsedSaveFunc = async () => {
|
||||
// 中间件-保存前
|
||||
const beforeSavePayload = await getPayloadByFormatter<ScopeStateType>(
|
||||
this.nextState,
|
||||
this.config?.middleware?.onBeforeSave,
|
||||
);
|
||||
// 生命周期-保存前
|
||||
await this.config?.eventCallBacks?.onBeforeSave?.({
|
||||
key: this.config.key,
|
||||
data: beforeSavePayload,
|
||||
});
|
||||
|
||||
console.log('beforeSavePayload:>>', beforeSavePayload);
|
||||
try {
|
||||
await this.config.saveRequest(
|
||||
beforeSavePayload,
|
||||
this.config.key,
|
||||
this.diff,
|
||||
);
|
||||
|
||||
// 中间件-保存后
|
||||
const afterSavePayload = await getPayloadByFormatter<ScopeStateType>(
|
||||
this.nextState,
|
||||
this.config?.middleware?.onAfterSave,
|
||||
);
|
||||
console.log('afterSavePayload:>>', afterSavePayload);
|
||||
|
||||
// 生命周期-保存后
|
||||
await this.config?.eventCallBacks?.onAfterSave?.({
|
||||
key: this.config.key,
|
||||
data: afterSavePayload,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('error:>>', error);
|
||||
// 生命周期-异常
|
||||
this.config?.eventCallBacks?.onError?.({
|
||||
key: this.config.key,
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
*/
|
||||
public close = () => {
|
||||
this.debouncedSaveFunc?.flush();
|
||||
this.unsubscribe();
|
||||
this.unobserver?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取状态变更带来的触发延时时间
|
||||
* @param prevState selector 选择的 store 的内容
|
||||
* @param diffChange 前后比对的diff
|
||||
* @returns 延时时间
|
||||
*/
|
||||
private getTriggerDelayTime = (
|
||||
prevState?: ScopeStateType,
|
||||
diffChange?: Diff<ScopeStateType, ScopeStateType>[],
|
||||
) => {
|
||||
const configDebounce = this.config.debounce;
|
||||
|
||||
if (!configDebounce) {
|
||||
return DebounceTime.Immediate;
|
||||
}
|
||||
|
||||
if (isFunction(configDebounce)) {
|
||||
return configDebounce();
|
||||
}
|
||||
|
||||
if (!isObject(configDebounce)) {
|
||||
return configDebounce;
|
||||
}
|
||||
|
||||
if (!diffChange || diffChange.length === 0) {
|
||||
return configDebounce.default;
|
||||
}
|
||||
|
||||
const targetDelayTimes: number[] = [];
|
||||
for (const change of diffChange) {
|
||||
const changePath = change.path;
|
||||
const debouncePath = this.getdebouncePath(changePath);
|
||||
|
||||
if (
|
||||
!changePath ||
|
||||
!has(prevState, changePath) ||
|
||||
typeof debouncePath === 'number'
|
||||
) {
|
||||
targetDelayTimes.push(configDebounce.default);
|
||||
continue;
|
||||
}
|
||||
const debounceType = get(
|
||||
configDebounce,
|
||||
debouncePath,
|
||||
configDebounce.default,
|
||||
);
|
||||
if (!isObject(debounceType)) {
|
||||
targetDelayTimes.push(debounceType);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!debounceType.arrayType) {
|
||||
targetDelayTimes.push(configDebounce.default);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isObject(debounceType.action as DebounceTime)) {
|
||||
targetDelayTimes.push(debounceType.action as DebounceTime);
|
||||
} else {
|
||||
const kind =
|
||||
change.kind === 'A' && change.item?.kind
|
||||
? change.item?.kind
|
||||
: change.kind;
|
||||
const triggerKind = debounceType.action[kind];
|
||||
targetDelayTimes.push(triggerKind);
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(...targetDelayTimes);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取变更与 trigger 声明配置对应的 key
|
||||
* @param changePath diff path
|
||||
* @returns path key
|
||||
*/
|
||||
private getdebouncePath = (changePath?: PathType[]) => {
|
||||
if (!changePath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const indexPath = path => typeof path === 'number';
|
||||
const isArrayPath = changePath.some(indexPath);
|
||||
|
||||
if (isArrayPath) {
|
||||
return changePath[0];
|
||||
}
|
||||
|
||||
return changePath.join('.');
|
||||
};
|
||||
}
|
||||
25
frontend/packages/studio/autosave/src/index.ts
Normal file
25
frontend/packages/studio/autosave/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed 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 { AutosaveManager } from './core/manager';
|
||||
export {
|
||||
DebounceTime,
|
||||
EventCallBacks,
|
||||
SaveRequest,
|
||||
HostedObserverConfig,
|
||||
} from './type';
|
||||
|
||||
export type { Diff } from 'deep-diff';
|
||||
152
frontend/packages/studio/autosave/src/type/index.ts
Normal file
152
frontend/packages/studio/autosave/src/type/index.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { type StoreApi, type UseBoundStore } from 'zustand';
|
||||
import type { Diff } from 'deep-diff';
|
||||
|
||||
/**
|
||||
* 防抖延迟时间
|
||||
* @readonly
|
||||
* @enum {number}
|
||||
*/
|
||||
export enum DebounceTime {
|
||||
/** 用于需要立即响应的保存操作,如按钮或下拉选择等操作 */
|
||||
Immediate = 0,
|
||||
/** 用于需要较短时间响应的保存操作,如拖拽等操作 */
|
||||
Medium = 500,
|
||||
/** 适合于文本输入等操作 */
|
||||
Long = 1000,
|
||||
}
|
||||
|
||||
/** trigger 配置声明时的函数形式,用于在运行时指定字段触发时机 */
|
||||
export type FunctionDebounceTime = () => DebounceTime;
|
||||
|
||||
/** trigger 配置声明时的数组形式,用于分别指定数组内容变化时的触发时机 */
|
||||
export interface ArrayDebounceTime {
|
||||
arrayType: boolean;
|
||||
action:
|
||||
| DebounceTime
|
||||
| {
|
||||
N?: DebounceTime;
|
||||
D?: DebounceTime;
|
||||
E?: DebounceTime;
|
||||
};
|
||||
}
|
||||
|
||||
/** trigger 配置声明时的对象形式,用于分别指定多字段触发时机 */
|
||||
export interface ObjectDebounceTime {
|
||||
default: DebounceTime;
|
||||
[index: string]: DebounceTime | ArrayDebounceTime;
|
||||
}
|
||||
|
||||
export type DebounceConfig =
|
||||
| DebounceTime
|
||||
| ObjectDebounceTime
|
||||
| FunctionDebounceTime;
|
||||
|
||||
export type FlexibleState<T> = T | any;
|
||||
|
||||
export type HostedObserverConfig<StoreType, ScopeKey, ScopeStateType> = Omit<
|
||||
AutosaveObserverConfig<StoreType, ScopeKey, ScopeStateType>,
|
||||
'saveRequest' | 'eventCallBacks' | 'unobserver' | 'immediate'
|
||||
>;
|
||||
|
||||
export type AutosaveObserverProps<StoreType, ScopeKey, ScopeStateType> =
|
||||
AutosaveObserverConfig<StoreType, ScopeKey, ScopeStateType> & {
|
||||
store: UseBoundStore<StoreApi<StoreType>>;
|
||||
};
|
||||
|
||||
export type SelectorType<StoreType, ScopeStateType> =
|
||||
| ((store: StoreType) => ScopeStateType)
|
||||
| {
|
||||
deps: ((store: StoreType) => ScopeStateType)[];
|
||||
transformer: (...args: ScopeStateType[]) => FlexibleState<ScopeStateType>;
|
||||
};
|
||||
|
||||
export interface AutosaveObserverConfig<StoreType, ScopeKey, ScopeStateType> {
|
||||
/** 被托管的数据字段的类型 */
|
||||
key: ScopeKey;
|
||||
/** 防抖延迟时间 */
|
||||
debounce?: DebounceConfig;
|
||||
/** store 需要被监听的属性选择器,支持配置依赖 */
|
||||
selector: SelectorType<StoreType, ScopeStateType>;
|
||||
/** 中间件 支持业务链式处理监听数据 */
|
||||
middleware?: MiddlewareHanderMap<ScopeStateType>;
|
||||
/** 是否立即保存当前字段 */
|
||||
immediate?: boolean;
|
||||
/** 保存的请求 */
|
||||
saveRequest: SaveRequest<ScopeStateType, ScopeKey>;
|
||||
/** 被托管的数据取消订阅时进行的回调 */
|
||||
unobserver?: () => void;
|
||||
/** 生命周期 */
|
||||
eventCallBacks?: EventCallBacks<ScopeStateType, ScopeKey>;
|
||||
}
|
||||
|
||||
export type SaveRequest<ScopeStateType, ScopeKey> = (
|
||||
payload: FlexibleState<ScopeStateType>,
|
||||
key: ScopeKey,
|
||||
diff: Diff<ScopeStateType, ScopeStateType>[],
|
||||
) => Promise<FlexibleState<ScopeStateType>>;
|
||||
|
||||
export type SaveMiddlewareHander<ScopeStateType> = (
|
||||
data: FlexibleState<ScopeStateType>,
|
||||
) => Promise<FlexibleState<ScopeStateType>> | FlexibleState<ScopeStateType>;
|
||||
|
||||
export interface MiddlewareHanderMap<ScopeStateType> {
|
||||
/** 生命周期-检测变更后 */
|
||||
onBeforeSave?: SaveMiddlewareHander<ScopeStateType>;
|
||||
/** 生命周期-成功保存后 */
|
||||
onAfterSave?: SaveMiddlewareHander<ScopeStateType>;
|
||||
}
|
||||
|
||||
export interface EventCallBacks<ScopeStateType, ScopeKey> {
|
||||
/** 生命周期-检测变更后 */
|
||||
onBeforeSave?: (params: {
|
||||
data: FlexibleState<ScopeStateType>;
|
||||
key: ScopeKey;
|
||||
}) => void | Promise<void>;
|
||||
/** 生命周期-成功保存后 */
|
||||
onAfterSave?: (params: {
|
||||
data: FlexibleState<ScopeStateType>;
|
||||
key: ScopeKey;
|
||||
}) => void | Promise<void>;
|
||||
/** 生命周期-异常 */
|
||||
onError?: (params: { error: Error; key: ScopeKey }) => void;
|
||||
}
|
||||
|
||||
// 比对出来的被变化的 key 的 path, number 形式对应数组
|
||||
export type PathType = string | number;
|
||||
|
||||
export interface UseStoreType<StoreType, ScopeStateType> {
|
||||
subscribe: {
|
||||
(
|
||||
listener: (
|
||||
selectedState: ScopeStateType,
|
||||
previousSelectedState: ScopeStateType,
|
||||
) => void,
|
||||
): () => void;
|
||||
<U>(
|
||||
selector: (state: StoreType) => U,
|
||||
listener: (selectedState: U, previousSelectedState: U) => void,
|
||||
options?: {
|
||||
equalityFn?: (a: U, b: U) => boolean;
|
||||
fireImmediately?: boolean;
|
||||
},
|
||||
): () => void;
|
||||
};
|
||||
}
|
||||
50
frontend/packages/studio/autosave/src/utils/index.ts
Normal file
50
frontend/packages/studio/autosave/src/utils/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed 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 {
|
||||
isObject as isObjectBase,
|
||||
isFunction as isFunctionBase,
|
||||
} from 'lodash-es';
|
||||
|
||||
import {
|
||||
type DebounceConfig,
|
||||
type ObjectDebounceTime,
|
||||
type SaveMiddlewareHander,
|
||||
type FunctionDebounceTime,
|
||||
} from '../type/index';
|
||||
|
||||
export function isFunction(
|
||||
value: DebounceConfig,
|
||||
): value is FunctionDebounceTime {
|
||||
return isFunctionBase(value);
|
||||
}
|
||||
|
||||
export function isObject(value: DebounceConfig): value is ObjectDebounceTime {
|
||||
return isObjectBase(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取保存接口调用时候需要的参数
|
||||
*/
|
||||
export const getPayloadByFormatter = async <T>(
|
||||
state: T,
|
||||
formatter?: SaveMiddlewareHander<T>,
|
||||
) => {
|
||||
if (formatter) {
|
||||
return await formatter(state);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
33
frontend/packages/studio/autosave/tsconfig.build.json
Normal file
33
frontend/packages/studio/autosave/tsconfig.build.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@coze-arch/ts-config/tsconfig.web.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": [],
|
||||
"module": "ESNext",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../arch/bot-api/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../arch/logger/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/eslint-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/stylelint-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/ts-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/vitest-config/tsconfig.build.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
frontend/packages/studio/autosave/tsconfig.json
Normal file
15
frontend/packages/studio/autosave/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.misc.json"
|
||||
}
|
||||
],
|
||||
"exclude": ["**/*"]
|
||||
}
|
||||
18
frontend/packages/studio/autosave/tsconfig.misc.json
Normal file
18
frontend/packages/studio/autosave/tsconfig.misc.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@coze-arch/ts-config/tsconfig.web.json",
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"include": ["__tests__", "__mocks__", "vitest.config.ts", "setup-vitest.ts"],
|
||||
"exclude": ["./dist"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.build.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"rootDir": "./",
|
||||
"outDir": "./dist",
|
||||
"types": ["vitest/globals"],
|
||||
"module": "ESNext"
|
||||
}
|
||||
}
|
||||
25
frontend/packages/studio/autosave/vitest.config.ts
Normal file
25
frontend/packages/studio/autosave/vitest.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed 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 { defineConfig } from '@coze-arch/vitest-config';
|
||||
|
||||
export default defineConfig({
|
||||
dirname: __dirname,
|
||||
preset: 'web',
|
||||
test: {
|
||||
setupFiles: ['./setup-vitest.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user