diff --git a/package-lock.json b/package-lock.json index 2232faf4..f3fcb95c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@commitlint/cli": "^17.7.2", "@commitlint/config-conventional": "^17.7.0", "@mui/icons-material": "^5.15.11", + "@reduxjs/toolkit": "^2.2.5", "@testing-library/react": "^14.1.2", "@types/jest": "^29.5.11", "@types/react": "^18.2.45", @@ -2491,6 +2492,45 @@ "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", "dev": true }, + "node_modules/@reduxjs/toolkit": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz", + "integrity": "sha512-aeFA/s5NCG7NoJe/MhmwREJxRkDs0ZaSqt0MxhWUrwCf1UQXpwR87RROJEql0uAkLI6U7snBOYOcKw83ew3FPg==", + "dev": true, + "dependencies": { + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "dev": true + }, + "node_modules/@reduxjs/toolkit/node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "dev": true, + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.0.tgz", @@ -6738,6 +6778,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -11540,6 +11590,12 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "dev": true + }, "node_modules/resolve": { "version": "1.22.6", "license": "MIT", @@ -15251,6 +15307,33 @@ "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", "dev": true }, + "@reduxjs/toolkit": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz", + "integrity": "sha512-aeFA/s5NCG7NoJe/MhmwREJxRkDs0ZaSqt0MxhWUrwCf1UQXpwR87RROJEql0uAkLI6U7snBOYOcKw83ew3FPg==", + "dev": true, + "requires": { + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "dependencies": { + "redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "dev": true + }, + "redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "dev": true, + "requires": {} + } + } + }, "@rollup/rollup-android-arm-eabi": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.0.tgz", @@ -18197,6 +18280,12 @@ "version": "5.2.4", "dev": true }, + "immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "dev": true + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -21463,6 +21552,12 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "dev": true + }, "resolve": { "version": "1.22.6", "requires": { diff --git a/package.json b/package.json index 7c0599ad..6d17bd84 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@commitlint/cli": "^17.7.2", "@commitlint/config-conventional": "^17.7.0", "@mui/icons-material": "^5.15.11", + "@reduxjs/toolkit": "^2.2.5", "@testing-library/react": "^14.1.2", "@types/jest": "^29.5.11", "@types/react": "^18.2.45", @@ -76,10 +77,10 @@ "@emotion/styled": "^11.11.0", "@mui/material": "^5.15.11", "@types/mui-datatables": "*", + "@xstate/react": "^4.1.1", "mui-datatables": "*", "react": ">=17", "react-dom": ">=17", - "@xstate/react": "^4.1.1", "xstate": "^5.13.0" }, "peerDependenciesMeta": { diff --git a/src/actors/index.ts b/src/actors/index.ts index 0dffc848..2923105d 100644 --- a/src/actors/index.ts +++ b/src/actors/index.ts @@ -8,7 +8,28 @@ export { selectValidationResults } from './validators/dataValidator'; +export * from './worker'; + +export { + REDUX_COMMANDS, + REDUX_EVENTS, + reduxActor, + reduxCommands, + reduxEvents, + type REXUX_ACTOR_EVENTS +} from './reduxActor'; + +export { + RTK_EVENTS, + rtkQueryActor, + rtkQueryActorCommands, + rtkQueryActorEvents +} from './rtkQueryActor'; + +export const REEE = 'xxx'; + export { + DeferEvents, XSTATE_DEBUG_EVENT, deadLetter, forwardToActors, diff --git a/src/actors/reduxActor.ts b/src/actors/reduxActor.ts new file mode 100644 index 00000000..a4325b6e --- /dev/null +++ b/src/actors/reduxActor.ts @@ -0,0 +1,94 @@ +import { assertEvent, sendTo, setup } from 'xstate'; + +import { Selector, Store, UnknownAction } from '@reduxjs/toolkit'; + +interface ReduxActorContext { + store: Store; + selectors: { + [key: string]: Selector; + }; +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +type extraArgs = any[]; + +export const REDUX_COMMANDS = { + DISPATCH: 'DISPATCH', + GET_STATE: 'GET_STATE', + GET_STATE_FROM_SELECTOR: 'GET_STATE_FROM_SELECTOR', + SUBSCRIBE: 'SUBSCRIBE', + UNSUBSCRIBE: 'UNSUBSCRIBE' +}; + +export const REDUX_EVENTS = { + REDUX_STATE_SNAPSHOT: 'REDUX_STATE_SNAPSHOT' +}; + +export type REXUX_ACTOR_EVENTS = + | { type: 'DISPATCH'; data: { action: UnknownAction } } + | { type: 'SUBSCRIBE' } + | { type: 'UNSUBSCRIBE' } + | { type: 'REDUX_STATE_SNAPSHOT'; data: { snapshot: unknown } } + | { type: 'GET_STATE'; returnAddress: string; data: { key: string } } + | { + type: 'GET_STATE_FROM_SELECTOR'; + returnAddress: string; + data: { selector: string; extraArgs?: extraArgs }; + }; + +export const reduxEvents = { + stateSnapshot: (snapshot: unknown) => ({ + type: 'STATE_SNAPSHOT', + data: { snapshot } + }) +}; + +export const reduxCommands = { + dispatch: (action: UnknownAction) => ({ + type: 'DISPATCH', + data: { action } + }), + + getState: (key: string, returnAddress: string) => ({ + type: 'GET_STATE', + returnAddress, + data: { key } + }), + + getStateFromSelector: (selector: string, returnAddress: string, extraArgs?: extraArgs) => ({ + type: 'GET_STATE_FROM_SELECTOR', + returnAddress, + data: { selector, extraArgs } + }) +}; + +export const reduxActor = setup({ + types: { + context: {} as ReduxActorContext, + input: {} as ReduxActorContext, + events: {} as REXUX_ACTOR_EVENTS + } +}).createMachine({ + initial: 'idle', + context: ({ input }) => input, + states: { + idle: { + on: { + DISPATCH: { + actions: [({ event, context }) => context.store.dispatch(event.data.action)] + }, + GET_STATE_FROM_SELECTOR: { + actions: sendTo( + ({ event }) => event.returnAddress, + ({ context, event }) => { + assertEvent(event, 'GET_STATE_FROM_SELECTOR'); + const selector = context.selectors[event.data.selector]; + const snapshot = selector(context.store.getState(), ...(event.data.extraArgs || [])); + return reduxEvents.stateSnapshot(snapshot); + } + ) + } + } + } + } +}); diff --git a/src/actors/rtkQueryActor.ts b/src/actors/rtkQueryActor.ts new file mode 100644 index 00000000..7e701d99 --- /dev/null +++ b/src/actors/rtkQueryActor.ts @@ -0,0 +1,152 @@ +import { Store } from '@reduxjs/toolkit'; +import { Api, EndpointDefinitions, QueryResultSelectorResult } from '@reduxjs/toolkit/query/react'; +import { DoneActorEvent, ErrorActorEvent, EventObject, fromPromise, sendTo, setup } from 'xstate'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +interface RtkQueryActorContext { + api: Api; + store: Store; +} + +interface RtkQueryActorInput { + api: Api; + store: Store; +} + +interface initiateQueryEvent extends EventObject { + type: 'INITIATE_QUERY'; + data: { + endpointName: string; + params: unknown; + }; + returnAddress: string; +} + +interface queryDoneEvent extends DoneActorEvent { + /* eslint-disable @typescript-eslint/no-explicit-any */ + output: { + result: QueryResultSelectorResult; + sourceEvent: initiateQueryEvent; + }; +} + +interface queryFailedEvent extends ErrorActorEvent { + /* eslint-disable @typescript-eslint/no-explicit-any */ + error: { + result: QueryResultSelectorResult; + sourceEvent: initiateQueryEvent; + }; +} + +interface QueryResultEvent extends EventObject { + type: 'QUERY_RESULT'; + data: { + result: unknown; + }; +} + +interface QueryFailedEvent extends EventObject { + type: 'QUERY_FAILED'; + data: { + error: unknown; + }; +} + +export const rtkQueryActorCommands = { + initiateQuery: ({ + endpointName, + params, + returnAddress + }: { + endpointName: string; + params: unknown; + returnAddress: string; + }): initiateQueryEvent => ({ + type: 'INITIATE_QUERY', + data: { endpointName, params }, + returnAddress + }) +}; + +export const rtkQueryActorEvents = { + queryResult: ({ result }: { result: unknown }): QueryResultEvent => ({ + type: 'QUERY_RESULT', + data: { result } + }), + queryFailed: ({ error }: { error: unknown }): QueryFailedEvent => ({ + type: 'QUERY_FAILED', + data: { error } + }) +}; + +export const RTK_EVENTS = { + QUERY_RESULT: 'QUERY_RESULT', + QUERY_FAILED: 'QUERY_FAILED' +}; + +type EVENTS = initiateQueryEvent | queryDoneEvent | queryFailedEvent; + +interface InitiateQueryActorInput { + context: RtkQueryActorContext; + event: initiateQueryEvent; +} +const InitiateQueryActor = fromPromise(async ({ input }: { input: InitiateQueryActorInput }) => { + const { event, context } = input; + console.log('event.data.endpointName', event, context); + const result = await context.store.dispatch( + (context.api.endpoints[event.data.endpointName] as any).initiate(event.data.params) + ); + return { + result, + sourceEvent: event + }; +}); + +export const rtkQueryActor = setup({ + types: { + context: {} as RtkQueryActorContext, + input: {} as RtkQueryActorInput, + events: {} as EVENTS + }, + actors: { + InitiateQueryActor + } +}).createMachine({ + initial: 'idle', + context: ({ input }) => input, + states: { + idle: { + on: { + INITIATE_QUERY: { + target: 'querying' + } + } + }, + querying: { + invoke: { + id: 'initiateQuery', + input: ({ context, event }) => ({ + context, + event: event as initiateQueryEvent + }), + src: 'InitiateQueryActor', + onDone: { + actions: sendTo( + ({ event }) => event.output.sourceEvent.returnAddress, + ({ event }) => rtkQueryActorEvents.queryResult({ result: event.output }) + ), + target: 'idle' + }, + onError: { + actions: sendTo( + ({ event }) => (event as queryFailedEvent).error.sourceEvent.returnAddress, + ({ event }) => rtkQueryActorEvents.queryFailed({ error: event.error }) + ), + + target: 'idle' + } + } + } + } +}); diff --git a/src/actors/utils.ts b/src/actors/utils.ts index 1854b7da..1deb016a 100644 --- a/src/actors/utils.ts +++ b/src/actors/utils.ts @@ -1,7 +1,7 @@ // disbale stict no any for now for full file /* eslint-disable @typescript-eslint/no-explicit-any */ -import { AnyActorRef, AnyEventObject, enqueueActions, sendTo } from 'xstate'; +import { AnyActorRef, AnyEventObject, assign, enqueueActions, sendTo } from 'xstate'; import { AnyActorSystem } from 'xstate/dist/declarations/src/system'; type ContextWithReturnAddress = { returnAddress: AnyActorRef }; @@ -43,3 +43,35 @@ export const reply = (eventFn: (actionArgs: any, params: any) => AnyEventObject) sendTo(({ context }: { context: ContextWithReturnAddress }) => context.returnAddress, eventFn); export const XSTATE_DEBUG_EVENT = 'XSTATE_DEBUG_EVENT'; + +type deferredEventsQueue = AnyEventObject[]; + +interface DeferredEventsQueueContext { + deferredEventsQueue: deferredEventsQueue; +} + +interface deferActionParams { + event: AnyEventObject; + context: DeferredEventsQueueContext; +} + +const defer = assign({ + deferredEventsQueue: ({ context: { deferredEventsQueue }, event }: deferActionParams) => [ + ...deferredEventsQueue, + event + ] +}); + +const recall = enqueueActions(({ context: { deferredEventsQueue }, enqueue }) => { + enqueue.assign({ + deferredEventsQueue: [] + }); + for (const event of deferredEventsQueue) { + enqueue.raise(event); + } +}); + +export const DeferEvents = { + defer, + recall +}; diff --git a/src/actors/validators/dataValidator.ts b/src/actors/validators/dataValidator.ts index e7cbde67..50dc47b9 100644 --- a/src/actors/validators/dataValidator.ts +++ b/src/actors/validators/dataValidator.ts @@ -168,13 +168,14 @@ export const dataValidatorMachine = setup({ waiting: {}, debouncing: { after: { - debounceTimeout: '#validationMachine.validatingData' + debounceTimeout: '#validatingData' } } } }, validatingData: { + id: 'validatingData', invoke: { src: 'ValidateActor', input: ({ context }: { context: ValidationMachineContext }) => ({ @@ -224,7 +225,7 @@ export const dataValidatorMachine = setup({ type ValidationMachineSnapshot = SnapshotFrom; export const selectValidationResults = (state: ValidationMachineSnapshot) => - state.context.validationResults; + state.context?.validationResults; export const selectIsValidating = (state: ValidationMachineSnapshot) => state.matches('validatingData'); diff --git a/src/actors/worker/README.md b/src/actors/worker/README.md new file mode 100644 index 00000000..e69de29b diff --git a/src/actors/worker/events.ts b/src/actors/worker/events.ts new file mode 100644 index 00000000..472120b9 --- /dev/null +++ b/src/actors/worker/events.ts @@ -0,0 +1,47 @@ +import { AnyEventObject } from 'xstate'; + +export const WORKER_COMMANDS = { + START_ACTOR: 'START_ACTOR', + STOP_ACTOR: 'STOP_ACTOR', + SEND_EVENT: 'SEND_EVENT', + GET_STATE: 'GET_STATE' +}; + +export const workerCommands = { + startActor: () => ({ type: WORKER_COMMANDS.START_ACTOR }), + stopActor: () => ({ type: WORKER_COMMANDS.STOP_ACTOR }), + sendEvent: (event: AnyEventObject) => ({ type: WORKER_COMMANDS.SEND_EVENT, event }), + getState: () => ({ type: WORKER_COMMANDS.GET_STATE }) +}; + +export interface PROXY_EVENT { + type: 'PROXY_EVENT'; + data: { + event: AnyEventObject; + to: string; + }; +} + +export interface STATE_SNAPSHOT_EVENT { + type: 'STATE_SNAPSHOT'; + data: { + snapshot: unknown; + }; +} + +export const workerEvents = { + proxyEvent: (event: AnyEventObject, to: string) => ({ + type: 'PROXY_EVENT', + data: { event, to } + }), + + stateSnapshot: (snapshot: unknown) => ({ + type: 'STATE_SNAPSHOT', + data: { snapshot } + }) +}; + +export const WORKER_EVENTS = { + STATE_SNAPSHOT: 'STATE_SNAPSHOT', + PROXY_EVENT: 'PROXY_EVENT' +}; diff --git a/src/actors/worker/fromWorkerfiedActor.ts b/src/actors/worker/fromWorkerfiedActor.ts new file mode 100644 index 00000000..4d3f7dd0 --- /dev/null +++ b/src/actors/worker/fromWorkerfiedActor.ts @@ -0,0 +1,120 @@ +import { + ActorLogic, + AnyEventObject, + AnyMachineSnapshot, + EventObject, + NonReducibleUnknown, + StateValue, + matchesState +} from 'xstate'; +import { AnyActorSystem } from 'xstate/dist/declarations/src/system'; +import { STATE_SNAPSHOT_EVENT, WORKER_EVENTS, workerCommands } from './events'; + +const instanceStates = /* #__PURE__ */ new WeakMap(); + +type WorkerInput = NonReducibleUnknown; + +type WorkerSnapshot = AnyMachineSnapshot; +type WorkerActorLogic = ActorLogic< + WorkerSnapshot, + TEvent, + TInput, + AnyActorSystem, + EventObject // TEmitted +>; + +interface ProxyEvent { + type: 'PROXY_EVENT'; + data: { + event: AnyEventObject; + to: string; + }; +} + +export const fromWorkerfiedActor = ( + worker: Worker +): WorkerActorLogic => ({ + config: Worker, + + start: (state, actorScope) => { + const { self, system } = actorScope; + console.log('Starting fromWorkerActor [+]', state, actorScope); + worker.postMessage(workerCommands.startActor()); + const workerState = { + worker, + snapshot: null + }; + + worker.addEventListener('message', (event) => { + const eventFromWorker = event.data as AnyEventObject; + if (eventFromWorker.type == 'STATE_SNAPSHOT') { + self.send(eventFromWorker); + return state; + } + + if (event.type === WORKER_EVENTS.PROXY_EVENT) { + const proxyEvent = event as ProxyEvent; + if (proxyEvent.data.to === 'parent' && self._parent) { + console.log('Relaying to parent', proxyEvent.data); + self._parent.send(proxyEvent.data.event); + return state; + } + + system.get(proxyEvent.data.to).send(proxyEvent.data.event); + return state; + } + }); + + instanceStates.set(self, workerState); + }, + transition: (state, event, actorScope) => { + const { self } = actorScope; + const workerState = instanceStates.get(self); + if (event.type === 'xstate.stop') { + console.log('Stopping fromWorkerActor...', state, event, actorScope); + workerState.worker.postMessage(workerCommands.stopActor()); + workerState.worker.terminate(); + return { + ...state, + status: 'stopped', + error: undefined + }; + } + if (event.type == WORKER_EVENTS.STATE_SNAPSHOT) { + const snapshot = (event as STATE_SNAPSHOT_EVENT).data.snapshot as AnyMachineSnapshot; + return { + ...state, + ...(snapshot || {}) + }; + } + + try { + workerState.worker.postMessage(workerCommands.sendEvent(event)); + } catch (error) { + console.error('Error sending event to worker', error, event); + } + const nextState = { + ...state + }; + return nextState; + }, + getInitialSnapshot: (_, input) => { + return { + status: 'active', + output: undefined, + error: undefined, + value: 'created', + input, + tags: [], + historyValue: undefined, + context: {}, + matches: function (value: StateValue) { + const currentValue = (this as WorkerSnapshot).value; + return matchesState(value, currentValue); + } + } as unknown as AnyMachineSnapshot; + }, + + getPersistedSnapshot: (snapshot) => snapshot, + restoreSnapshot: (snapshot) => snapshot as WorkerSnapshot +}); diff --git a/src/actors/worker/index.ts b/src/actors/worker/index.ts new file mode 100644 index 00000000..1f69094c --- /dev/null +++ b/src/actors/worker/index.ts @@ -0,0 +1,2 @@ +export { fromWorkerfiedActor } from './fromWorkerfiedActor'; +export { workerfyActor } from './workerfy'; diff --git a/src/actors/worker/workerfy.ts b/src/actors/worker/workerfy.ts new file mode 100644 index 00000000..5308eb7c --- /dev/null +++ b/src/actors/worker/workerfy.ts @@ -0,0 +1,92 @@ +import { AnyActorLogic, AnyActorRef, Subscription, createActor, setup } from 'xstate'; +import { WORKER_COMMANDS, workerEvents } from './events'; + +interface ProxyActorContext { + proxyToId: string; +} + +interface ProxyActorInput { + proxyToId: string; +} + +const ProxyActor = setup({ + types: { + context: {} as ProxyActorContext, + input: {} as ProxyActorInput + } +}).createMachine({ + id: 'proxy-actor', + initial: 'idle', + context: ({ input }) => ({ + proxyToId: input.proxyToId + }), + + states: { + idle: { + on: { + '*': { + actions: [ + ({ event, context }) => console.log('Proxying event', event, 'to', context.proxyToId), + ({ event, context }) => postMessage(workerEvents.proxyEvent(event, context.proxyToId)) + ] + } + } + } + } +}); + +const syncSnapshot = (actorRef: AnyActorRef) => { + return actorRef.subscribe((snapshot) => { + const jsonSnapshot = snapshot.toJSON(); + delete jsonSnapshot.children; // children are not serializable + try { + postMessage(workerEvents.stateSnapshot(jsonSnapshot)); + } catch (error) { + console.error('Error sending snapshot from worker', error, jsonSnapshot); + } + }); +}; + +export const workerfyActor = (actor: AnyActorLogic) => { + let actorRef: AnyActorRef | null = null; + let snapshotSubscription: Subscription | null = null; + const parentProxy = createActor(ProxyActor, { + input: { + proxyToId: 'parent' + } + }).start(); + + addEventListener('message', (event) => { + if (event.data.type === WORKER_COMMANDS.START_ACTOR) { + actorRef = createActor(actor, { + input: event.data.input, + parent: parentProxy + }); + + snapshotSubscription = syncSnapshot(actorRef); + actorRef.start(); + } + + if (event.data.type === WORKER_COMMANDS.STOP_ACTOR) { + snapshotSubscription?.unsubscribe && snapshotSubscription.unsubscribe(); + actorRef?.stop && actorRef.stop(); + } + + if (event.data.type === WORKER_COMMANDS.SEND_EVENT) { + if (!actorRef) { + throw new Error('Cannot send event to uninitialized actor'); + } + actorRef.send(event.data.event); + } + + if (event.data.type === WORKER_COMMANDS.GET_STATE) { + if (!actorRef) { + throw new Error('Cannot get state of uninitialized actor'); + } + const snapshot = actorRef.getSnapshot().toJSON(); + postMessage(workerEvents.stateSnapshot(snapshot)); + } + }); + + return actorRef; +}; diff --git a/src/base/index.tsx b/src/base/index.tsx index b34b4228..0f7d8850 100644 --- a/src/base/index.tsx +++ b/src/base/index.tsx @@ -37,6 +37,7 @@ export * from './Grow'; export * from './IconButton'; export * from './Input'; export * from './InputLabel'; +export * from './Link'; export * from './List'; export * from './ListItem'; export * from './ListItemAvatar'; @@ -54,6 +55,7 @@ export * from './Paper'; export * from './Popper'; export * from './RadioGroup'; export * from './Select'; +export * from './Slide'; export * from './Stack'; export * from './Switch'; export * from './Tab'; diff --git a/src/custom/Feedback/FeedbackButton.tsx b/src/custom/Feedback/FeedbackButton.tsx index b244d9b4..f47f9157 100644 --- a/src/custom/Feedback/FeedbackButton.tsx +++ b/src/custom/Feedback/FeedbackButton.tsx @@ -10,6 +10,7 @@ import { } from '../../icons'; import { CULTURED } from '../../theme'; import { CustomTooltip } from '../CustomTooltip'; +import { ModalButtonPrimary } from '../Modal'; import { ModalCard } from '../ModalCard'; import { ActionWrapper, @@ -21,7 +22,6 @@ import { FeedbackMiniIcon, FeedbackOptionButton, FeedbackOptions, - FeedbackSubmitButton, FeedbackTextArea, HelperWrapper, InnerComponentWrapper, @@ -166,14 +166,14 @@ const FeedbackComponent: React.FC = ({ We may email you for more information or updates - Send - + } leftHeaderIcon={} diff --git a/src/custom/Feedback/style.tsx b/src/custom/Feedback/style.tsx index a17e1a18..9b973e16 100644 --- a/src/custom/Feedback/style.tsx +++ b/src/custom/Feedback/style.tsx @@ -218,7 +218,7 @@ export const FeedbackMessage = styled(Box)(({ isOpen, them position: 'relative', bottom: isOpen ? '0px' : '-240px', right: '0', - color: BLACK, + color: theme.palette.text.default, backgroundColor: theme.palette.mode === 'dark' ? DARK_JUNGLE_GREEN : WHITE, border: `1px solid ${MEDIUM_GREY}`, padding: '20px', diff --git a/src/custom/LearningCard/LearningCard.tsx b/src/custom/LearningCard/LearningCard.tsx index 2fcf0343..9ef01a1c 100644 --- a/src/custom/LearningCard/LearningCard.tsx +++ b/src/custom/LearningCard/LearningCard.tsx @@ -79,7 +79,7 @@ const LearningCard: React.FC = ({ tutorial, path, courseCount, courseType

{courseCount} {courseType} - {courseCount === 1 ? '' : 's'} + {courseCount > 1 ? 's' : ''}

diff --git a/src/custom/Modal/index.tsx b/src/custom/Modal/index.tsx index 21fd2daf..4b6aaa1d 100644 --- a/src/custom/Modal/index.tsx +++ b/src/custom/Modal/index.tsx @@ -1,4 +1,4 @@ -import { DialogProps, styled } from '@mui/material'; +import { ButtonProps, DialogProps, styled } from '@mui/material'; import React, { useRef, useState } from 'react'; import { Box, Dialog, IconButton, Paper, Typography } from '../../base'; import { ContainedButton, OutlinedButton, TextButton } from '../../base/Button/Button'; @@ -172,8 +172,12 @@ export const ModalFooter: React.FC = ({ helpText, children, va ); }; +interface ModalButtonPrimaryProps extends ButtonProps { + isOpen?: boolean; +} + // ModalButtonPrimary -export const ModalButtonPrimary = styled(ContainedButton)(({ theme }) => ({ +export const ModalButtonPrimary = styled(ContainedButton)(({ theme }) => ({ backgroundColor: theme.palette.background.brand?.default, color: theme.palette.text.constant?.white, '&:hover': { diff --git a/src/custom/ModalCard/style.tsx b/src/custom/ModalCard/style.tsx index efc920f1..7552e297 100644 --- a/src/custom/ModalCard/style.tsx +++ b/src/custom/ModalCard/style.tsx @@ -1,16 +1,9 @@ import { styled } from '@mui/material'; import { Typography } from '../../base'; -import { - BLACK, - BUTTON_MODAL, - BUTTON_MODAL_DARK, - SLIGHT_BLACK_2, - SLIGHT_BLUE, - WHITE -} from '../../theme/colors/colors'; +import { WHITE } from '../../theme/colors/colors'; export const ContentContainer = styled('div')(({ theme }) => ({ - backgroundColor: theme.palette.background.default + backgroundColor: theme.palette.background.surfaces })); export const ModalWrapper = styled('div')(() => ({ @@ -18,39 +11,27 @@ export const ModalWrapper = styled('div')(() => ({ borderRadius: '5px' })); -export const ButtonContainer = styled('div')(({ theme }) => { - const startColor = theme.palette.mode === 'light' ? BUTTON_MODAL : BLACK; - const endColor = theme.palette.mode === 'light' ? SLIGHT_BLUE : SLIGHT_BLACK_2; +export const ButtonContainer = styled('div')(() => ({ + padding: '1.25rem 1rem', + display: 'flex', + justifyContent: 'flex-end', - return { - padding: '1.25rem 1rem', - display: 'flex', - justifyContent: 'flex-end', + background: 'linear-gradient(90deg, #3B687B 0%, #507D90 100%)', + boxShadow: 'inset 0px 3px 5px 0px rgba(0,0,0,0.25)', + position: 'relative', + zIndex: '100' +})); - background: `linear-gradient(90deg, ${startColor}, ${endColor})`, - boxShadow: 'inset 0px 3px 5px 0px rgba(0,0,0,0.25)', - position: 'relative', - zIndex: '100' - }; -}); export const HeaderTypography = styled(Typography)({ fontSize: '18px' }); -export const HeaderModal = styled('div')(({ theme }) => { - const startColor = theme.palette.mode === 'light' ? BUTTON_MODAL : BLACK; - const endColor = theme.palette.mode === 'light' ? SLIGHT_BLUE : SLIGHT_BLACK_2; - return { - display: 'flex', - borderRadius: '5px 5px 0px 0px', - justifyContent: 'space-between', - padding: '11px 16px', - height: '52px', - fill: WHITE, - boxShadow: 'inset 0px -1px 3px 0px rgba(0,0,0,0.2)', - background: `linear-gradient(90deg, ${startColor}, ${endColor})`, - filter: - theme.palette.mode === 'light' - ? `progid:DXImageTransform.Microsoft.gradient(startColorstr='${BUTTON_MODAL}',endColorstr='${SLIGHT_BLUE}',GradientType=1)` - : `progid:DXImageTransform.Microsoft.gradient(startColorstr='${BUTTON_MODAL_DARK}',GradientType=1)` - }; -}); +export const HeaderModal = styled('div')(() => ({ + display: 'flex', + borderRadius: '5px 5px 0px 0px', + justifyContent: 'space-between', + padding: '11px 16px', + height: '52px', + fill: WHITE, + boxShadow: 'inset 0px -1px 3px 0px rgba(0,0,0,0.2)', + background: 'linear-gradient(90deg, #3B687B 0%, #507D90 100%)' +})); diff --git a/src/custom/ResponsiveDataTable.tsx b/src/custom/ResponsiveDataTable.tsx index 07fd470f..22d5bf67 100644 --- a/src/custom/ResponsiveDataTable.tsx +++ b/src/custom/ResponsiveDataTable.tsx @@ -1,6 +1,124 @@ +import { Theme, ThemeProvider, createTheme } from '@mui/material'; import MUIDataTable from 'mui-datatables'; import React, { useCallback } from 'react'; +const dataTableTheme = (theme: Theme) => + createTheme({ + components: { + MuiTable: { + styleOverrides: { + root: { + // border: `2px solid ${theme.palette.border.normal}`, + width: '-webkit-fill-available', + '@media (max-width: 500px)': { + wordWrap: 'break-word' + }, + background: theme.palette.background.constant?.table, + color: theme.palette.text.default + } + } + }, + MUIDataTableHeadCell: { + styleOverrides: { + data: { + fontWeight: 'bold', + textTransform: 'uppercase', + color: theme.palette.text.default + }, + root: { + fontWeight: 'bold', + textTransform: 'uppercase', + color: theme.palette.text.default + } + } + }, + MUIDataTableSearch: { + styleOverrides: { + main: { + '@media (max-width: 600px)': { + justifyContent: 'center' + } + } + } + }, + MuiCheckbox: { + styleOverrides: { + root: { + intermediate: false, + color: 'transparent', + '&.Mui-checked': { + color: theme.palette.text.default, + '& .MuiSvgIcon-root': { + width: '1.25rem', + height: '1.25rem', + borderColor: theme.palette.border.brand, + marginLeft: '0px', + padding: '0px' + } + }, + '&.MuiCheckbox-indeterminate': { + color: theme.palette.background.brand?.default + }, + '& .MuiSvgIcon-root': { + width: '1.25rem', + height: '1.25rem', + border: `.75px solid ${theme.palette.border.strong}`, + borderRadius: '2px', + padding: '0px' + }, + '&:hover': { + backgroundColor: 'transparent' + }, + '&.Mui-disabled': { + '&:hover': { + cursor: 'not-allowed' + } + } + } + } + }, + MuiTableCell: { + styleOverrides: { + body: { + color: theme.palette.text.default + }, + root: { + borderBottom: `1px solid ${theme.palette.border.default}` + } + } + }, + MUIDataTablePagination: { + styleOverrides: { + toolbar: { + color: theme.palette.text.default + } + } + }, + MUIDataTableSelectCell: { + styleOverrides: { + headerCell: { + background: theme.palette.background.constant?.table + } + } + }, + MuiInput: { + styleOverrides: { + root: { + '&:before': { + borderBottom: `2px solid ${theme.palette.border.brand}` + }, + '&.Mui-focused:after': { + borderBottom: `2px solid ${theme.palette.border.brand}` + }, + '&:hover:not(.Mui-disabled):before': { + borderBottom: `2px solid ${theme.palette.border.brand}` + } + } + } + } + } + }); + export interface Column { name: string; label: string; @@ -130,14 +248,16 @@ const ResponsiveDataTable = ({ }; return ( - + + + ); }; diff --git a/src/custom/TOCChapter/style.tsx b/src/custom/TOCChapter/style.tsx index 761e1860..0819f465 100644 --- a/src/custom/TOCChapter/style.tsx +++ b/src/custom/TOCChapter/style.tsx @@ -44,7 +44,7 @@ export const TOCWrapper = styled('div')(({ theme }) => ({ MozPaddingStart: '2.65rem', '&::after': { position: 'absolute', - inset: '1rem auto 1rem 31px', + inset: '1rem auto 1rem 1.7rem', width: 'auto', height: 'auto', borderLeft: `1px solid rgba(177, 182, 184, 0.25)`, @@ -77,52 +77,5 @@ export const TOCWrapper = styled('div')(({ theme }) => ({ } } } - }, - '@media(max-width: 992px)': { - '.toc-list ul': { - '&::after': { - inset: '1rem auto 1rem 32.4px' - } - } - }, - '@media(max-width: 767px)': { - position: 'initial', - '.toc-list ul': { - display: 'flex', - flexFlow: 'wrap', - margin: '1.5rem 0', - flexDirection: 'column', - paddingInlineStart: '0rem', - '&::after': { - display: 'none' - }, - '& li': { - listStyleType: 'none', - margin: '0.5rem', - display: 'none' - } - }, - '.toc-ul': { - opacity: 0, - height: 0, - transition: 'none', - paddingLeft: '1rem' - }, - '.toc-ul-open': { - height: 'auto', - opacity: 1, - transition: 'all .4s', - '& li': { - display: 'inline-block' - } - }, - '.chapter-back': { - '& h4': { - margin: '0 1rem' - }, - '.toc-toggle-btn': { - display: 'flex' - } - } } })); diff --git a/src/icons/CatalogIcon/CatalogIcon.tsx b/src/icons/CatalogIcon/CatalogIcon.tsx new file mode 100644 index 00000000..6129d27a --- /dev/null +++ b/src/icons/CatalogIcon/CatalogIcon.tsx @@ -0,0 +1,65 @@ +import { FC } from 'react'; +import { DEFAULT_HEIGHT, DEFAULT_WIDTH } from '../../constants/constants'; +import { CARIBBEAN_GREEN, DARK_SLATE_GRAY, KEPPEL, WHITE, useTheme } from '../../theme'; +import { IconProps } from '../types'; + +type CatalogIconProps = { + primaryFill?: string; + secondaryFill?: string; + tertiaryFill?: string; +} & IconProps; + +export const CatalogIcon: FC = ({ + width = DEFAULT_WIDTH, + height = DEFAULT_HEIGHT, + primaryFill, + secondaryFill, + tertiaryFill = WHITE, + style = {}, + ...props +}) => { + const theme = useTheme(); + const themeMode = theme?.palette?.mode ?? 'light'; + + const themePrimaryFill = primaryFill ?? (themeMode === 'dark' ? KEPPEL : DARK_SLATE_GRAY); + const themeSecondaryFill = secondaryFill ?? (themeMode === 'dark' ? CARIBBEAN_GREEN : KEPPEL); + + return ( + + + + + + + + ); +}; + +export default CatalogIcon; diff --git a/src/icons/CatalogIcon/index.ts b/src/icons/CatalogIcon/index.ts new file mode 100644 index 00000000..95db9801 --- /dev/null +++ b/src/icons/CatalogIcon/index.ts @@ -0,0 +1 @@ +export { default as CatalogIcon } from './CatalogIcon'; diff --git a/src/icons/index.ts b/src/icons/index.ts index 7543da19..9ac694d7 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -3,6 +3,7 @@ export * from './AddCircle'; export * from './Application'; export * from './Bell'; export * from './Bus'; +export * from './CatalogIcon'; export * from './Chevron'; export * from './Circle'; export * from './Clone'; diff --git a/src/theme/ThemeProvider.tsx b/src/theme/ThemeProvider.tsx index 8b369e13..488e2ee9 100644 --- a/src/theme/ThemeProvider.tsx +++ b/src/theme/ThemeProvider.tsx @@ -1,5 +1,5 @@ import { EmotionCache } from '@emotion/react'; -import { CssBaseline, PaletteMode, Theme, ThemeProvider } from '@mui/material'; +import { CssBaseline, Interactiveness, PaletteMode, Theme, ThemeProvider } from '@mui/material'; import React from 'react'; import { createCustomTheme } from './theme'; @@ -13,15 +13,19 @@ export interface SistentThemeProviderProps { children: React.ReactNode; emotionCache?: EmotionCache; initialMode?: PaletteMode; + customTheme?: Interactiveness; } function SistentThemeProvider({ children, emotionCache, - initialMode = 'light' + initialMode = 'light', + customTheme }: SistentThemeProviderProps): JSX.Element { - const theme = React.useMemo(() => createCustomTheme(initialMode), [initialMode]); - + const theme = React.useMemo( + () => createCustomTheme(initialMode, customTheme), + [initialMode, customTheme] + ); return ( @@ -38,7 +42,6 @@ export function SistentThemeProviderWithoutBaseLine({ initialMode = 'light' }: SistentThemeProviderProps): JSX.Element { const theme = React.useMemo(() => createCustomTheme(initialMode), [initialMode]); - return ( {children} diff --git a/src/theme/palette.ts b/src/theme/palette.ts index 3369d760..062246e0 100644 --- a/src/theme/palette.ts +++ b/src/theme/palette.ts @@ -33,6 +33,7 @@ declare module '@mui/material/styles' { constant?: { disabled: string; white: string; + table: string; }; inverse?: string; brand?: Interactiveness; @@ -84,6 +85,7 @@ declare module '@mui/material/styles' { constant?: { white: string; disabled: string; + table: string; }; inverse?: string; brand?: Interactiveness; @@ -116,6 +118,7 @@ declare module '@mui/material/styles' { constant?: { white: string; disabled: string; + table: string; }; inverse?: string; brand?: Interactiveness; @@ -252,7 +255,8 @@ export const lightModePalette: PaletteOptions = { code: Colors.charcoal[90], constant: { white: Colors.accentGrey[100], - disabled: Colors.charcoal[70] + disabled: Colors.charcoal[70], + table: Colors.charcoal[100] }, surfaces: Colors.accentGrey[100] }, @@ -365,7 +369,8 @@ export const darkModePalette: PaletteOptions = { code: Colors.accentGrey[90], constant: { white: Colors.accentGrey[100], - disabled: Colors.charcoal[70] + disabled: Colors.charcoal[70], + table: '#363636' }, surfaces: Colors.accentGrey[10] }, diff --git a/src/theme/theme.ts b/src/theme/theme.ts index 76b6dfdd..71a42e9b 100644 --- a/src/theme/theme.ts +++ b/src/theme/theme.ts @@ -1,15 +1,24 @@ -import { PaletteMode, createTheme } from '@mui/material'; +import { Interactiveness, PaletteMode, createTheme } from '@mui/material'; import { components } from './components'; import { darkModePalette, lightModePalette } from './palette'; import { typography } from './typography'; export const drawerWidth = 240; -export const createCustomTheme = (mode: PaletteMode) => { +export const createCustomTheme = (mode: PaletteMode, brandPalette?: Interactiveness) => { + const basePalette = mode == 'light' ? lightModePalette : darkModePalette; + const themePalette = brandPalette + ? Object.assign({}, basePalette, { + background: { + brand: brandPalette + } + }) + : basePalette; + return createTheme({ palette: { mode, - ...(mode === 'light' ? lightModePalette : darkModePalette) + ...themePalette }, components, typography: typography(mode),