diff --git a/api-editor/gui/src/app/App.tsx b/api-editor/gui/src/app/App.tsx index 992f7171a..97c4ad7a1 100644 --- a/api-editor/gui/src/app/App.tsx +++ b/api-editor/gui/src/app/App.tsx @@ -34,10 +34,7 @@ import OptionalForm from '../features/annotations/forms/OptionalForm'; import RenameForm from '../features/annotations/forms/RenameForm'; import { PythonFilter } from '../features/packageData/model/PythonFilter'; import PythonPackage from '../features/packageData/model/PythonPackage'; -import { - parsePythonPackageJson, - PythonPackageJson, -} from '../features/packageData/model/PythonPackageBuilder'; +import { parsePythonPackageJson, PythonPackageJson } from '../features/packageData/model/PythonPackageBuilder'; import PackageDataImportDialog from '../features/packageData/PackageDataImportDialog'; import { selectShowPackageDataImportDialog, @@ -48,22 +45,34 @@ import TreeView from '../features/packageData/treeView/TreeView'; import { useAppDispatch, useAppSelector } from './hooks'; import PythonFunction from '../features/packageData/model/PythonFunction'; import AttributeForm from '../features/annotations/forms/AttributeForm'; +import { UsageCountJson, UsageCountStore } from '../features/usages/model/UsageCountStore'; +import { selectShowUsageImportDialog } from '../features/usages/usageSlice'; +import UsageImportDialog from '../features/usages/UsageImportDialog'; const App: React.FC = function () { - const [pythonPackage, setPythonPackage] = useState( - new PythonPackage('empty', 'empty', '0.0.1'), - ); + const dispatch = useAppDispatch(); const currentUserAction = useAppSelector(selectCurrentUserAction); const currentPathName = useLocation().pathname; + // Initialize package data + const [pythonPackage, setPythonPackage] = useState(new PythonPackage('empty', 'empty', '0.0.1')); + useEffect(() => { // noinspection JSIgnoredPromiseFromCall getPythonPackageFromIndexedDB(setPythonPackage); }, []); + // Initialize usages + const [usages, setUsages] = useState(new UsageCountStore()); + + useEffect(() => { + // noinspection JSIgnoredPromiseFromCall + getUsagesFromIndexedDB(setUsages); + }); + + // Initialize annotations const annotationStore = useAppSelector(selectAnnotations); - const dispatch = useAppDispatch(); useEffect(() => { dispatch(initializeAnnotations()); }, [dispatch]); @@ -87,16 +96,11 @@ const App: React.FC = function () { const pythonFilter = PythonFilter.fromFilterBoxInput(filter); const filteredPythonPackage = pythonPackage.filter(pythonFilter); - const userActionTarget = pythonPackage.getByRelativePathAsString( - currentUserAction.target, - ); + const userActionTarget = pythonPackage.getByRelativePathAsString(currentUserAction.target); - const showAnnotationImportDialog = useAppSelector( - selectShowAnnotationImportDialog, - ); - const showPackageDataImportDialog = useAppSelector( - selectShowPackageDataImportDialog, - ); + const showAnnotationImportDialog = useAppSelector(selectShowAnnotationImportDialog); + const showPackageDataImportDialog = useAppSelector(selectShowPackageDataImportDialog); + const showUsagesImportDialog = useAppSelector(selectShowUsageImportDialog); const [showInferErrorDialog, setShowInferErrorDialog] = useState(false); const [inferErrors, setInferErrors] = useState([]); @@ -133,55 +137,34 @@ const App: React.FC = function () { resize="horizontal" > {currentUserAction.type === 'attribute' && ( - + )} {currentUserAction.type === 'boundary' && ( - + )} - {currentUserAction.type === 'calledAfter' && - userActionTarget instanceof PythonFunction && ( - - )} - {currentUserAction.type === 'constant' && ( - + {currentUserAction.type === 'calledAfter' && userActionTarget instanceof PythonFunction && ( + )} - {currentUserAction.type === 'enum' && ( - + {currentUserAction.type === 'constant' && ( + )} + {currentUserAction.type === 'enum' && } {currentUserAction.type === 'group' && ( )} - {currentUserAction.type === 'move' && ( - - )} - {currentUserAction.type === 'none' && ( - - )} + {currentUserAction.type === 'move' && } + {currentUserAction.type === 'none' && } {currentUserAction.type === 'optional' && ( - - )} - {currentUserAction.type === 'rename' && ( - + )} + {currentUserAction.type === 'rename' && } @@ -189,11 +172,9 @@ const App: React.FC = function () { {showAnnotationImportDialog && } {showPackageDataImportDialog && ( - + )} + {showUsagesImportDialog && } , -) { +const getPythonPackageFromIndexedDB = async function (setPythonPackage: Setter) { const storedPackage = (await idb.get('package')) as PythonPackageJson; if (storedPackage) { setPythonPackage(parsePythonPackageJson(storedPackage)); } }; -const setAnnotationsInIndexedDB = async function ( - annotationStore: AnnotationsState, -) { +const getUsagesFromIndexedDB = async function (setUsages: Setter) { + const storedUsages = (await idb.get('usages')) as UsageCountJson; + if (storedUsages) { + setUsages(UsageCountStore.fromJson(storedUsages)); + } +}; + +const setAnnotationsInIndexedDB = async function (annotationStore: AnnotationsState) { await idb.set('annotations', annotationStore); }; diff --git a/api-editor/gui/src/app/store.ts b/api-editor/gui/src/app/store.ts index 9d830b0d0..90261a44a 100644 --- a/api-editor/gui/src/app/store.ts +++ b/api-editor/gui/src/app/store.ts @@ -2,11 +2,13 @@ import { configureStore } from '@reduxjs/toolkit'; import { setupListeners } from '@reduxjs/toolkit/query'; import annotationReducer from '../features/annotations/annotationSlice'; import packageDataReducer from '../features/packageData/packageDataSlice'; +import usageReducer from '../features/usages/usageSlice'; export const store = configureStore({ reducer: { annotations: annotationReducer, packageData: packageDataReducer, + usages: usageReducer, }, }); diff --git a/api-editor/gui/src/common/MenuBar.tsx b/api-editor/gui/src/common/MenuBar.tsx index aed0fb553..04b76b608 100644 --- a/api-editor/gui/src/common/MenuBar.tsx +++ b/api-editor/gui/src/common/MenuBar.tsx @@ -39,10 +39,7 @@ import { FaCheck, FaChevronDown } from 'react-icons/fa'; import { useLocation } from 'react-router'; import { NavLink } from 'react-router-dom'; import { useAppDispatch, useAppSelector } from '../app/hooks'; -import { - resetAnnotations, - toggleAnnotationImportDialog, -} from '../features/annotations/annotationSlice'; +import { resetAnnotations, toggleAnnotationImportDialog } from '../features/annotations/annotationSlice'; import AnnotatedPythonPackageBuilder from '../features/annotatedPackageData/model/AnnotatedPythonPackageBuilder'; import { PythonFilter } from '../features/packageData/model/PythonFilter'; import PythonPackage from '../features/packageData/model/PythonPackage'; @@ -52,6 +49,7 @@ import { toggleShowPrivateDeclarations, } from '../features/packageData/packageDataSlice'; import { Setter } from './util/types'; +import { toggleUsageImportDialog } from '../features/usages/usageSlice'; interface MenuBarProps { pythonPackage: PythonPackage; @@ -77,15 +75,9 @@ const DeleteAllAnnotations = function () { return ( <> - + - + @@ -94,14 +86,10 @@ const DeleteAllAnnotations = function () { + Are you sure? You can't undo this action afterwards. - Are you sure? You can't undo this action - afterwards. - - - Hint: Consider exporting your work first by - clicking on the "Export" button in the menu - bar. + Hint: Consider exporting your work first by clicking on the "Export" button in the + menu bar. @@ -110,11 +98,7 @@ const DeleteAllAnnotations = function () { - @@ -125,12 +109,7 @@ const DeleteAllAnnotations = function () { ); }; -const MenuBar: React.FC = function ({ - pythonPackage, - filter, - setFilter, - displayInferErrors, -}) { +const MenuBar: React.FC = function ({ pythonPackage, filter, setFilter, displayInferErrors }) { const { colorMode, toggleColorMode } = useColorMode(); const initialFocusRef = useRef(null); const dispatch = useAppDispatch(); @@ -138,9 +117,7 @@ const MenuBar: React.FC = function ({ const pathname = useLocation().pathname.split('/').slice(1); const annotationStore = useAppSelector((state) => state.annotations); - const enableNavigation = useAppSelector( - (state) => state.annotations.currentUserAction.type === 'none', - ); + const enableNavigation = useAppSelector((state) => state.annotations.currentUserAction.type === 'none'); const exportAnnotations = () => { const a = document.createElement('a'); @@ -153,12 +130,8 @@ const MenuBar: React.FC = function ({ }; const infer = () => { - const annotatedPythonPackageBuilder = new AnnotatedPythonPackageBuilder( - pythonPackage, - annotationStore, - ); - const annotatedPythonPackage = - annotatedPythonPackageBuilder.generateAnnotatedPythonPackage(); + const annotatedPythonPackageBuilder = new AnnotatedPythonPackageBuilder(pythonPackage, annotationStore); + const annotatedPythonPackage = annotatedPythonPackageBuilder.generateAnnotatedPythonPackage(); const requestOptions = { method: 'POST', @@ -180,22 +153,12 @@ const MenuBar: React.FC = function ({ }; return ( - +
@@ -204,18 +167,11 @@ const MenuBar: React.FC = function ({ // eslint-disable-next-line react/no-array-index-key {enableNavigation && ( - + {part} )} - {!enableNavigation && ( - {part} - )} + {!enableNavigation && {part}} ))} @@ -229,71 +185,42 @@ const MenuBar: React.FC = function ({ {/* Box gets rid of popper.js warning "CSS margin styles cannot be used" */} - } - > + }> Import - - dispatch(togglePackageDataImportDialog()) - } - > - API Data - - - dispatch(toggleAnnotationImportDialog()) - } - > - Annotations - + dispatch(togglePackageDataImportDialog())}>API Data + dispatch(toggleUsageImportDialog())}>Usages + dispatch(toggleAnnotationImportDialog())}>Annotations - - + - + - setFilter(event.target.value) - } - isInvalid={ - !PythonFilter.fromFilterBoxInput(filter) - } + onChange={(event) => setFilter(event.target.value)} + isInvalid={!PythonFilter.fromFilterBoxInput(filter)} borderColor={ - PythonFilter.fromFilterBoxInput( - filter, - )?.isFilteringModules() + PythonFilter.fromFilterBoxInput(filter)?.isFilteringModules() ? 'green' : 'inherit' } spellCheck={false} /> - {PythonFilter.fromFilterBoxInput( - filter, - )?.isFilteringModules() && ( + {PythonFilter.fromFilterBoxInput(filter)?.isFilteringModules() && ( @@ -302,9 +229,7 @@ const MenuBar: React.FC = function ({ - - Each scope must only be used once. - + Each scope must only be used once. diff --git a/api-editor/gui/src/features/usages/UsageImportDialog.tsx b/api-editor/gui/src/features/usages/UsageImportDialog.tsx new file mode 100644 index 000000000..ea1723bbd --- /dev/null +++ b/api-editor/gui/src/features/usages/UsageImportDialog.tsx @@ -0,0 +1,103 @@ +import { + Box, + Button, + FormControl, + FormLabel, + Heading, + HStack, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text as ChakraText, +} from '@chakra-ui/react'; +import * as idb from 'idb-keyval'; +import React, { useState } from 'react'; +import { useAppDispatch } from '../../app/hooks'; +import StyledDropzone from '../../common/StyledDropzone'; +import { Setter } from '../../common/util/types'; +import { isValidJsonFile } from '../../common/util/validation'; +import { resetAnnotations } from '../annotations/annotationSlice'; +import { UsageCountJson, UsageCountStore } from './model/UsageCountStore'; + +import { toggleUsageImportDialog } from './usageSlice'; + +interface ImportPythonPackageDialogProps { + setUsages: Setter; +} + +const UsageImportDialog: React.FC = function ({ setUsages }) { + const [fileName, setFileName] = useState(''); + const [newUsages, setNewUsages] = useState(); + const dispatch = useAppDispatch(); + + const submit = async () => { + if (newUsages) { + const parsedUsages = JSON.parse(newUsages) as UsageCountJson; + setUsages(UsageCountStore.fromJson(parsedUsages)); + + await idb.set('usages', parsedUsages); + } + close(); + }; + const close = () => dispatch(toggleUsageImportDialog()); + + const slurpAndParse = (acceptedFiles: File[]) => { + if (isValidJsonFile(acceptedFiles[acceptedFiles.length - 1].name)) { + if (acceptedFiles.length > 1) { + // eslint-disable-next-line no-param-reassign + acceptedFiles = [acceptedFiles[acceptedFiles.length - 1]]; + } + setFileName(acceptedFiles[0].name); + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + setNewUsages(reader.result); + dispatch(resetAnnotations()); + } + }; + reader.readAsText(acceptedFiles[0]); + } + }; + + return ( + + + + + Import usages + + + + Select a usage file to import. + + Drag and drop a usage file here or click to select the file. + (Only *.json will be accepted.) + + + {fileName && ( + + Imported file: + {fileName} + + )} + + + + + + + + + + + ); +}; + +export default UsageImportDialog; diff --git a/api-editor/gui/src/features/usages/model/UsageCountStore.ts b/api-editor/gui/src/features/usages/model/UsageCountStore.ts new file mode 100644 index 000000000..ad2ac37d8 --- /dev/null +++ b/api-editor/gui/src/features/usages/model/UsageCountStore.ts @@ -0,0 +1,45 @@ +export interface UsageCountJson { + class_counts: { + [target: string]: number; + }; + function_counts: { + [target: string]: number; + }; + parameter_counts: { + [target: string]: number; + }; + value_counts: { + [target: string]: { + [stringifiedValue: string]: number; + }; + }; +} + +export class UsageCountStore { + static fromJson(json: UsageCountJson): UsageCountStore { + return new UsageCountStore( + new Map(Object.entries(json.class_counts)), + new Map(Object.entries(json.function_counts)), + new Map(Object.entries(json.parameter_counts)), + new Map(Object.entries(json.value_counts).map((entry) => [entry[0], new Map(Object.entries(entry[1]))])), + ); + } + + constructor( + readonly classUsages: Map = new Map(), + readonly functionUsages: Map = new Map(), + readonly parameterUsages: Map = new Map(), + readonly valueUsages: Map> = new Map(), + ) {} + + toJson(): UsageCountJson { + return { + class_counts: Object.fromEntries(this.classUsages), + function_counts: Object.fromEntries(this.functionUsages), + parameter_counts: Object.fromEntries(this.parameterUsages), + value_counts: Object.fromEntries( + [...this.valueUsages.entries()].map((entry) => [entry[0], Object.fromEntries(entry[1])]), + ), + }; + } +} diff --git a/api-editor/gui/src/features/usages/usageSlice.ts b/api-editor/gui/src/features/usages/usageSlice.ts new file mode 100644 index 000000000..2ac067df6 --- /dev/null +++ b/api-editor/gui/src/features/usages/usageSlice.ts @@ -0,0 +1,31 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { RootState } from '../../app/store'; + +export interface UsageState { + showImportDialog: boolean; +} + +// Initial state ------------------------------------------------------------------------------------------------------- + +const initialState: UsageState = { + showImportDialog: false, +}; + +// Slice --------------------------------------------------------------------------------------------------------------- + +const usageSlice = createSlice({ + name: 'usages', + initialState, + reducers: { + toggleImportDialog(state) { + state.showImportDialog = !state.showImportDialog; + }, + }, +}); + +const { actions, reducer } = usageSlice; +export const { toggleImportDialog: toggleUsageImportDialog } = actions; +export default reducer; + +const selectUsage = (state: RootState) => state.usages; +export const selectShowUsageImportDialog = (state: RootState): boolean => selectUsage(state).showImportDialog;