diff --git a/apps/web/src/core/file-system/events.ts b/apps/web/src/core/file-system/actions.ts similarity index 89% rename from apps/web/src/core/file-system/events.ts rename to apps/web/src/core/file-system/actions.ts index a4ef2424e..2ca88a646 100644 --- a/apps/web/src/core/file-system/events.ts +++ b/apps/web/src/core/file-system/actions.ts @@ -1,6 +1,6 @@ import { AppState } from "../../store/state"; -export type FileExplorerEvents = +export type FileExplorerActions = | { action: "filter_by_glob"; payload: { diff --git a/apps/web/src/core/file-system/filter-by-glob.spec.ts b/apps/web/src/core/file-system/filter-by-glob.spec.ts index 35958d5fe..23062202d 100644 --- a/apps/web/src/core/file-system/filter-by-glob.spec.ts +++ b/apps/web/src/core/file-system/filter-by-glob.spec.ts @@ -1,26 +1,18 @@ import { describe, test, expect } from "vitest"; -import { BehaviorSubject, Observable, ReplaySubject, Subject } from "rxjs"; +import { BehaviorSubject, Subject } from "rxjs"; import { AppStore } from "@/store/store"; import { AppState, storeDefaultValue } from "@/store/state"; import { filterByGlob } from "./filter-by-glob"; -import reducers from "./reducers"; - -function toPromise(observable: Observable): Promise { - return new Promise((resolve) => { - observable.subscribe((data) => { - resolve(data); - }); - }); -} +import { fileSystemReducers } from "./reducers"; +import { toPromise } from "../utils"; describe("When filtering data by adding a glob", () => { describe("When the data store is initially empty", () => { test("Should apply the filter in the ui store but should not change anything in the data store", async () => { const appStore = new AppStore( new BehaviorSubject(storeDefaultValue), - new Subject(), - reducers + fileSystemReducers ); const dispatchAction = filterByGlob(appStore); @@ -36,6 +28,7 @@ describe("When filtering data by adding a glob", () => { }); expect(ui).toEqual({ + ...storeDefaultValue.ui, filters: { glob: "src/**/*.ts", }, @@ -75,8 +68,7 @@ describe("When filtering data by adding a glob", () => { }, }, }), - new Subject(), - reducers + fileSystemReducers ); const dispatchAction = filterByGlob(appStore); @@ -103,6 +95,7 @@ describe("When filtering data by adding a glob", () => { }); expect(ui).toEqual({ + ...storeDefaultValue.ui, filters: { glob: "*.ts", }, @@ -122,8 +115,7 @@ describe("When filtering data by adding a glob", () => { graph: {}, }, }), - new Subject(), - reducers + fileSystemReducers ); const dispatchAction = filterByGlob(appStore); @@ -139,6 +131,7 @@ describe("When filtering data by adding a glob", () => { }); expect(ui).toEqual({ + ...storeDefaultValue.ui, filters: { glob: "a.js", }, @@ -166,17 +159,12 @@ describe("When resetting the glob to none", () => { }, }, }, - ui: { - filters: { - glob: "", - }, - }, + ui: storeDefaultValue.ui, }; const appStore = new AppStore( new BehaviorSubject(storeDefaultValue), - new Subject(), - reducers + fileSystemReducers ); appStore.setInitialState(initialAppState); @@ -217,17 +205,12 @@ describe("When removing an initially set glob", () => { }, }, }, - ui: { - filters: { - glob: "", - }, - }, + ui: storeDefaultValue.ui, }; const appStore = new AppStore( new BehaviorSubject(storeDefaultValue), - new Subject(), - reducers + fileSystemReducers ); appStore.setInitialState(initialAppState); @@ -253,6 +236,7 @@ describe("When removing an initially set glob", () => { }, }, ui: { + ...storeDefaultValue.ui, filters: { glob: "src/lib/**/*", }, diff --git a/apps/web/src/core/file-system/reducers.ts b/apps/web/src/core/file-system/reducers.ts index eae09254f..541064779 100644 --- a/apps/web/src/core/file-system/reducers.ts +++ b/apps/web/src/core/file-system/reducers.ts @@ -41,6 +41,7 @@ function filterByGlob(): AppReducer { ...applyGlob(event.payload.glob, event.payload.data), }, ui: { + ...state.ui, filters: { glob: event.payload.glob, }, @@ -52,6 +53,7 @@ function filterByGlob(): AppReducer { return Option.some({ data: event.payload.data, ui: { + ...state.ui, filters: { glob: "", }, @@ -63,4 +65,4 @@ function filterByGlob(): AppReducer { }; } -export default [filterByGlob()]; +export const fileSystemReducers = [filterByGlob()]; diff --git a/apps/web/src/core/network/reducers.ts b/apps/web/src/core/network/reducers.ts new file mode 100644 index 000000000..644174356 --- /dev/null +++ b/apps/web/src/core/network/reducers.ts @@ -0,0 +1,33 @@ +import { AppReducer } from "@/store/reducer"; +import * as Option from "@effect/data/Option"; + +function toggleDependencies(): AppReducer { + return function (event, state) { + if ( + event.action === "toggle_circular" || + event.action === "toggle_builtin" || + event.action === "toggle_thirdparty" + ) { + const target = event.action.split("_")[1]; + + return Option.some({ + data: state.data, + ui: { + ...state.ui, + network: { + dependencies: { + ...state.ui.network.dependencies, + [target]: { + active: event.payload.enabled, + }, + }, + }, + }, + }); + } + + return Option.none(); + }; +} + +export const networkReducers = [toggleDependencies()]; diff --git a/apps/web/src/core/network/toggle-dependencies.spec.ts b/apps/web/src/core/network/toggle-dependencies.spec.ts new file mode 100644 index 000000000..7dbefd18d --- /dev/null +++ b/apps/web/src/core/network/toggle-dependencies.spec.ts @@ -0,0 +1,65 @@ +import { AppState, storeDefaultValue } from "@/store/state"; +import { AppStore } from "@/store/store"; +import { BehaviorSubject } from "rxjs"; +import { describe, expect, test } from "vitest"; +import { toggleDependencies } from "./toggle-dependencies"; +import { toPromise } from "../utils"; +import { networkReducers } from "./reducers"; + +describe("When interacting with network dependencies", () => { + describe.each([ + { target: "circular" }, + { target: "builtin" }, + { target: "thirdparty" }, + ] as const)("When enabling $target dependencies", ({ target }) => { + test(`Should register the activated state for ${target} deps`, async () => { + const appStore = new AppStore( + new BehaviorSubject(storeDefaultValue), + networkReducers + ); + + const emittedEvents: string[] = []; + const subscription = appStore.events$.subscribe((events) => { + emittedEvents.push(events.action); + }); + const dispatchAction = toggleDependencies(appStore); + + dispatchAction({ target }); + + const { ui: uiState1 } = await toPromise(appStore.store$); + + expect(uiState1).toEqual({ + ...storeDefaultValue.ui, + network: { + dependencies: { + ...storeDefaultValue.ui.network.dependencies, + [target]: { + active: true, + }, + }, + }, + }); + + dispatchAction({ target }); + + const { ui: uiState2 } = await toPromise(appStore.store$); + + expect(uiState2).toEqual({ + ...storeDefaultValue.ui, + network: { + dependencies: { + ...storeDefaultValue.ui.network.dependencies, + [target]: { + active: false, + }, + }, + }, + }); + + const appEvent = `toggle_${target}`; + expect(emittedEvents).toEqual([appEvent, appEvent]); + + subscription.unsubscribe(); + }); + }); +}); diff --git a/apps/web/src/core/network/toggle-dependencies.ts b/apps/web/src/core/network/toggle-dependencies.ts new file mode 100644 index 000000000..f8eaa2ffd --- /dev/null +++ b/apps/web/src/core/network/toggle-dependencies.ts @@ -0,0 +1,18 @@ +import { AppStore } from "@/store/store"; + +export function toggleDependencies(appStore: AppStore) { + return function (params: { target: "circular" | "builtin" | "thirdparty" }) { + const networkDependency = + appStore.getState().ui.network.dependencies[params.target]; + + appStore.dispatch( + { + action: `toggle_${params.target}`, + payload: { + enabled: !networkDependency.active, + }, + }, + { notify: true } + ); + }; +} diff --git a/apps/web/src/core/utils.ts b/apps/web/src/core/utils.ts new file mode 100644 index 000000000..1031b8527 --- /dev/null +++ b/apps/web/src/core/utils.ts @@ -0,0 +1,12 @@ +import { Observable } from "rxjs"; + +export function toPromise(observable: Observable): Promise { + return new Promise((resolve) => { + const subscription = observable.subscribe((data) => { + resolve(data); + setTimeout(() => { + subscription.unsubscribe(); + }); + }); + }); +} diff --git a/apps/web/src/global-search/GlobalSearch.tsx b/apps/web/src/global-search/GlobalSearch.tsx index 0e0e79d24..8301ac67a 100644 --- a/apps/web/src/global-search/GlobalSearch.tsx +++ b/apps/web/src/global-search/GlobalSearch.tsx @@ -21,7 +21,7 @@ export default function GlobalSearch() { } }); - const dataStoreSubscription = appStore.dataState$.subscribe((data) => { + const dataStoreSubscription = appStore.store$.subscribe(({ data }) => { if (containerRef.current) { containerRef.current.data = Object.values(data.graph) .map((value) => ({ diff --git a/apps/web/src/network/Network.tsx b/apps/web/src/network/Network.tsx index de2461192..d92196818 100644 --- a/apps/web/src/network/Network.tsx +++ b/apps/web/src/network/Network.tsx @@ -1,12 +1,11 @@ import React from "react"; -import { Subscription, combineLatest, distinctUntilChanged } from "rxjs"; +import { Subscription, combineLatest, distinctUntilChanged, map } from "rxjs"; import { DataSet } from "vis-data"; import { Edge, Network, Node } from "vis-network"; import isEqual from "lodash.isequal"; import { SkottStructureWithCycles, SkottStructureWithMetadata } from "../skott"; -import { UiEvents } from "@/store/events"; import { defaultEdgeOptions, defaultNodeOptions, @@ -24,6 +23,8 @@ import { } from "./dependencies"; import { AppState } from "@/store/state"; import { useAppStore } from "@/store/react-bindings"; +import { AppActions } from "@/store/actions"; +import { AppEvents } from "@/store/events"; export function getMethodToApplyOnNetworkElement(enable: boolean) { return enable ? "update" : "remove"; @@ -121,26 +122,29 @@ export default function GraphNetwork() { edgesDataset[getMethodToApplyOnNetworkElement(enabled)](linkedEdges); } - function networkReducer(dataStore: AppState["data"], uiEvents: UiEvents) { - switch (uiEvents.action) { + function networkUIReducer( + dataStore: AppState["data"], + appEvents: AppActions | AppEvents + ) { + switch (appEvents.action) { case "focus_on_node": { - focusOnNetworkNode(uiEvents.payload.nodeId); + focusOnNetworkNode(appEvents.payload.nodeId); break; } case "toggle_circular": { - highlightCircularDependencies(dataStore, uiEvents.payload.enabled); + highlightCircularDependencies(dataStore, appEvents.payload.enabled); if (dataStore.entrypoint) { highlightEntrypoint(dataStore.entrypoint); } break; } case "toggle_builtin": { - toggleDependencies(dataStore, "builtin", uiEvents.payload.enabled); + toggleDependencies(dataStore, "builtin", appEvents.payload.enabled); network?.stabilize(); break; } case "toggle_thirdparty": { - toggleDependencies(dataStore, "third_party", uiEvents.payload.enabled); + toggleDependencies(dataStore, "third_party", appEvents.payload.enabled); network?.stabilize(); break; } @@ -151,8 +155,11 @@ export default function GraphNetwork() { let subscription: Subscription; if (networkContainerRef.current) { - subscription = appStore.dataState$ - .pipe(distinctUntilChanged(isEqual)) + subscription = appStore.store$ + .pipe( + map(({ data }) => data), + distinctUntilChanged(isEqual) + ) .subscribe((data) => { const { graphNodes, graphEdges } = makeNodesAndEdges( Object.values(data.graph), @@ -199,8 +206,8 @@ export default function GraphNetwork() { React.useEffect(() => { const uiEventsSubscription = combineLatest([ appStore.events$, - appStore.dataState$, - ]).subscribe(([uiEvents, data]) => networkReducer(data, uiEvents)); + appStore.store$, + ]).subscribe(([uiEvents, { data }]) => networkUIReducer(data, uiEvents)); return () => { uiEventsSubscription.unsubscribe(); diff --git a/apps/web/src/sidebar/Layout.tsx b/apps/web/src/sidebar/Layout.tsx index 7905a7bdc..2a90ee249 100644 --- a/apps/web/src/sidebar/Layout.tsx +++ b/apps/web/src/sidebar/Layout.tsx @@ -13,6 +13,7 @@ import { IconVectorTriangle, IconSettings, IconRefreshAlert, + IconAB2, } from "@tabler/icons-react"; import { Circular } from "./Circular"; @@ -21,6 +22,7 @@ import { Summary } from "./summary/Summary"; import { FileExplorer } from "./file-explorer/FileExplorer"; import { InteractivePlayground } from "./InteractivePlayground"; import { UserSettings } from "./UserSettings"; +import { Dependencies } from "./dependencies/Dependencies"; const useStyles = createStyles((theme) => ({ wrapper: { @@ -83,16 +85,21 @@ const useStyles = createStyles((theme) => ({ const menus = [ { icon: IconClipboardData, label: "Summary", key: "summary" }, { icon: IconFiles, label: "File Explorer", key: "file_explorer" }, - { - icon: IconDeviceDesktopAnalytics, - label: "Interactive Playground (work in progress)", - key: "interactive_playground", - }, { icon: IconRefreshAlert, label: "Circular dependencies (work in progress)", key: "circular", }, + { + icon: IconAB2, + label: "Dependencies configuration", + key: "dependencies", + }, + { + icon: IconDeviceDesktopAnalytics, + label: "Interactive Playground (work in progress)", + key: "interactive_playground", + }, { icon: IconVectorTriangle, label: "Graph Configuration (work in progress)", @@ -108,11 +115,13 @@ const menus = [ type MenuKeys = (typeof menus)[number]["key"]; const isFeatureDisabled = (section: string) => - section !== "file_explorer" && section !== "summary"; + section !== "file_explorer" && + section !== "summary" && + section !== "dependencies"; export function DoubleNavbar() { const { classes, cx } = useStyles(); - const [active, setActive] = useState("file_explorer"); + const [active, setActive] = useState("dependencies"); const mainMenus = menus.map((link) => ( ; case "file_explorer": return ; + case "dependencies": + return ; case "interactive_playground": return ; case "settings": diff --git a/apps/web/src/sidebar/dependencies/Dependencies.tsx b/apps/web/src/sidebar/dependencies/Dependencies.tsx new file mode 100644 index 000000000..ed5e8b9ed --- /dev/null +++ b/apps/web/src/sidebar/dependencies/Dependencies.tsx @@ -0,0 +1,57 @@ +import { toggleDependencies } from "@/core/network/toggle-dependencies"; +import { useStoreSelect } from "@/store/react-bindings"; +import { callUseCase } from "@/store/store"; +import { Box, Checkbox, Navbar, ScrollArea } from "@mantine/core"; + +export function Dependencies() { + const network = useStoreSelect("ui", "network"); + + function toggleDepsVisualizationOption( + option: "circular" | "thirdparty" | "builtin" + ) { + const invokeUseCase = callUseCase(toggleDependencies); + invokeUseCase({ target: option }); + } + + return ( + + + Dependencies visualization + + + { + toggleDepsVisualizationOption("circular"); + }} + /> + + + { + toggleDepsVisualizationOption("thirdparty"); + }} + /> + + + { + toggleDepsVisualizationOption("builtin"); + }} + /> + + + + ); +} diff --git a/apps/web/src/sidebar/file-explorer/FileAccordion.tsx b/apps/web/src/sidebar/file-explorer/FileAccordion.tsx index cc6d1e433..2bf41c356 100644 --- a/apps/web/src/sidebar/file-explorer/FileAccordion.tsx +++ b/apps/web/src/sidebar/file-explorer/FileAccordion.tsx @@ -250,7 +250,7 @@ export function FileExplorerAccordion() { const appStore = useAppStore(); React.useEffect(() => { - const s = appStore.dataState$.subscribe((data) => { + const s = appStore.store$.subscribe(({ data }) => { setFileTree(makeTreeStructure(data.files)); }); diff --git a/apps/web/src/sidebar/file-explorer/FileExplorer.tsx b/apps/web/src/sidebar/file-explorer/FileExplorer.tsx index cf5b8b2c4..262ad5413 100644 --- a/apps/web/src/sidebar/file-explorer/FileExplorer.tsx +++ b/apps/web/src/sidebar/file-explorer/FileExplorer.tsx @@ -49,8 +49,8 @@ export function FileExplorer() { } React.useEffect(() => { - const unsubscribe = appStore.uiState$.subscribe(({ filters }) => { - setFilter(filters.glob); + const unsubscribe = appStore.store$.subscribe(({ ui }) => { + setFilter(ui.filters.glob); }); return () => { unsubscribe.unsubscribe(); diff --git a/apps/web/src/sidebar/summary/Summary.tsx b/apps/web/src/sidebar/summary/Summary.tsx index a3f8dfa07..4a7ecb83b 100644 --- a/apps/web/src/sidebar/summary/Summary.tsx +++ b/apps/web/src/sidebar/summary/Summary.tsx @@ -16,6 +16,7 @@ import { convertBytesToUserFriendlyUnit, formatForm } from "./formatters"; import { LanguageRing } from "./LanguageRing"; import { Dependencies } from "./Dependencies"; import { useAppStore } from "@/store/react-bindings"; +import { map } from "rxjs"; function safeSet(m: Map, key: string, value: string) { if (m.has(key)) { @@ -78,7 +79,9 @@ export function Summary() { } React.useEffect(() => { - const subscription = appStore.dataState$.subscribe(computeSummary); + const subscription = appStore.store$ + .pipe(map(({ data }) => data)) + .subscribe(computeSummary); return () => { subscription.unsubscribe(); diff --git a/apps/web/src/store/actions.ts b/apps/web/src/store/actions.ts new file mode 100644 index 000000000..ebe2b9b01 --- /dev/null +++ b/apps/web/src/store/actions.ts @@ -0,0 +1,7 @@ +import { FileExplorerActions } from "@/core/file-system/actions"; + +export type AppActions = + | { action: "toggle_circular"; payload: { enabled: boolean } } + | { action: "toggle_builtin"; payload: { enabled: boolean } } + | { action: "toggle_thirdparty"; payload: { enabled: boolean } } + | FileExplorerActions; diff --git a/apps/web/src/store/events.ts b/apps/web/src/store/events.ts index ad056356a..5175a53c7 100644 --- a/apps/web/src/store/events.ts +++ b/apps/web/src/store/events.ts @@ -1,24 +1,16 @@ -import { FileExplorerEvents } from "@/core/file-system/events"; - -export type UiEvents = - | { action: "toggle_circular"; payload: { enabled: boolean } } - | { action: "toggle_builtin"; payload: { enabled: boolean } } - | { action: "toggle_thirdparty"; payload: { enabled: boolean } } +export type AppEvents = | { action: "open_search"; } | { - action: "focus_on_node"; + action: "isolate_node"; payload: { nodeId: string; }; } | { - action: "isolate_node"; + action: "focus_on_node"; payload: { nodeId: string; }; - } - | FileExplorerEvents; - -export type AppEvents = UiEvents; + }; diff --git a/apps/web/src/store/react-bindings.ts b/apps/web/src/store/react-bindings.ts index 8e9d3ca23..57c0bf417 100644 --- a/apps/web/src/store/react-bindings.ts +++ b/apps/web/src/store/react-bindings.ts @@ -1,9 +1,34 @@ import React from "react"; +import { map } from "rxjs"; import { AppStore, AppStoreInstance } from "./store"; +import { AppState } from "./state"; const AppStoreContext = React.createContext(AppStoreInstance); export const useAppStore = () => React.useContext(AppStoreContext); export const AppStoreProvider = AppStoreContext.Provider; + +export const useStoreSelect = < + T extends keyof AppState, + K extends keyof AppState[T] +>( + storeSegment: T, + pluckedProperty: K +) => { + const [state, setState] = React.useState(null!); + const store = useAppStore(); + + React.useEffect(() => { + const subscription = store.store$ + .pipe(map((state) => state[storeSegment][pluckedProperty])) + .subscribe(setState); + + return () => { + subscription.unsubscribe(); + }; + }); + + return state; +}; diff --git a/apps/web/src/store/reducer.ts b/apps/web/src/store/reducer.ts index b01077e44..f9dfb8b72 100644 --- a/apps/web/src/store/reducer.ts +++ b/apps/web/src/store/reducer.ts @@ -1,10 +1,10 @@ import * as Option from "@effect/data/Option"; import { AppState } from "./state"; -import { AppEvents } from "./events"; +import { AppEffects } from "./store"; export interface StoreReducer { (action: ActionShape, state: StoreShape): Option.Option; } -export interface AppReducer extends StoreReducer {} +export interface AppReducer extends StoreReducer {} diff --git a/apps/web/src/store/state.ts b/apps/web/src/store/state.ts index 433dee74c..27d4d9e75 100644 --- a/apps/web/src/store/state.ts +++ b/apps/web/src/store/state.ts @@ -4,6 +4,19 @@ export interface UiState { filters: { glob: string; }; + network: { + dependencies: { + circular: { + active: boolean; + }; + builtin: { + active: boolean; + }; + thirdparty: { + active: boolean; + }; + }; + }; } export interface DataState extends SkottStructureWithCycles {} @@ -23,5 +36,18 @@ export const storeDefaultValue: AppState = { filters: { glob: "", }, + network: { + dependencies: { + circular: { + active: false, + }, + thirdparty: { + active: false, + }, + builtin: { + active: false, + }, + }, + }, }, }; diff --git a/apps/web/src/store/store.ts b/apps/web/src/store/store.ts index 21cbff7fb..87bb2af59 100644 --- a/apps/web/src/store/store.ts +++ b/apps/web/src/store/store.ts @@ -1,41 +1,31 @@ -import { BehaviorSubject, Subject, distinctUntilChanged, map } from "rxjs"; +import { BehaviorSubject, Subject } from "rxjs"; import * as Option from "@effect/data/Option"; import { pipe } from "@effect/data/Function"; -import { AppEvents, UiEvents } from "./events"; +import { AppEvents } from "./events"; +import { AppActions } from "./actions"; import { AppState, storeDefaultValue } from "./state"; import { StoreReducer } from "./reducer"; -import reducers from "../core/file-system/reducers"; +import { fileSystemReducers } from "../core/file-system/reducers"; +import { networkReducers } from "@/core/network/reducers"; + +export type AppEffects = AppEvents | AppActions; export class AppStore { constructor( private readonly _store$: BehaviorSubject, - private readonly _uiEvents$: Subject, - private readonly _reducers: StoreReducer[] + private readonly _reducers: StoreReducer[] ) {} + private _eventStream$ = new Subject(); private _initialState: AppState = storeDefaultValue; get store$() { return this._store$; } - get dataState$() { - return this._store$.pipe( - map((state) => state.data), - distinctUntilChanged() - ); - } - - get uiState$() { - return this._store$.pipe( - map((state) => state.ui), - distinctUntilChanged() - ); - } - get events$() { - return this._uiEvents$; + return this._eventStream$; } getState() { @@ -55,9 +45,13 @@ export class AppStore { this._store$.next(this._initialState); } - dispatch(action: AppEvents) { - this._reducers.forEach((reducer) => { - return pipe( + notify(event: AppActions) { + this.events$.next(event); + } + + dispatch(action: AppActions, dispatchOptions = { notify: false }) { + this._reducers.forEach((reducer) => + pipe( reducer(action, this.getState()), Option.match( () => {}, @@ -65,20 +59,23 @@ export class AppStore { return this._store$.next(state); } ) - ); - }); + ) + ); + + if (dispatchOptions.notify) { + this.notify(action); + } } } -const listOfReducers = [...reducers]; +const listOfReducers = [...fileSystemReducers, ...networkReducers]; const instance = new AppStore( new BehaviorSubject(storeDefaultValue), - new Subject(), listOfReducers ); -export const notify = (event: UiEvents) => instance.events$.next(event); +export const notify = (event: AppEvents) => instance.events$.next(event); export const callUseCase = ( useCase: (appStore: AppStore) => (args: T) => void