Last active
March 26, 2020 22:42
-
-
Save UberMouse/7f944e46cc4be00fc332d80b1b58a3d6 to your computer and use it in GitHub Desktop.
RxJS + XState
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { isArray, some } from "lodash"; | |
import { Observable } from "rxjs"; | |
import { useObservable } from "rxjs-hooks"; | |
import { | |
interpret, | |
EventObject, | |
StateMachine, | |
Typestate, | |
StateSchema, | |
Interpreter, | |
StateValue | |
} from "xstate"; | |
import { pluck } from "../../rxjs"; | |
type TransitionMessage< | |
TContext, | |
TEvent extends EventObject, | |
TTypestate extends Typestate<TContext>, | |
TState extends TTypestate["value"] | |
> = { | |
context: TContext; | |
event: TEvent; | |
matches: (state: TState | TState[]) => boolean; | |
state: string; | |
}; | |
type MatchProps< | |
TContext, | |
TStateSchema extends StateSchema, | |
TEvent extends EventObject, | |
TTypestate extends Typestate<TContext>, | |
TState extends TTypestate["value"], | |
TInterpreter extends Interpreter<TContext, TStateSchema, TEvent, TTypestate> = Interpreter< | |
TContext, | |
TStateSchema, | |
TEvent, | |
TTypestate | |
> | |
> = { | |
state: TState | TState[]; | |
children: (args: { | |
context: Extract<TTypestate, { value: TState | TState[] }>["context"]; | |
send: TInterpreter["send"]; | |
}) => JSX.Element; | |
}; | |
export function buildService< | |
TContext, | |
TStateSchema extends StateSchema, | |
TEvent extends EventObject, | |
TTypestate extends Typestate<TContext>, | |
TState extends TTypestate["value"], | |
TInterpreter extends Interpreter<TContext, TStateSchema, TEvent, TTypestate> = Interpreter< | |
TContext, | |
TStateSchema, | |
TEvent, | |
TTypestate | |
> | |
>( | |
machine: StateMachine<TContext, TStateSchema, TEvent, TTypestate> | |
): { | |
current$: Observable<TransitionMessage<TContext, TEvent, TTypestate, TState>>; | |
context$: Observable<TContext>; | |
send: TInterpreter["send"]; | |
service: TInterpreter; | |
Match: ( | |
props: MatchProps<TContext, TStateSchema, TEvent, TTypestate, TState, TInterpreter> | |
) => JSX.Element | null; | |
} { | |
const service = interpret(machine); | |
const current$ = new Observable<TransitionMessage<TContext, TEvent, TTypestate, TState>>(sub => { | |
service.onTransition(state => { | |
sub.next({ | |
context: state.context, | |
event: state.event, | |
state: stateValueToString(state.value), | |
// TState does not match up with what state.matches expects | |
// But we know that the runtime values will match up from other | |
// guarantees | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
matches: (s: TState | TState[]) => state.matches(s as any) as boolean | |
}); | |
}); | |
}); | |
const context$ = current$.pipe(pluck("context")); | |
function Match({ | |
state, | |
children | |
}: MatchProps<TContext, TStateSchema, TEvent, TTypestate, TState, TInterpreter>) { | |
const currentState = useObservable(() => current$); | |
const context = useObservable(() => context$); | |
if (currentState === null || context === null) { | |
return null; | |
} | |
const matched = isArray(state) | |
? some(state, s => currentState.matches(s)) | |
: currentState.matches(state); | |
if (matched) { | |
return children({ context, send: service.send }); | |
} | |
return null; | |
} | |
return { | |
current$, | |
context$, | |
send: service.send, | |
service: service as TInterpreter, | |
Match | |
}; | |
} | |
/** | |
* StateValues can either be a simple state ("state"), or a complex state ({state: "subState"}) | |
* complex states can be nested so we need recursively unpack them | |
* | |
* @param s the state to turn into a.human.readable.string | |
*/ | |
function stateValueToString(s: StateValue) { | |
if (typeof s === "string") { | |
return s; | |
} | |
const outer = Object.keys(s)[0]; | |
const inner = stateValueToString(Object.values(s)[0]); | |
return `${outer}.${inner}`; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import produce from "immer"; | |
import { createMachine, assign } from "xstate"; | |
import { buildService } from "@kx/utils/lib/xstate"; | |
import { FeatureChange } from "~modules/repository/api"; | |
import { ChangeTypes } from "../../types"; | |
type IdStr = string; | |
export type Events = | |
| { type: "TOGGLE_CHANGE_TYPE"; changeType: ChangeTypes; changeId: IdStr } | |
| { type: "ADD_CHANGE"; change: FeatureChange } | |
| { type: "REMOVE_CHANGE"; id: IdStr } | |
| { type: "RESET" }; | |
export type Context = { | |
changes: Record<IdStr, FeatureChange>; | |
filters: Record<IdStr, Record<ChangeTypes, boolean>>; | |
}; | |
export type State = | |
| { value: "noChanges"; context: Context } | |
| { value: "singleChange"; context: Context } | |
| { value: "multipleChanges"; context: Context }; | |
const defaultContext: Context = Object.freeze({ | |
changes: {}, | |
filters: {} | |
}); | |
const addChangeAction = "addChange" as const; | |
const removeChangeAction = "removeChange" as const; | |
const actions = { | |
addChange: addChangeAction, | |
removeChange: removeChangeAction | |
}; | |
const guards = { | |
onlyTwoChanges: "onlyTwoChanges" as const | |
}; | |
export const machine = createMachine<Context, Events, State>( | |
{ | |
id: "diff-machine", | |
context: defaultContext, | |
initial: "noChanges", | |
on: { | |
RESET: { | |
target: "noChanges", | |
actions: assign(() => defaultContext) | |
} | |
}, | |
states: { | |
noChanges: { | |
on: { | |
ADD_CHANGE: { | |
target: "singleChange", | |
actions: actions.addChange | |
} | |
} | |
}, | |
singleChange: { | |
on: { | |
REMOVE_CHANGE: { | |
target: "noChanges", | |
actions: actions.removeChange | |
}, | |
ADD_CHANGE: { | |
target: "multipleChanges", | |
actions: actions.addChange | |
} | |
} | |
}, | |
multipleChanges: { | |
on: { | |
REMOVE_CHANGE: [ | |
{ | |
target: "singleChange", | |
cond: guards.onlyTwoChanges, | |
actions: actions.removeChange | |
}, | |
{ target: "multipleChanges", actions: actions.removeChange } | |
], | |
ADD_CHANGE: { | |
target: "multipleChanges", | |
actions: actions.addChange | |
} | |
} | |
} | |
} | |
}, | |
{ | |
actions: { | |
[actions.removeChange]: assign({ | |
changes: (ctx, e) => | |
e.type === "REMOVE_CHANGE" ? removeChange(ctx.changes, e.type) : ctx.changes | |
}), | |
[actions.addChange]: assign({ | |
changes: (ctx, e) => | |
e.type === "ADD_CHANGE" ? addChange(ctx.changes, e.change) : ctx.changes | |
}) | |
}, | |
guards: { | |
[guards.onlyTwoChanges]: ctx => Object.keys(ctx.changes).length === 2 | |
} | |
} | |
); | |
function addChange(changes: Context["changes"], change: FeatureChange) { | |
return produce(changes, draft => { | |
draft[change.id] = change; | |
}); | |
} | |
function removeChange(changes: Context["changes"], id: IdStr) { | |
return produce(changes, draft => { | |
delete draft[id]; | |
}); | |
} | |
const { Match, context$, current$, send, service } = buildService(machine); | |
service.start(); | |
export { | |
Match as DiffMatch, | |
context$ as diffContext$, | |
current$ as diffState$, | |
send as diffSend, | |
service as DiffService | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from "react"; | |
import { combineLatest } from "rxjs"; | |
import { useObservable } from "rxjs-hooks"; | |
import { map } from "rxjs/operators"; | |
import { pluck } from "@kx/utils/lib/rxjs"; | |
import { diffContext$ } from "../../../machines"; | |
import { DiffViewer } from "../../DiffViewer"; | |
const state$ = combineLatest( | |
[diffContext$.pipe(pluck("filters")), diffContext$.pipe(pluck("changes"), map(Object.values))], | |
(filters, changes) => ({ filters, changes }) | |
); | |
export function ActiveDiff() { | |
const { changes, filters } = useObservable(() => state$) ?? {}; | |
if (!changes || !filters) { | |
return null; | |
} | |
return <DiffViewer changeTypesToDisplayById={filters} diffs={changes} />; | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment