diff --git a/src/core/Initialize.tsx b/src/core/Initialize.tsx index e03160d..ccebee6 100644 --- a/src/core/Initialize.tsx +++ b/src/core/Initialize.tsx @@ -7,7 +7,6 @@ import { AuthInit } from "./user/AuthInit"; import { Loader } from "../components/Loader"; import { useNotifications } from "./notifications"; import { graphDatasetAtom } from "./graph"; -import { useOpenGexf } from "./graph/useOpenGexf"; import { parseDataset, getEmptyGraphDataset } from "./graph/utils"; import { filtersAtom } from "./filters"; import { parseFiltersState } from "./filters/utils"; @@ -21,12 +20,15 @@ import { useModal } from "./modals"; import { WelcomeModal } from "../views/graphPage/modals/WelcomeModal"; import { resetCamera } from "./sigma"; import { I18n } from "../locales/provider"; +import { useFileActions, useFileState } from "./context/dataContexts"; +import { fileStateAtom } from "./graph/files"; export const Initialize: FC> = ({ children }) => { const { t } = useTranslation(); const { notify } = useNotifications(); const { openModal } = useModal(); - const { loading, openRemoteFile } = useOpenGexf(); + const { type: fileStateType } = useFileState(); + const { openRemoteFile } = useFileActions(); useKonami( () => { @@ -86,7 +88,8 @@ export const Initialize: FC> = ({ children }) => { // If query params has gexf // => try to load the file - if (!graphFound && url.searchParams.has("gexf")) { + const isIdle = fileStateAtom.get().type === "idle"; + if (!graphFound && url.searchParams.has("gexf") && isIdle) { const gexfUrl = url.searchParams.get("gexf") || ""; try { await openRemoteFile({ @@ -152,7 +155,7 @@ export const Initialize: FC> = ({ children }) => { return ( - {loading ? : children} + {children} ); }; diff --git a/src/core/context/dataContexts.tsx b/src/core/context/dataContexts.tsx index f89b99a..04de6db 100644 --- a/src/core/context/dataContexts.tsx +++ b/src/core/context/dataContexts.tsx @@ -9,6 +9,8 @@ import { preferencesActions, preferencesAtom } from "../preferences"; import { selectionActions, selectionAtom } from "../selection"; import { sigmaActions, sigmaAtom, sigmaStateAtom } from "../sigma"; import { searchActions, searchAtom } from "../search"; +import { Action } from "../utils/producers"; +import { fileActions, fileStateAtom } from "../graph/files"; /** * Helpers: @@ -38,6 +40,7 @@ const ATOMS = { sigma: sigmaAtom, filters: filtersAtom, selection: selectionAtom, + fileState: fileStateAtom, appearance: appearanceAtom, sigmaState: sigmaStateAtom, sigmaGraph: sigmaGraphAtom, @@ -53,6 +56,7 @@ const CONTEXTS = { sigma: createContext(ATOMS.sigma), filters: createContext(ATOMS.filters), selection: createContext(ATOMS.selection), + fileState: createContext(ATOMS.fileState), appearance: createContext(ATOMS.appearance), sigmaState: createContext(ATOMS.sigmaState), sigmaGraph: createContext(ATOMS.sigmaGraph), @@ -79,11 +83,19 @@ export const AtomsContextsRoot: FC<{ children?: ReactNode }> = ({ children }) => ); }; +export const resetStates: Action = () => { + filtersActions.resetFilters(); + selectionActions.reset(); + appearanceActions.resetState(); + sigmaActions.resetState(); + searchActions.reset(); +}; // Read data: export const useFilters = makeUseAtom(CONTEXTS.filters); export const useSigmaAtom = makeUseAtom(CONTEXTS.sigma); export const useSelection = makeUseAtom(CONTEXTS.selection); +export const useFileState = makeUseAtom(CONTEXTS.fileState); export const useAppearance = makeUseAtom(CONTEXTS.appearance); export const useSigmaState = makeUseAtom(CONTEXTS.sigmaState); export const useSigmaGraph = makeUseAtom(CONTEXTS.sigmaGraph); @@ -100,13 +112,8 @@ export const useAppearanceActions = makeUseActions(appearanceActions); export const useGraphDatasetActions = makeUseActions(graphDatasetActions); export const usePreferencesActions = makeUseActions(preferencesActions); export const useSearchActions = makeUseActions(searchActions); +export const useFileActions = makeUseActions(fileActions); export const useResetStates = () => { - return () => { - filtersActions.resetFilters(); - selectionActions.reset(); - appearanceActions.resetState(); - sigmaActions.resetState(); - searchActions.reset(); - }; + return resetStates; }; diff --git a/src/core/graph/files.ts b/src/core/graph/files.ts new file mode 100644 index 0000000..3a6e205 --- /dev/null +++ b/src/core/graph/files.ts @@ -0,0 +1,70 @@ +import { asyncAction } from "../utils/producers"; +import { FileState, LocalFile, RemoteFile } from "./types"; +import { parse } from "graphology-gexf"; +import Graph from "graphology"; +import { resetStates } from "../context/dataContexts"; +import { getEmptyFileState, initializeGraphDataset } from "./utils"; +import { preferencesActions } from "../preferences"; +import { resetCamera } from "../sigma"; +import { atom } from "../utils/atoms"; +import { graphDatasetActions } from "./index"; + +/** + * Public API: + * *********** + */ +export const fileStateAtom = atom(getEmptyFileState()); + +/** + * Actions: + * ******** + */ +export const openRemoteFile = asyncAction(async (remote: RemoteFile) => { + const { setGraphDataset } = graphDatasetActions; + const { addRemoteFile } = preferencesActions; + + if (fileStateAtom.get().type === "loading") throw new Error("A file is already being loaded"); + + fileStateAtom.set({ type: "loading" }); + try { + const response = await fetch(remote.url); + const gexf = await response.text(); + const graph = parse(Graph, gexf, { allowUndeclaredAttributes: true, addMissingNodes: true }); + graph.setAttribute("title", remote.filename); + resetStates(); + setGraphDataset({ ...initializeGraphDataset(graph), origin: remote }); + addRemoteFile(remote); + resetCamera({ forceRefresh: true }); + } catch (e) { + fileStateAtom.set({ type: "error", message: (e as Error).message }); + throw e; + } finally { + fileStateAtom.set({ type: "idle" }); + } +}); + +export const openLocalFile = asyncAction(async (file: LocalFile) => { + const { setGraphDataset } = graphDatasetActions; + + if (fileStateAtom.get().type === "loading") throw new Error("A file is already being loaded"); + + fileStateAtom.set({ type: "loading" }); + try { + const content = await file.source.text(); + const graph = parse(Graph, content, { allowUndeclaredAttributes: true }); + graph.setAttribute("title", file.filename); + resetStates(); + setGraphDataset({ ...initializeGraphDataset(graph), origin: file }); + resetCamera({ forceRefresh: true }); + } catch (e) { + fileStateAtom.set({ type: "error", message: (e as Error).message }); + throw e; + } finally { + fileStateAtom.set({ type: "idle" }); + } +}); + +export const fileActions = { + openRemoteFile, + openLocalFile, +}; diff --git a/src/core/graph/index.ts b/src/core/graph/index.ts index f8c422c..b42d58e 100644 --- a/src/core/graph/index.ts +++ b/src/core/graph/index.ts @@ -223,7 +223,7 @@ graphDatasetAtom.bind((graphDataset, previousGraphDataset) => { filteredGraphsAtom.set(newCache); } - // When graph data or fields changed changed, we reindex it for the search + // When graph data or fields changed, we reindex it for the search if (updatedKeys.has("fullGraph") || updatedKeys.has("edgeFields") || updatedKeys.has("nodeFields")) { searchActions.indexAll(); } diff --git a/src/core/graph/types.ts b/src/core/graph/types.ts index c7767da..f90d5be 100644 --- a/src/core/graph/types.ts +++ b/src/core/graph/types.ts @@ -103,3 +103,4 @@ export interface GraphDataset { // Ex: is it a local or a remote file origin: GraphOrigin; } +export type FileState = { type: "idle" } | { type: "loading" } | { type: "error"; message?: string }; diff --git a/src/core/graph/useOpenGexf.ts b/src/core/graph/useOpenGexf.ts deleted file mode 100644 index 21d6f47..0000000 --- a/src/core/graph/useOpenGexf.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useCallback, useState } from "react"; -import { parse } from "graphology-gexf"; -import Graph from "graphology"; - -import { RemoteFile, LocalFile } from "./types"; -import { initializeGraphDataset } from "./utils"; -import { useGraphDatasetActions, usePreferencesActions, useResetStates } from "../context/dataContexts"; -import { resetCamera } from "../sigma"; - -export function useOpenGexf() { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const { setGraphDataset } = useGraphDatasetActions(); - const resetStates = useResetStates(); - const { addRemoteFile } = usePreferencesActions(); - - const openRemoteFile = useCallback( - async (remote: RemoteFile) => { - setLoading(true); - setError(null); - try { - const response = await fetch(remote.url); - const gexf = await response.text(); - const graph = parse(Graph, gexf, { allowUndeclaredAttributes: true, addMissingNodes: true }); - graph.setAttribute("title", remote.filename); - resetStates(); - setGraphDataset({ ...initializeGraphDataset(graph), origin: remote }); - addRemoteFile(remote); - resetCamera({ forceRefresh: true }); - } catch (e) { - setError(e as Error); - throw e; - } finally { - setLoading(false); - } - }, - [addRemoteFile, resetStates, setGraphDataset], - ); - - const openLocalFile = useCallback( - async (file: LocalFile) => { - setLoading(true); - setError(null); - try { - const content = await file.source.text(); - const graph = parse(Graph, content, { allowUndeclaredAttributes: true }); - graph.setAttribute("title", file.filename); - resetStates(); - setGraphDataset({ ...initializeGraphDataset(graph), origin: file }); - resetCamera({ forceRefresh: true }); - } catch (e) { - setError(e as Error); - throw e; - } finally { - setLoading(false); - } - }, - [resetStates, setGraphDataset], - ); - - return { loading, error, openRemoteFile, openLocalFile }; -} diff --git a/src/core/graph/utils.spec.ts b/src/core/graph/utils.spec.ts index 27570a6..82c641f 100644 --- a/src/core/graph/utils.spec.ts +++ b/src/core/graph/utils.spec.ts @@ -32,16 +32,7 @@ describe("Graph utilities", () => { it("should properly detect separators", () => { expect( - inferFieldType( - [ - "TypeScript", - "Neo4J,TypeScript", - "Python,TypeScript", - "TypeScript,Python", - "TypeScript", - ], - 5, - ), + inferFieldType(["TypeScript", "Neo4J,TypeScript", "Python,TypeScript", "TypeScript,Python", "TypeScript"], 5), ).toEqual({ quantitative: null, qualitative: { separator: "," }, diff --git a/src/core/graph/utils.ts b/src/core/graph/utils.ts index 7a3c723..20fd9c3 100644 --- a/src/core/graph/utils.ts +++ b/src/core/graph/utils.ts @@ -10,6 +10,7 @@ import { DatalessGraph, EdgeRenderingData, FieldModel, + FileState, FullGraph, GraphDataset, ItemData, @@ -35,6 +36,10 @@ export function getEmptyGraphDataset(): GraphDataset { }; } +export function getEmptyFileState(): FileState { + return { type: "idle" }; +} + /** * Appearance lifecycle helpers (state serialization / deserialization): */ diff --git a/src/core/metrics/collections.ts b/src/core/metrics/collections.ts index 36d0ac1..b1b9a6c 100644 --- a/src/core/metrics/collections.ts +++ b/src/core/metrics/collections.ts @@ -13,16 +13,16 @@ import { toNumber } from "../utils/casting"; // Definition of a custom metric function for nodes // eslint-disable-next-line no-new-func -const nodeMetricCustomFn = new Function(`return ( +const nodeMetricCustomFn = new Function(`return ( function nodeMetric(id, attributes, index, graph) { // Your code goes here return Math.random(); -} +} )`)(); // Definition of a custom metric function for edges // eslint-disable-next-line no-new-func -const edgeMetricCustomFn = new Function(`return ( +const edgeMetricCustomFn = new Function(`return ( function edgeMetric(id, attributes, index, graph) { // Your code goes here return Math.random(); @@ -256,7 +256,6 @@ export const NODE_METRICS: Metric<"nodes", any, any>[] = [ const id = fullGraph.nodes()[0]; const attributs = fullGraph.getNodeAttributes(id); const result = fn(id, attributs, 0, fullGraph); - console.log(isNumber(result), isString(result)); if (!isNumber(result) && !isString(result)) throw new Error("Function must return either a number or a string"); }, diff --git a/src/core/utils/atoms.spec.ts b/src/core/utils/atoms.spec.ts index 704dde1..2ec41f4 100644 --- a/src/core/utils/atoms.spec.ts +++ b/src/core/utils/atoms.spec.ts @@ -33,7 +33,7 @@ describe("Atoms library", () => { }); describe("#derivedAtom", () => { - it("should be updated when depencies are updated", () => { + it("should be updated when dependencies are updated", () => { const a1 = atom(0); const a2 = atom(0); const sum = derivedAtom([a1, a2], (v1, v2, previousValue) => v1 + v2); diff --git a/src/core/utils/producers.ts b/src/core/utils/producers.ts index a995805..be0ebf8 100644 --- a/src/core/utils/producers.ts +++ b/src/core/utils/producers.ts @@ -2,6 +2,16 @@ import { WritableAtom } from "./atoms"; export type Reducer = (v: T) => T; +export type Action = (...args: Args) => void; +export type AsyncAction = (...args: Args) => Promise; + +/** + * A short function to help automatically type an AsyncAction Args generic. + */ +export function asyncAction(fn: (...args: Args) => Promise) { + return fn as AsyncAction; +} + /** * A Producer is a function that returns a reducer. These are easy to test, and * it is easy to create mutating actions from them as well. @@ -17,7 +27,10 @@ export type MultiProducer, Args extends unknown[] = [] * This allows writing the logic as unit-testable producers, but spread through * the code simple actions. */ -export function producerToAction(producer: Producer, atom: WritableAtom) { +export function producerToAction( + producer: Producer, + atom: WritableAtom, +): Action { return (...args: Args) => { atom.set(producer(...args)); }; @@ -27,7 +40,7 @@ export function multiproducerToAction, Args extends un atoms: { [K in keyof Ts]: WritableAtom; }, -) { +): Action { return (...args: Args) => { const reducers = producer(...args); atoms.forEach((atom, i) => atom.set(reducers[i])); diff --git a/src/views/graphPage/StatisticsPanel.tsx b/src/views/graphPage/StatisticsPanel.tsx index fd019ac..6ed0017 100644 --- a/src/views/graphPage/StatisticsPanel.tsx +++ b/src/views/graphPage/StatisticsPanel.tsx @@ -292,7 +292,6 @@ export const MetricForm: FC<{ metric: Metric; onClose: () => void checkFunction: param.functionCheck, }, beforeSubmit: ({ run, script }) => { - console.log(script); onChange("parameters", param.id, script); if (run) setTimeout(submit, 0); }, diff --git a/src/views/graphPage/modals/WelcomeModal.tsx b/src/views/graphPage/modals/WelcomeModal.tsx index 7caab4c..ae262e5 100644 --- a/src/views/graphPage/modals/WelcomeModal.tsx +++ b/src/views/graphPage/modals/WelcomeModal.tsx @@ -9,9 +9,8 @@ import { RemoteFileModal } from "./open/RemoteFileModal"; import { useConnectedUser } from "../../../core/user"; import { useModal } from "../../../core/modals"; import { Loader } from "../../../components/Loader"; -import { useOpenGexf } from "../../../core/graph/useOpenGexf"; import { useNotifications } from "../../../core/notifications"; -import { usePreferences } from "../../../core/context/dataContexts"; +import { useFileActions, useFileState, usePreferences } from "../../../core/context/dataContexts"; import { GitHubIcon } from "../../../components/common-icons"; import LocalSwitcher from "../../../components/LocalSwitcher"; @@ -24,17 +23,18 @@ export const WelcomeModal: FC> = ({ cancel, submit }) => { const [user] = useConnectedUser(); const { recentRemoteFiles } = usePreferences(); - const { loading, error, openRemoteFile } = useOpenGexf(); + const { type: fileStateType } = useFileState(); + const { openRemoteFile } = useFileActions(); useEffect(() => { - if (error) { + if (fileStateType === "error") { notify({ type: "error", message: t("graph.open.remote.error") as string, title: t("gephi-lite.title") as string, }); } - }, [error, notify, t]); + }, [fileStateType, notify, t]); return ( > = ({ cancel, submit }) => { } - onClose={loading ? undefined : () => cancel()} + onClose={fileStateType === "loading" ? undefined : () => cancel()} className="modal-lg" >
@@ -152,7 +152,7 @@ export const WelcomeModal: FC> = ({ cancel, submit }) => { ))}
- {loading && } + {fileStateType === "loading" && }
diff --git a/src/views/graphPage/modals/open/LocalFileModal.tsx b/src/views/graphPage/modals/open/LocalFileModal.tsx index dbcb248..0267765 100644 --- a/src/views/graphPage/modals/open/LocalFileModal.tsx +++ b/src/views/graphPage/modals/open/LocalFileModal.tsx @@ -3,29 +3,33 @@ import { FaFolderOpen, FaTimes } from "react-icons/fa"; import { useTranslation } from "react-i18next"; import { ModalProps } from "../../../../core/modals/types"; -import { useOpenGexf } from "../../../../core/graph/useOpenGexf"; import { useNotifications } from "../../../../core/notifications"; import { Modal } from "../../../../components/modals"; import { Loader } from "../../../../components/Loader"; import { DropInput } from "../../../../components/DropInput"; +import { useFileActions, useFileState } from "../../../../core/context/dataContexts"; export const LocalFileModal: FC> = ({ cancel, submit }) => { const { notify } = useNotifications(); const { t } = useTranslation(); - const { loading, error, openLocalFile } = useOpenGexf(); const [file, setFile] = useState(null); + const { type: fileStateType } = useFileState(); + const { openLocalFile } = useFileActions(); + return ( cancel()}> <> - {error &&

{t("graph.open.local.error").toString()}

} + {fileStateType === "error" && ( +

{t("graph.open.local.error").toString()}

+ )} setFile(file)} helpText={t("graph.open.local.dragndrop_text").toString()} accept={{ "application/graph": [".gexf"] }} /> - {loading && } + {fileStateType === "loading" && } <>