From 2f783c6db3efcba53a2bab6086892c7ebab574a4 Mon Sep 17 00:00:00 2001 From: tombogle Date: Fri, 6 Oct 2023 10:33:57 -0400 Subject: [PATCH 01/24] #363: created useProjectDataProvider hook --- .../papi-hooks/use-data-provider.hook.ts | 10 +-- .../use-project-data-provider.hook.ts | 61 +++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts diff --git a/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts b/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts index 952a27177d..4e94562d72 100644 --- a/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts +++ b/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts @@ -7,8 +7,8 @@ import { isString } from '@shared/utils/util'; /** * Gets a data provider with specified provider name - * @param dataProviderSource string name of the data provider to get OR dataProvider (result of useDataProvider if you - * want this hook to just return the data provider again) + * @param dataProviderSource string name of the data provider to get OR dataProvider (result of + * useDataProvider, if you want this hook to just return the data provider again) * @returns undefined if the data provider has not been retrieved, * data provider if it has been retrieved and is not disposed, * and undefined again if the data provider is disposed @@ -24,7 +24,8 @@ function useDataProvider>( const didReceiveDataProvider = !isString(dataProviderSource); // Get the data provider for this data provider name - // Note: do nothing if we received a data provider, but still run this hook. We must make sure to run the same number of hooks in all code paths) + // Note: do nothing if we already received a data provider, but still run this hook. + // (We must make sure to run the same number of hooks in all code paths.) const [dataProvider] = usePromise( useMemo(() => { return didReceiveDataProvider @@ -41,7 +42,8 @@ function useDataProvider>( ); // Disable this hook when the data provider is disposed - // Note: do nothing if we received a data provider, but still run this hook. We must make sure to run the same number of hooks in all code paths) + // Note: do nothing if we already received a data provider, but still run this hook. + // (We must make sure to run the same number of hooks in all code paths.) const [isDisposed, setIsDisposed] = useState(false); useEvent( !didReceiveDataProvider && dataProvider && !isDisposed ? dataProvider.onDidDispose : undefined, diff --git a/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts new file mode 100644 index 0000000000..e0d1fd01f8 --- /dev/null +++ b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts @@ -0,0 +1,61 @@ +import projectDataProviderService from '@shared/services/data-provider.service'; +import IProjectDataProvider from '@shared/models/data-provider.interface'; +import { useCallback, useMemo, useState } from 'react'; +import useEvent from '@renderer/hooks/papi-hooks/use-event.hook'; +import usePromise from '@renderer/hooks/papi-hooks/use-promise.hook'; +import { isString } from '@shared/utils/util'; + +/** + * Gets a project data provider with specified provider name + * @param dataProviderSource string name of the data provider to get OR projectDataProvider (result + * of useProjectDataProvider, if you want this hook to just return the data provider again) + * @returns undefined if the project data provider has not been retrieved, the requested project + * data provider if it has been retrieved and is not disposed, and undefined again if the project + * data provider is disposed + * + * @type `T` - the type of project data provider to return. Use + * `IProjectDataProvider`, specifying your own types, or provide a + * custom project data provider type + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function useProjectDataProvider>( + dataProviderSource: string | T | undefined, +): T | undefined { + // Check to see if they passed in the results of a useDataProvider hook or undefined + const didReceiveDataProvider = !isString(dataProviderSource); + + // Get the requested project data provider + // Note: do nothing if we have already received a project data provider, but still run this hook. + // (We must make sure to run the same number of hooks in all code paths.) + const [dataProvider] = usePromise( + useMemo(() => { + return didReceiveDataProvider + ? // We already have a project data provider or undefined, so we don't need to run this promise + undefined + : async () => + // We have the project data provider's type, so we need to get the provider + dataProviderSource + ? // Type assert here - the user of this hook must make sure to provide the correct type + (projectDataProviderService.get(dataProviderSource) as Promise) + : undefined; + }, [didReceiveDataProvider, dataProviderSource]), + undefined, + ); + + // Disable this hook when the project data provider is disposed + // Note: do nothing if we already received a project data provider, but still run this hook. + // (We must make sure to run the same number of hooks in all code paths.) + const [isDisposed, setIsDisposed] = useState(false); + useEvent( + !didReceiveDataProvider && dataProvider && !isDisposed ? dataProvider.onDidDispose : undefined, + useCallback(() => setIsDisposed(true), []), + ); + + // If we received a data provider or undefined, return it + if (didReceiveDataProvider) return dataProviderSource; + + // If we had to get a data provider, return it if it is not disposed + return dataProvider && !isDisposed ? dataProvider : undefined; +} + +export default useProjectDataProvider; From 3c5e9f0ea25eed5340e723d178e2afd645528f19 Mon Sep 17 00:00:00 2001 From: tombogle Date: Fri, 6 Oct 2023 17:37:35 -0400 Subject: [PATCH 02/24] #363: [WIP] This commit has some experimental code and a hard-coded project-name that needs to change. Loading project by id does not work yet. --- lib/papi-dts/papi.d.ts | 4 ++-- .../run-basic-checks-tab.component.tsx | 23 +++++++++++++++---- .../use-project-data-provider.hook.ts | 12 +++++++--- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index caaff232a0..5a7cdb315d 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -2529,8 +2529,8 @@ declare module 'renderer/hooks/papi-hooks/use-data-provider.hook' { import IDataProvider from 'shared/models/data-provider.interface'; /** * Gets a data provider with specified provider name - * @param dataProviderSource string name of the data provider to get OR dataProvider (result of useDataProvider if you - * want this hook to just return the data provider again) + * @param dataProviderSource string name of the data provider to get OR dataProvider (result of + * useDataProvider, if you want this hook to just return the data provider again) * @returns undefined if the data provider has not been retrieved, * data provider if it has been retrieved and is not disposed, * and undefined again if the data provider is disposed diff --git a/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx b/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx index 09c8f263e1..621d1c9c15 100644 --- a/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx +++ b/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx @@ -2,7 +2,7 @@ import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; import { Button, ScriptureReference, getChaptersForBook } from 'papi-components'; import logger from '@shared/services/logger.service'; import { Typography } from '@mui/material'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import settingsService from '@shared/services/settings.service'; import { fetchProjects } from '@renderer/components/project-dialogs/open-project-tab.component'; import BookSelector from '@renderer/components/run-basic-checks-dialog/book-selector.component'; @@ -11,6 +11,9 @@ import BasicChecks, { } from '@renderer/components/run-basic-checks-dialog/basic-checks.component'; import { Project } from '@renderer/components/project-dialogs/project-list.component'; import './run-basic-checks-tab.component.scss'; +import useProjectDataProvider from '@renderer/hooks/papi-hooks/use-project-data-provider.hook'; +import { VerseRef } from '@sillsdev/scripture'; +import usePromise from '@renderer/hooks/papi-hooks/use-promise.hook'; export const TAB_TYPE_RUN_BASIC_CHECKS = 'run-basic-checks'; @@ -83,11 +86,21 @@ export default function RunBasicChecksTab({ ); }; + const project = useProjectDataProvider(currentProject?.id); + + const [projectString] = usePromise( + useMemo(() => { + return async () => + project === undefined + ? 'No current project' + : project?.ParatextStandard.getVerse(new VerseRef('MAT 4:1')); + }, [project]), + 'Wait???', + ); + return (
- - {currentProject ? currentProject.name : 'No Current Project'} - + {`Run basic checks: ${currentProject?.id}, ${projectString}`} {/* Should always be two columns? */}
Checks @@ -120,7 +133,7 @@ export default function RunBasicChecksTab({ } export const loadRunBasicChecksTab = (savedTabInfo: SavedTabInfo): TabInfo => { - const project = fetchProjects().find((proj) => proj.id === 'project-1'); + const project = fetchProjects().find((proj) => proj.name === 'SPAN'); return { ...savedTabInfo, diff --git a/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts index e0d1fd01f8..65986547c9 100644 --- a/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts +++ b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts @@ -1,5 +1,5 @@ import projectDataProviderService from '@shared/services/data-provider.service'; -import IProjectDataProvider from '@shared/models/data-provider.interface'; +import { ProjectDataProvider } from '@shared/models/project-data-provider-engine.model'; import { useCallback, useMemo, useState } from 'react'; import useEvent from '@renderer/hooks/papi-hooks/use-event.hook'; import usePromise from '@renderer/hooks/papi-hooks/use-promise.hook'; @@ -18,7 +18,7 @@ import { isString } from '@shared/utils/util'; * custom project data provider type */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -function useProjectDataProvider>( +function useProjectDataProvider( dataProviderSource: string | T | undefined, ): T | undefined { // Check to see if they passed in the results of a useDataProvider hook or undefined @@ -47,7 +47,13 @@ function useProjectDataProvider>( // (We must make sure to run the same number of hooks in all code paths.) const [isDisposed, setIsDisposed] = useState(false); useEvent( - !didReceiveDataProvider && dataProvider && !isDisposed ? dataProvider.onDidDispose : undefined, + !didReceiveDataProvider && dataProvider && !isDisposed + ? // REVIEW: I'm pretty sure there must be a better way... + dataProvider.ParatextStandard?.onDidDispose ?? + dataProvider.NotesOnly?.onDidDispose ?? + dataProvider.MyExtensionProjectTypeName?.onDidDispose ?? + undefined + : undefined, useCallback(() => setIsDisposed(true), []), ); From 72d369d8e12d8062fe7fc08372bd33073b658ebd Mon Sep 17 00:00:00 2001 From: Katherine Jensen Date: Wed, 11 Oct 2023 12:15:03 -0700 Subject: [PATCH 03/24] Fix types on Project Data Provider Hook --- .../use-project-data-provider.hook.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts index 65986547c9..0dfc403682 100644 --- a/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts +++ b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts @@ -1,9 +1,10 @@ -import projectDataProviderService from '@shared/services/data-provider.service'; import { ProjectDataProvider } from '@shared/models/project-data-provider-engine.model'; import { useCallback, useMemo, useState } from 'react'; import useEvent from '@renderer/hooks/papi-hooks/use-event.hook'; import usePromise from '@renderer/hooks/papi-hooks/use-promise.hook'; import { isString } from '@shared/utils/util'; +import { papiFrontendProjectDataProviderService } from '@shared/services/project-data-provider.service'; +import { ProjectTypes } from 'papi-shared-types'; /** * Gets a project data provider with specified provider name @@ -18,9 +19,9 @@ import { isString } from '@shared/utils/util'; * custom project data provider type */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -function useProjectDataProvider( - dataProviderSource: string | T | undefined, -): T | undefined { +function useProjectDataProvider( + dataProviderSource: string | ProjectDataProvider[ProjectType] | undefined, +): ProjectDataProvider[ProjectType] | undefined { // Check to see if they passed in the results of a useDataProvider hook or undefined const didReceiveDataProvider = !isString(dataProviderSource); @@ -35,8 +36,9 @@ function useProjectDataProvider( : async () => // We have the project data provider's type, so we need to get the provider dataProviderSource - ? // Type assert here - the user of this hook must make sure to provide the correct type - (projectDataProviderService.get(dataProviderSource) as Promise) + ? papiFrontendProjectDataProviderService.getProjectDataProvider( + dataProviderSource, + ) : undefined; }, [didReceiveDataProvider, dataProviderSource]), undefined, @@ -47,13 +49,7 @@ function useProjectDataProvider( // (We must make sure to run the same number of hooks in all code paths.) const [isDisposed, setIsDisposed] = useState(false); useEvent( - !didReceiveDataProvider && dataProvider && !isDisposed - ? // REVIEW: I'm pretty sure there must be a better way... - dataProvider.ParatextStandard?.onDidDispose ?? - dataProvider.NotesOnly?.onDidDispose ?? - dataProvider.MyExtensionProjectTypeName?.onDidDispose ?? - undefined - : undefined, + !didReceiveDataProvider && dataProvider && !isDisposed ? dataProvider.onDidDispose : undefined, useCallback(() => setIsDisposed(true), []), ); From a8d7aab9f479f412d4407448a43cde1f8aabccf5 Mon Sep 17 00:00:00 2001 From: Katherine Jensen Date: Wed, 11 Oct 2023 14:55:39 -0700 Subject: [PATCH 04/24] Reduce code duplication between useProjectDataProvider and useDataProvider hooks --- lib/papi-dts/papi.d.ts | 13 ++++- .../run-basic-checks-tab.component.tsx | 25 ++++----- .../create-use-network-object-hook.util.ts | 52 +++++++++++++++++++ .../papi-hooks/use-data-provider.hook.ts | 51 +++--------------- .../use-project-data-provider.hook.ts | 50 +++--------------- 5 files changed, 90 insertions(+), 101 deletions(-) create mode 100644 src/renderer/hooks/create-use-network-object-hook.util.ts diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index 5a7cdb315d..73213640a3 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -2525,6 +2525,15 @@ declare module 'renderer/hooks/papi-hooks/use-event-async.hook' { ) => void; export default useEventAsync; } +declare module 'renderer/hooks/create-use-network-object-hook.util' { + import { NetworkObject } from 'shared/models/network-object.model'; + function createUseNetworkObjectHook( + getNetworkObject: (id: string) => Promise | undefined>, + ): ( + networkObjectSource: string | NetworkObject | undefined, + ) => NetworkObject | undefined; + export default createUseNetworkObjectHook; +} declare module 'renderer/hooks/papi-hooks/use-data-provider.hook' { import IDataProvider from 'shared/models/data-provider.interface'; /** @@ -2538,9 +2547,9 @@ declare module 'renderer/hooks/papi-hooks/use-data-provider.hook' { * @type `T` - the type of data provider to return. Use `IDataProvider`, * specifying your own types, or provide a custom data provider type */ - function useDataProvider>( + const useDataProvider: >( dataProviderSource: string | T | undefined, - ): T | undefined; + ) => T | undefined; export default useDataProvider; } declare module 'renderer/hooks/papi-hooks/use-data.hook' { diff --git a/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx b/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx index 621d1c9c15..a56077be14 100644 --- a/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx +++ b/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx @@ -4,12 +4,10 @@ import logger from '@shared/services/logger.service'; import { Typography } from '@mui/material'; import { useState, useMemo } from 'react'; import settingsService from '@shared/services/settings.service'; -import { fetchProjects } from '@renderer/components/project-dialogs/open-project-tab.component'; import BookSelector from '@renderer/components/run-basic-checks-dialog/book-selector.component'; import BasicChecks, { fetchChecks, } from '@renderer/components/run-basic-checks-dialog/basic-checks.component'; -import { Project } from '@renderer/components/project-dialogs/project-list.component'; import './run-basic-checks-tab.component.scss'; import useProjectDataProvider from '@renderer/hooks/papi-hooks/use-project-data-provider.hook'; import { VerseRef } from '@sillsdev/scripture'; @@ -20,12 +18,12 @@ export const TAB_TYPE_RUN_BASIC_CHECKS = 'run-basic-checks'; // Changing global scripture reference won't effect the dialog because reference is passed in once at the start. type RunBasicChecksTabProps = { currentScriptureReference: ScriptureReference | null; - currentProject: Project | undefined; + currentProjectId: string | undefined; }; export default function RunBasicChecksTab({ currentScriptureReference, - currentProject, + currentProjectId, }: RunBasicChecksTabProps) { const currentBookNumber = currentScriptureReference?.bookNum ?? 1; const basicChecks = fetchChecks(); @@ -86,21 +84,20 @@ export default function RunBasicChecksTab({ ); }; - const project = useProjectDataProvider(currentProject?.id); + const project = useProjectDataProvider<'ParatextStandard'>(currentProjectId); + logger.info(``); const [projectString] = usePromise( useMemo(() => { return async () => - project === undefined - ? 'No current project' - : project?.ParatextStandard.getVerse(new VerseRef('MAT 4:1')); + project === undefined ? 'No current project' : project.getVerse(new VerseRef('MAT 4:1')); }, [project]), 'Wait???', ); return (
- {`Run basic checks: ${currentProject?.id}, ${projectString}`} + {`Run basic checks: ${currentProjectId}, ${projectString}`} {/* Should always be two columns? */}
Checks @@ -133,14 +130,18 @@ export default function RunBasicChecksTab({ } export const loadRunBasicChecksTab = (savedTabInfo: SavedTabInfo): TabInfo => { - const project = fetchProjects().find((proj) => proj.name === 'SPAN'); - return { ...savedTabInfo, tabTitle: 'Run Basic Checks', content: ( /.platform.bible/_/project/paratext + // For example: "~/.platform.bible/projects/TPKJ_b4c501ad2538989d6fb723518e92408406e232d3/project/paratext" + // Then create a file named "meta.json" in the "_" directory with this JSON: + currentProjectId="INSERT YOUR PROJECT ID HERE" currentScriptureReference={settingsService.get('platform.verseRef')} /> ), diff --git a/src/renderer/hooks/create-use-network-object-hook.util.ts b/src/renderer/hooks/create-use-network-object-hook.util.ts new file mode 100644 index 0000000000..1d26273ac6 --- /dev/null +++ b/src/renderer/hooks/create-use-network-object-hook.util.ts @@ -0,0 +1,52 @@ +import { NetworkObject } from '@shared/models/network-object.model'; +import { useMemo, useState, useCallback } from 'react'; +import { isString } from '@shared/utils/util'; +import useEvent from '@renderer/hooks/papi-hooks/use-event.hook'; +import usePromise from '@renderer/hooks/papi-hooks/use-promise.hook'; + +function createUseNetworkObjectHook( + getNetworkObject: (id: string) => Promise | undefined>, +): ( + networkObjectSource: string | NetworkObject | undefined, +) => NetworkObject | undefined { + return function useNetworkObject( + networkObjectSource: string | NetworkObject | undefined, + ): NetworkObject | undefined { + // Check to see if they passed in the results of a useDataProvider hook or undefined + const didReceiveDataProvider = !isString(networkObjectSource); + + // Get the data provider for this data provider name + // Note: do nothing if we already a data provider, but still run this hook. + // (We must make sure to run the same number of hooks in all code paths.) + const [dataProvider] = usePromise( + useMemo(() => { + return didReceiveDataProvider + ? // We already have a data provider or undefined, so we don't need to run this promise + undefined + : async () => + // We have the data provider's type, so we need to get the provider + networkObjectSource ? getNetworkObject(networkObjectSource) : undefined; + }, [didReceiveDataProvider, networkObjectSource]), + undefined, + ); + + // Disable this hook when the data provider is disposed + // Note: do nothing if we already received a data provider, but still run this hook. + // (We must make sure to run the same number of hooks in all code paths.) + const [isDisposed, setIsDisposed] = useState(false); + useEvent( + !didReceiveDataProvider && dataProvider && !isDisposed + ? dataProvider.onDidDispose + : undefined, + useCallback(() => setIsDisposed(true), []), + ); + + // If we received a data provider or undefined, return it + if (didReceiveDataProvider) return networkObjectSource; + + // If we had to get a data provider, return it if it is not disposed + return dataProvider && !isDisposed ? dataProvider : undefined; + }; +} + +export default createUseNetworkObjectHook; diff --git a/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts b/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts index 4e94562d72..10f08fb192 100644 --- a/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts +++ b/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts @@ -1,9 +1,6 @@ import dataProviderService from '@shared/services/data-provider.service'; import IDataProvider from '@shared/models/data-provider.interface'; -import { useCallback, useMemo, useState } from 'react'; -import useEvent from '@renderer/hooks/papi-hooks/use-event.hook'; -import usePromise from '@renderer/hooks/papi-hooks/use-promise.hook'; -import { isString } from '@shared/utils/util'; +import createUseNetworkObjectHook from '@renderer/hooks/create-use-network-object-hook.util'; /** * Gets a data provider with specified provider name @@ -16,45 +13,13 @@ import { isString } from '@shared/utils/util'; * @type `T` - the type of data provider to return. Use `IDataProvider`, * specifying your own types, or provide a custom data provider type */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function useDataProvider>( - dataProviderSource: string | T | undefined, -): T | undefined { - // Check to see if they passed in the results of a useDataProvider hook or undefined - const didReceiveDataProvider = !isString(dataProviderSource); - - // Get the data provider for this data provider name - // Note: do nothing if we already received a data provider, but still run this hook. - // (We must make sure to run the same number of hooks in all code paths.) - const [dataProvider] = usePromise( - useMemo(() => { - return didReceiveDataProvider - ? // We already have a data provider or undefined, so we don't need to run this promise - undefined - : async () => - // We have the data provider's type, so we need to get the provider - dataProviderSource - ? // Type assert here - the user of this hook must make sure to provide the correct type - (dataProviderService.get(dataProviderSource) as Promise) - : undefined; - }, [didReceiveDataProvider, dataProviderSource]), - undefined, - ); - - // Disable this hook when the data provider is disposed - // Note: do nothing if we already received a data provider, but still run this hook. - // (We must make sure to run the same number of hooks in all code paths.) - const [isDisposed, setIsDisposed] = useState(false); - useEvent( - !didReceiveDataProvider && dataProvider && !isDisposed ? dataProvider.onDidDispose : undefined, - useCallback(() => setIsDisposed(true), []), - ); - // If we received a data provider or undefined, return it - if (didReceiveDataProvider) return dataProviderSource; - - // If we had to get a data provider, return it if it is not disposed - return dataProvider && !isDisposed ? dataProvider : undefined; -} +const useDataProvider = createUseNetworkObjectHook(dataProviderService.get) as < + // We don't know what type the data provider serves + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends IDataProvider, +>( + dataProviderSource: string | T | undefined, +) => T | undefined; export default useDataProvider; diff --git a/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts index 0dfc403682..8fa30e3f70 100644 --- a/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts +++ b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts @@ -1,10 +1,7 @@ import { ProjectDataProvider } from '@shared/models/project-data-provider-engine.model'; -import { useCallback, useMemo, useState } from 'react'; -import useEvent from '@renderer/hooks/papi-hooks/use-event.hook'; -import usePromise from '@renderer/hooks/papi-hooks/use-promise.hook'; -import { isString } from '@shared/utils/util'; import { papiFrontendProjectDataProviderService } from '@shared/services/project-data-provider.service'; import { ProjectTypes } from 'papi-shared-types'; +import createUseNetworkObjectHook from '@renderer/hooks/create-use-network-object-hook.util'; /** * Gets a project data provider with specified provider name @@ -18,46 +15,11 @@ import { ProjectTypes } from 'papi-shared-types'; * `IProjectDataProvider`, specifying your own types, or provide a * custom project data provider type */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function useProjectDataProvider( - dataProviderSource: string | ProjectDataProvider[ProjectType] | undefined, -): ProjectDataProvider[ProjectType] | undefined { - // Check to see if they passed in the results of a useDataProvider hook or undefined - const didReceiveDataProvider = !isString(dataProviderSource); - - // Get the requested project data provider - // Note: do nothing if we have already received a project data provider, but still run this hook. - // (We must make sure to run the same number of hooks in all code paths.) - const [dataProvider] = usePromise( - useMemo(() => { - return didReceiveDataProvider - ? // We already have a project data provider or undefined, so we don't need to run this promise - undefined - : async () => - // We have the project data provider's type, so we need to get the provider - dataProviderSource - ? papiFrontendProjectDataProviderService.getProjectDataProvider( - dataProviderSource, - ) - : undefined; - }, [didReceiveDataProvider, dataProviderSource]), - undefined, - ); - - // Disable this hook when the project data provider is disposed - // Note: do nothing if we already received a project data provider, but still run this hook. - // (We must make sure to run the same number of hooks in all code paths.) - const [isDisposed, setIsDisposed] = useState(false); - useEvent( - !didReceiveDataProvider && dataProvider && !isDisposed ? dataProvider.onDidDispose : undefined, - useCallback(() => setIsDisposed(true), []), - ); - // If we received a data provider or undefined, return it - if (didReceiveDataProvider) return dataProviderSource; - - // If we had to get a data provider, return it if it is not disposed - return dataProvider && !isDisposed ? dataProvider : undefined; -} +const useProjectDataProvider = createUseNetworkObjectHook( + papiFrontendProjectDataProviderService.getProjectDataProvider, +) as ( + dataProviderSource: string | ProjectDataProvider[ProjectType] | undefined, +) => ProjectDataProvider[ProjectType] | undefined; export default useProjectDataProvider; From 7fe92404938996637e270b0c5020a53ea2596024 Mon Sep 17 00:00:00 2001 From: Katherine Jensen Date: Thu, 12 Oct 2023 11:37:40 -0700 Subject: [PATCH 05/24] Changes per Code Review --- .../run-basic-checks-tab.component.tsx | 7 ++- .../create-use-network-object-hook.util.ts | 52 ----------------- .../create-use-network-object-hook.util.ts | 58 +++++++++++++++++++ src/renderer/hooks/papi-hooks/index.ts | 3 + .../papi-hooks/use-data-provider.hook.ts | 2 +- .../use-project-data-provider.hook.ts | 11 ++-- 6 files changed, 71 insertions(+), 62 deletions(-) delete mode 100644 src/renderer/hooks/create-use-network-object-hook.util.ts create mode 100644 src/renderer/hooks/hook-generators/create-use-network-object-hook.util.ts diff --git a/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx b/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx index a56077be14..38ad2056f5 100644 --- a/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx +++ b/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx @@ -85,14 +85,15 @@ export default function RunBasicChecksTab({ }; const project = useProjectDataProvider<'ParatextStandard'>(currentProjectId); - logger.info(``); const [projectString] = usePromise( useMemo(() => { return async () => - project === undefined ? 'No current project' : project.getVerse(new VerseRef('MAT 4:1')); + project === undefined + ? 'No current project' + : project.getVerseUSFM(new VerseRef('MAT 4:1')); }, [project]), - 'Wait???', + 'Loading', ); return ( diff --git a/src/renderer/hooks/create-use-network-object-hook.util.ts b/src/renderer/hooks/create-use-network-object-hook.util.ts deleted file mode 100644 index 1d26273ac6..0000000000 --- a/src/renderer/hooks/create-use-network-object-hook.util.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NetworkObject } from '@shared/models/network-object.model'; -import { useMemo, useState, useCallback } from 'react'; -import { isString } from '@shared/utils/util'; -import useEvent from '@renderer/hooks/papi-hooks/use-event.hook'; -import usePromise from '@renderer/hooks/papi-hooks/use-promise.hook'; - -function createUseNetworkObjectHook( - getNetworkObject: (id: string) => Promise | undefined>, -): ( - networkObjectSource: string | NetworkObject | undefined, -) => NetworkObject | undefined { - return function useNetworkObject( - networkObjectSource: string | NetworkObject | undefined, - ): NetworkObject | undefined { - // Check to see if they passed in the results of a useDataProvider hook or undefined - const didReceiveDataProvider = !isString(networkObjectSource); - - // Get the data provider for this data provider name - // Note: do nothing if we already a data provider, but still run this hook. - // (We must make sure to run the same number of hooks in all code paths.) - const [dataProvider] = usePromise( - useMemo(() => { - return didReceiveDataProvider - ? // We already have a data provider or undefined, so we don't need to run this promise - undefined - : async () => - // We have the data provider's type, so we need to get the provider - networkObjectSource ? getNetworkObject(networkObjectSource) : undefined; - }, [didReceiveDataProvider, networkObjectSource]), - undefined, - ); - - // Disable this hook when the data provider is disposed - // Note: do nothing if we already received a data provider, but still run this hook. - // (We must make sure to run the same number of hooks in all code paths.) - const [isDisposed, setIsDisposed] = useState(false); - useEvent( - !didReceiveDataProvider && dataProvider && !isDisposed - ? dataProvider.onDidDispose - : undefined, - useCallback(() => setIsDisposed(true), []), - ); - - // If we received a data provider or undefined, return it - if (didReceiveDataProvider) return networkObjectSource; - - // If we had to get a data provider, return it if it is not disposed - return dataProvider && !isDisposed ? dataProvider : undefined; - }; -} - -export default createUseNetworkObjectHook; diff --git a/src/renderer/hooks/hook-generators/create-use-network-object-hook.util.ts b/src/renderer/hooks/hook-generators/create-use-network-object-hook.util.ts new file mode 100644 index 0000000000..a6bcb6349e --- /dev/null +++ b/src/renderer/hooks/hook-generators/create-use-network-object-hook.util.ts @@ -0,0 +1,58 @@ +import { NetworkObject } from '@shared/models/network-object.model'; +import { useMemo, useState, useCallback } from 'react'; +import { isString } from '@shared/utils/util'; +import useEvent from '@renderer/hooks/papi-hooks/use-event.hook'; +import usePromise from '@renderer/hooks/papi-hooks/use-promise.hook'; + +/** + * This function takes in a getNetworkObject function and creates a hook with that function in it + * which will return a network object + * @param getNetworkObject A function that takes in an id string and returns a network object + * @returns a function that takes in a networkObjectSource and returns a NetworkObject + */ +function createUseNetworkObjectHook( + getNetworkObject: (id: string) => Promise | undefined>, +): ( + networkObjectSource: string | NetworkObject | undefined, +) => NetworkObject | undefined { + return function useNetworkObject( + networkObjectSource: string | NetworkObject | undefined, + ): NetworkObject | undefined { + // Check to see if they passed in the results of a useNetworkObject hook or undefined + const didReceiveNetworkObject = !isString(networkObjectSource); + + // Get the network object for this network object name + // Note: do nothing if we already a network object, but still run this hook. + // (We must make sure to run the same number of hooks in all code paths.) + const [networkObject] = usePromise( + useMemo(() => { + return didReceiveNetworkObject + ? // We already have a network object or undefined, so we don't need to run this promise + undefined + : async () => + // We have the network object's type, so we need to get the provider + networkObjectSource ? getNetworkObject(networkObjectSource) : undefined; + }, [didReceiveNetworkObject, networkObjectSource]), + undefined, + ); + + // Disable this hook when the network object is disposed + // Note: do nothing if we already received a network object, but still run this hook. + // (We must make sure to run the same number of hooks in all code paths.) + const [isDisposed, setIsDisposed] = useState(false); + useEvent( + !didReceiveNetworkObject && networkObject && !isDisposed + ? networkObject.onDidDispose + : undefined, + useCallback(() => setIsDisposed(true), []), + ); + + // If we received a network object or undefined, return it + if (didReceiveNetworkObject) return networkObjectSource; + + // If we had to get a network object, return it if it is not disposed + return networkObject && !isDisposed ? networkObject : undefined; + }; +} + +export default createUseNetworkObjectHook; diff --git a/src/renderer/hooks/papi-hooks/index.ts b/src/renderer/hooks/papi-hooks/index.ts index f82c53226a..41903583d6 100644 --- a/src/renderer/hooks/papi-hooks/index.ts +++ b/src/renderer/hooks/papi-hooks/index.ts @@ -4,12 +4,14 @@ import useEventAsync from '@renderer/hooks/papi-hooks/use-event-async.hook'; import useDataProvider from '@renderer/hooks/papi-hooks/use-data-provider.hook'; import useData from '@renderer/hooks/papi-hooks/use-data.hook'; import useSetting from '@renderer/hooks/papi-hooks/use-setting.hook'; +import useProjectDataProvider from './use-project-data-provider.hook'; // Declare an interface for the object we're exporting so that JSDoc comments propagate export interface PapiHooks { usePromise: typeof usePromise; useEvent: typeof useEvent; useEventAsync: typeof useEventAsync; + useProjectDataProvider: typeof useProjectDataProvider; useDataProvider: typeof useDataProvider; /** JSDOC DESTINATION UseDataHook */ useData: typeof useData; @@ -23,6 +25,7 @@ const papiHooks: PapiHooks = { usePromise, useEvent, useEventAsync, + useProjectDataProvider, useDataProvider, useData, useSetting, diff --git a/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts b/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts index 10f08fb192..8286607553 100644 --- a/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts +++ b/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts @@ -1,6 +1,6 @@ import dataProviderService from '@shared/services/data-provider.service'; import IDataProvider from '@shared/models/data-provider.interface'; -import createUseNetworkObjectHook from '@renderer/hooks/create-use-network-object-hook.util'; +import createUseNetworkObjectHook from '@renderer/hooks/hook-generators/create-use-network-object-hook.util'; /** * Gets a data provider with specified provider name diff --git a/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts index 8fa30e3f70..5f092bee20 100644 --- a/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts +++ b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts @@ -1,25 +1,24 @@ import { ProjectDataProvider } from '@shared/models/project-data-provider-engine.model'; import { papiFrontendProjectDataProviderService } from '@shared/services/project-data-provider.service'; import { ProjectTypes } from 'papi-shared-types'; -import createUseNetworkObjectHook from '@renderer/hooks/create-use-network-object-hook.util'; +import createUseNetworkObjectHook from '@renderer/hooks/hook-generators/create-use-network-object-hook.util'; /** * Gets a project data provider with specified provider name - * @param dataProviderSource string name of the data provider to get OR projectDataProvider (result + * @param projectDataProviderSource string name of the id of the project to get OR projectDataProvider (result * of useProjectDataProvider, if you want this hook to just return the data provider again) * @returns undefined if the project data provider has not been retrieved, the requested project * data provider if it has been retrieved and is not disposed, and undefined again if the project * data provider is disposed * - * @type `T` - the type of project data provider to return. Use - * `IProjectDataProvider`, specifying your own types, or provide a - * custom project data provider type + * @ProjectType `T` - the project type for the project to use. The returned project data provider + * will have the project data provider type associated with this project type. */ const useProjectDataProvider = createUseNetworkObjectHook( papiFrontendProjectDataProviderService.getProjectDataProvider, ) as ( - dataProviderSource: string | ProjectDataProvider[ProjectType] | undefined, + projectDataProviderSource: string | ProjectDataProvider[ProjectType] | undefined, ) => ProjectDataProvider[ProjectType] | undefined; export default useProjectDataProvider; From ee95b57f114e34cde52354925a03d6a86bc5738c Mon Sep 17 00:00:00 2001 From: Katherine Jensen Date: Thu, 12 Oct 2023 14:25:17 -0700 Subject: [PATCH 06/24] Missed a few files --- lib/papi-dts/papi.d.ts | 30 ++++++++++++++++++- src/declarations/papi-shared-types.ts | 4 +++ .../use-project-data-provider.hook.ts | 8 ++--- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index 423c858935..b0e5650ae1 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -1583,6 +1583,7 @@ declare module 'papi-shared-types' { */ interface ProjectDataTypes { NotesOnly: NotesOnlyProjectDataTypes; + placeholder: MandatoryProjectDataType; } /** * Identifiers for all project types supported by PAPI. These are not intended to correspond 1:1 @@ -2517,8 +2518,14 @@ declare module 'renderer/hooks/papi-hooks/use-event-async.hook' { ) => void; export default useEventAsync; } -declare module 'renderer/hooks/create-use-network-object-hook.util' { +declare module 'renderer/hooks/hook-generators/create-use-network-object-hook.util' { import { NetworkObject } from 'shared/models/network-object.model'; + /** + * This function takes in a getNetworkObject function and creates a hook with that function in it + * which will return a network object + * @param getNetworkObject A function that takes in an id string and returns a network object + * @returns a function that takes in a networkObjectSource and returns a NetworkObject + */ function createUseNetworkObjectHook( getNetworkObject: (id: string) => Promise | undefined>, ): ( @@ -2677,6 +2684,25 @@ declare module 'renderer/hooks/papi-hooks/use-setting.hook' { ) => [SettingTypes[SettingName], (newSetting: SettingTypes[SettingName]) => void]; export default useSetting; } +declare module 'renderer/hooks/papi-hooks/use-project-data-provider.hook' { + import { ProjectDataTypes } from 'papi-shared-types'; + import IDataProvider from 'shared/models/data-provider.interface'; + /** + * Gets a project data provider with specified provider name + * @param projectDataProviderSource string name of the id of the project to get OR projectDataProvider (result + * of useProjectDataProvider, if you want this hook to just return the data provider again) + * @returns undefined if the project data provider has not been retrieved, the requested project + * data provider if it has been retrieved and is not disposed, and undefined again if the project + * data provider is disposed + * + * @ProjectType `T` - the project type for the project to use. The returned project data provider + * will have the project data provider type associated with this project type. + */ + const useProjectDataProvider: ( + projectDataProviderSource: string | IDataProvider | undefined, + ) => IDataProvider | undefined; + export default useProjectDataProvider; +} declare module 'renderer/hooks/papi-hooks/index' { import usePromise from 'renderer/hooks/papi-hooks/use-promise.hook'; import useEvent from 'renderer/hooks/papi-hooks/use-event.hook'; @@ -2684,10 +2710,12 @@ declare module 'renderer/hooks/papi-hooks/index' { import useDataProvider from 'renderer/hooks/papi-hooks/use-data-provider.hook'; import useData from 'renderer/hooks/papi-hooks/use-data.hook'; import useSetting from 'renderer/hooks/papi-hooks/use-setting.hook'; + import useProjectDataProvider from 'renderer/hooks/papi-hooks/use-project-data-provider.hook'; export interface PapiHooks { usePromise: typeof usePromise; useEvent: typeof useEvent; useEventAsync: typeof useEventAsync; + useProjectDataProvider: typeof useProjectDataProvider; useDataProvider: typeof useDataProvider; /** * Special React hook that subscribes to run a callback on a data provider's data with specified diff --git a/src/declarations/papi-shared-types.ts b/src/declarations/papi-shared-types.ts index f8b06bcf54..20a8ae5621 100644 --- a/src/declarations/papi-shared-types.ts +++ b/src/declarations/papi-shared-types.ts @@ -86,6 +86,10 @@ declare module 'papi-shared-types' { */ export interface ProjectDataTypes { NotesOnly: NotesOnlyProjectDataTypes; + // With only one key in this interface, `papi.d.ts` was baking in the literal string when + // `SettingNames` was being used. Adding a placeholder key makes TypeScript generate `papi.d.ts` + // correctly. When we add another setting, we can remove this placeholder. + placeholder: MandatoryProjectDataType; } /** diff --git a/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts index 5f092bee20..1508b18894 100644 --- a/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts +++ b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts @@ -1,7 +1,7 @@ -import { ProjectDataProvider } from '@shared/models/project-data-provider-engine.model'; import { papiFrontendProjectDataProviderService } from '@shared/services/project-data-provider.service'; -import { ProjectTypes } from 'papi-shared-types'; +import { ProjectTypes, ProjectDataTypes } from 'papi-shared-types'; import createUseNetworkObjectHook from '@renderer/hooks/hook-generators/create-use-network-object-hook.util'; +import IDataProvider from '@shared/models/data-provider.interface'; /** * Gets a project data provider with specified provider name @@ -18,7 +18,7 @@ import createUseNetworkObjectHook from '@renderer/hooks/hook-generators/create-u const useProjectDataProvider = createUseNetworkObjectHook( papiFrontendProjectDataProviderService.getProjectDataProvider, ) as ( - projectDataProviderSource: string | ProjectDataProvider[ProjectType] | undefined, -) => ProjectDataProvider[ProjectType] | undefined; + projectDataProviderSource: string | IDataProvider | undefined, +) => IDataProvider | undefined; export default useProjectDataProvider; From d551cd2f4e6cd2c968e4f85d1435526bd2a0bddd Mon Sep 17 00:00:00 2001 From: Matt Lyons Date: Fri, 13 Oct 2023 13:22:26 -0500 Subject: [PATCH 07/24] Add web view state service and corresponding React hook (#545) --- .../web-views/hello-world.web-view.tsx | 9 +- lib/papi-dts/papi.d.ts | 61 +++++++++- src/renderer/global-this.model.ts | 13 +++ src/renderer/hooks/use-webview-state.ts | 21 ++++ src/renderer/index.tsx | 6 + .../services/web-view-state.service.ts | 109 ++++++++++++++++++ src/shared/data/web-view.model.ts | 2 + src/shared/global-this.model.ts | 25 +++- src/shared/services/web-view.service.ts | 18 ++- 9 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 src/renderer/hooks/use-webview-state.ts create mode 100644 src/renderer/services/web-view-state.service.ts diff --git a/extensions/src/hello-world/web-views/hello-world.web-view.tsx b/extensions/src/hello-world/web-views/hello-world.web-view.tsx index 361b13b864..54f4bb3d29 100644 --- a/extensions/src/hello-world/web-views/hello-world.web-view.tsx +++ b/extensions/src/hello-world/web-views/hello-world.web-view.tsx @@ -59,7 +59,7 @@ papi globalThis.webViewComponent = function HelloWorld() { const test = useContext(TestContext) || "Context didn't work!! :("; - const [clicks, setClicks] = useState(0); + const [clicks, setClicks] = globalThis.useWebViewState('clicks', 0); const [rows, setRows] = useState(initializeRows()); const [selectedRows, setSelectedRows] = useState(new Set()); const [scrRef, setScrRef] = useSetting('platform.verseRef', defaultScrRef); @@ -67,7 +67,12 @@ globalThis.webViewComponent = function HelloWorld() { // Update the clicks when we are informed helloWorld has been run useEvent( 'helloWorld.onHelloWorld', - useCallback(({ times }: HelloWorldEvent) => setClicks(times), []), + useCallback( + ({ times }: HelloWorldEvent) => { + if (times > clicks) setClicks(times); + }, + [clicks, setClicks], + ), ); const [echoResult] = usePromise( diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index b0e5650ae1..7f0afb7f95 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -3,7 +3,7 @@ /// declare module 'shared/global-this.model' { import { LogLevel } from 'electron-log'; - import { FunctionComponent } from 'react'; + import { FunctionComponent, Dispatch, SetStateAction } from 'react'; /** * Variables that are defined in global scope. These must be defined in main.ts (main), index.ts (renderer), and extension-host.ts (extension host) */ @@ -20,9 +20,30 @@ declare module 'shared/global-this.model' { var logLevel: LogLevel; /** * A function that each React WebView extension must provide for Paranext to display it. - * Only used in WebView iframes + * Only used in WebView iframes. */ var webViewComponent: FunctionComponent; + /** + * A React hook for working with a state object tied to a webview. + * Only used in WebView iframes. + * @param stateKey Key of the state value to use. The webview state holds a unique value per key. + * NOTE: `stateKey` needs to be a constant string, not something that could change during execution. + * @param defaultStateValue Value to use if the web view state didn't contain a value for the given 'stateKey' + * @returns string holding the state value and a function to use to update the state value + * @example const [lastPersonSeen, setLastPersonSeen] = useWebViewState('lastSeen'); + */ + var useWebViewState: ( + stateKey: string, + defaultStateValue: NonNullable, + ) => [webViewState: NonNullable, setWebViewState: Dispatch>>]; + /** + * Retrieve the value from web view state with the given 'stateKey', if it exists. + */ + var getWebViewState: (stateKey: string) => T | undefined; + /** + * Set the value for a given key in the web view state. + */ + var setWebViewState: (stateKey: string, stateValue: NonNullable) => void; } /** Type of Paranext process */ export enum ProcessType { @@ -1725,6 +1746,8 @@ declare module 'shared/data/web-view.model' { content: string; /** Name of the tab for the WebView */ title?: string; + /** General object to store unique state for this webview */ + state?: Record; }; /** WebView representation using React */ export type WebViewDefinitionReact = WebViewDefinitionBase & { @@ -1907,6 +1930,40 @@ declare module 'shared/log-error.model' { constructor(message?: string); } } +declare module 'renderer/services/web-view-state.service' { + /** + * Get the web view state associated with the given ID + * This function is only intended to be used at startup. getWebViewState is intended for web views to call. + * @param id ID of the web view + * @returns state object of the given web view + */ + export function getFullWebViewStateById(id: string): Record; + /** + * Set the web view state associated with the given ID + * This function is only intended to be used at startup. setWebViewState is intended for web views to call. + * @param id ID of the web view + * @param state State to set for the given web view + */ + export function setFullWebViewStateById(id: string, state: Record): void; + /** + * Get the web view state associated with the given ID + * @param id ID of the web view + * @param stateKey Key used to retrieve the state value + * @returns string (if it exists) containing the state for the given key of the given web view + */ + export function getWebViewStateById(id: string, stateKey: string): T | undefined; + /** + * Set the web view state object associated with the given ID + * @param id ID of the web view + * @param stateKey Key for the associated state + * @param stateValue Value of the state for the given key of the given web view - must work with JSON.stringify/parse + */ + export function setWebViewStateById(id: string, stateKey: string, stateValue: T): void; + /** Purge any web view state that hasn't been touched since the process has been running. + * Only call this once all web views have been loaded. + */ + export function cleanupOldWebViewState(): void; +} declare module 'shared/services/web-view.service' { import { Unsubscriber } from 'shared/utils/papi-util'; import { MutableRefObject } from 'react'; diff --git a/src/renderer/global-this.model.ts b/src/renderer/global-this.model.ts index ee1a0b074d..388db7fb07 100644 --- a/src/renderer/global-this.model.ts +++ b/src/renderer/global-this.model.ts @@ -10,6 +10,11 @@ import * as SillsdevScripture from '@sillsdev/scripture'; import { ProcessType } from '@shared/global-this.model'; import papi, { Papi } from '@renderer/services/papi-frontend.service'; import { getModuleSimilarApiMessage } from '@shared/utils/papi-util'; +import { + getWebViewStateById, + setWebViewStateById, +} from '@renderer/services/web-view-state.service'; +import useWebViewState from '@renderer/hooks/use-webview-state'; // #region webpack DefinePlugin types setup - these should be from the renderer webpack DefinePlugin @@ -57,6 +62,9 @@ declare global { var createRoot: typeof ReactDOMClient.createRoot; var SillsdevScripture: SillsdevScriptureType; var webViewRequire: WebViewRequire; + // Web view state functions are used in the default imports for each webview in web-view.service.ts + var getWebViewStateById: (id: string, stateKey: string) => T | undefined; + var setWebViewStateById: (id: string, stateKey: string, stateValue: NonNullable) => void; } /* eslint-enable */ @@ -81,5 +89,10 @@ globalThis.ReactDOMClient = ReactDOMClient; globalThis.createRoot = ReactDOMClient.createRoot; globalThis.SillsdevScripture = SillsdevScripture; globalThis.webViewRequire = webViewRequire; +// We don't expose get/setWebViewStateById in PAPI because web views don't have access to IDs +globalThis.getWebViewStateById = getWebViewStateById; +globalThis.setWebViewStateById = setWebViewStateById; +// We store the hook reference because we need it to bind it to the webview's iframe 'window' context +globalThis.useWebViewState = useWebViewState; // #endregion diff --git a/src/renderer/hooks/use-webview-state.ts b/src/renderer/hooks/use-webview-state.ts new file mode 100644 index 0000000000..c231f1523c --- /dev/null +++ b/src/renderer/hooks/use-webview-state.ts @@ -0,0 +1,21 @@ +import { useState, useEffect, Dispatch, SetStateAction } from 'react'; + +// We don't add this to PAPI directly like other hooks because `this` has to be bound to a web view's iframe context +/** See src/shared/global-this.model.ts for normal hook documentation */ +export default function useWebViewState( + this: { + getWebViewState: (stateKey: string) => T | undefined; + setWebViewState: (stateKey: string, stateValue: NonNullable) => void; + }, + stateKey: string, + defaultStateValue: NonNullable, +): [webViewState: NonNullable, setWebViewState: Dispatch>>] { + const [state, setState] = useState(() => this.getWebViewState(stateKey) ?? defaultStateValue); + + // Whenever the state changes, save the updated value + useEffect(() => { + this.setWebViewState(stateKey, state); + }, [stateKey, state]); + + return [state, setState]; +} diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index 0886e42af9..d1747dea9e 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -6,6 +6,7 @@ import * as webViewService from '@shared/services/web-view.service'; import logger from '@shared/services/logger.service'; import webViewProviderService from '@shared/services/web-view-provider.service'; import App from './app.component'; +import { cleanupOldWebViewState } from './services/web-view-state.service'; logger.info('Starting renderer'); @@ -18,3 +19,8 @@ webViewService.initialize(); const container = document.getElementById('root'); const root = createRoot(container as HTMLElement); root.render(); + +// This doesn't run if the renderer has an uncaught exception (which is a good thing) +window.addEventListener('beforeunload', () => { + cleanupOldWebViewState(); +}); diff --git a/src/renderer/services/web-view-state.service.ts b/src/renderer/services/web-view-state.service.ts new file mode 100644 index 0000000000..1e9c167f40 --- /dev/null +++ b/src/renderer/services/web-view-state.service.ts @@ -0,0 +1,109 @@ +const WEBVIEW_STATE_KEY = 'web-view-state'; +const stateMap = new Map>(); +const idsLookedUp = new Set(); + +function loadIfNeeded(): void { + // If we have any data or tried to look something up, we've already loaded + if (stateMap.size > 0 || idsLookedUp.size > 0) return; + + const serializedState = localStorage.getItem(WEBVIEW_STATE_KEY); + if (!serializedState) return; + + const entries = JSON.parse(serializedState) as [[string, Record]]; + entries.forEach(([key, value]) => { + if (key && value) stateMap.set(key, value); + }); +} + +function save(): void { + // If no one looked anything up, don't overwrite anything + if (idsLookedUp.size <= 0) return; + + const stateToSave = JSON.stringify(Array.from(stateMap.entries())); + localStorage.setItem(WEBVIEW_STATE_KEY, stateToSave); +} + +function getRecord(id: string): Record { + loadIfNeeded(); + idsLookedUp.add(id); + + const savedState = stateMap.get(id); + if (savedState) return savedState; + + const newState = {}; + stateMap.set(id, newState); + return newState; +} + +/** + * Get the web view state associated with the given ID + * This function is only intended to be used at startup. getWebViewState is intended for web views to call. + * @param id ID of the web view + * @returns state object of the given web view + */ +export function getFullWebViewStateById(id: string): Record { + if (!id) throw new Error('id must be provided to get webview state'); + return getRecord(id); +} + +/** + * Set the web view state associated with the given ID + * This function is only intended to be used at startup. setWebViewState is intended for web views to call. + * @param id ID of the web view + * @param state State to set for the given web view + */ +export function setFullWebViewStateById(id: string, state: Record): void { + if (!id || !state) throw new Error('id and state must be provided to set webview state'); + loadIfNeeded(); + idsLookedUp.add(id); + stateMap.set(id, state); + save(); +} + +/** + * Get the web view state associated with the given ID + * @param id ID of the web view + * @param stateKey Key used to retrieve the state value + * @returns string (if it exists) containing the state for the given key of the given web view + */ +export function getWebViewStateById(id: string, stateKey: string): T | undefined { + if (!id || !stateKey) throw new Error('id and stateKey must be provided to get webview state'); + const state: Record = getRecord(id); + const stateValue: string | undefined = state[stateKey]; + return stateValue ? (JSON.parse(stateValue) as T) : undefined; +} + +/** + * Set the web view state object associated with the given ID + * @param id ID of the web view + * @param stateKey Key for the associated state + * @param stateValue Value of the state for the given key of the given web view - must work with JSON.stringify/parse + */ +export function setWebViewStateById(id: string, stateKey: string, stateValue: T): void { + if (!id || !stateKey) throw new Error('id and stateKey must be provided to set webview state'); + const stringifiedValue = JSON.stringify(stateValue); + try { + const roundTripped = JSON.parse(stringifiedValue); + const roundTrippedStringified = JSON.stringify(roundTripped); + if (stringifiedValue !== roundTrippedStringified) { + throw new Error(`roundtrip failure`); + } + } catch (err) { + throw new Error(`"${stateKey}" value cannot round trip with JSON.stringify and JSON.parse.`); + } + + const state: Record = getRecord(id); + state[stateKey] = stringifiedValue; + save(); +} + +/** Purge any web view state that hasn't been touched since the process has been running. + * Only call this once all web views have been loaded. + */ +export function cleanupOldWebViewState(): void { + if (stateMap.size <= 0 || idsLookedUp.size <= 0) return; + stateMap.forEach((_, id) => { + if (!idsLookedUp.has(id)) stateMap.delete(id); + }); + save(); +} diff --git a/src/shared/data/web-view.model.ts b/src/shared/data/web-view.model.ts index d54005cb98..1df6f8b90c 100644 --- a/src/shared/data/web-view.model.ts +++ b/src/shared/data/web-view.model.ts @@ -89,6 +89,8 @@ type WebViewDefinitionBase = { content: string; /** Name of the tab for the WebView */ title?: string; + /** General object to store unique state for this webview */ + state?: Record; }; /** WebView representation using React */ diff --git a/src/shared/global-this.model.ts b/src/shared/global-this.model.ts index ad7e9a95e9..caaf20e2e9 100644 --- a/src/shared/global-this.model.ts +++ b/src/shared/global-this.model.ts @@ -2,7 +2,7 @@ /* eslint-disable no-var */ import { LogLevel } from 'electron-log'; -import { FunctionComponent } from 'react'; +import { FunctionComponent, Dispatch, SetStateAction } from 'react'; /** * Variables that are defined in global scope. These must be defined in main.ts (main), index.ts (renderer), and extension-host.ts (extension host) @@ -21,9 +21,30 @@ declare global { var logLevel: LogLevel; /** * A function that each React WebView extension must provide for Paranext to display it. - * Only used in WebView iframes + * Only used in WebView iframes. */ var webViewComponent: FunctionComponent; + /** + * A React hook for working with a state object tied to a webview. + * Only used in WebView iframes. + * @param stateKey Key of the state value to use. The webview state holds a unique value per key. + * NOTE: `stateKey` needs to be a constant string, not something that could change during execution. + * @param defaultStateValue Value to use if the web view state didn't contain a value for the given 'stateKey' + * @returns string holding the state value and a function to use to update the state value + * @example const [lastPersonSeen, setLastPersonSeen] = useWebViewState('lastSeen'); + */ + var useWebViewState: ( + stateKey: string, + defaultStateValue: NonNullable, + ) => [webViewState: NonNullable, setWebViewState: Dispatch>>]; + /** + * Retrieve the value from web view state with the given 'stateKey', if it exists. + */ + var getWebViewState: (stateKey: string) => T | undefined; + /** + * Set the value for a given key in the web view state. + */ + var setWebViewState: (stateKey: string, stateValue: NonNullable) => void; } /** Type of Paranext process */ diff --git a/src/shared/services/web-view.service.ts b/src/shared/services/web-view.service.ts index 1797c86aec..487d7d6c65 100644 --- a/src/shared/services/web-view.service.ts +++ b/src/shared/services/web-view.service.ts @@ -36,6 +36,10 @@ import AsyncVariable from '@shared/utils/async-variable'; import logger from '@shared/services/logger.service'; import LogError from '@shared/log-error.model'; import memoizeOne from 'memoize-one'; +import { + getFullWebViewStateById, + setFullWebViewStateById, +} from '@renderer/services/web-view-state.service'; /** rc-dock's onLayoutChange prop made asynchronous - resolves */ export type OnLayoutChangeRCDock = ( @@ -423,6 +427,8 @@ export const getWebView = async ( existingSavedWebView = convertWebViewDefinitionToSaved( existingWebView.data as WebViewDefinition, ); + // Load the web view state since the web view provider doesn't have access to the data store + existingSavedWebView.state = getFullWebViewStateById(existingWebView.id); didFindExistingWebView = true; } } @@ -441,6 +447,9 @@ export const getWebView = async ( // The web view provider didn't want to create this web view if (!webView) return undefined; + // The web view provider might have updated the web view state, so save it + if (webView.state) setFullWebViewStateById(webView.id, webView.state); + /** * The web view we are getting is new. Either the webview provider gave us a new webview instead * of the existing one or there wasn't an existing one in the first place @@ -457,7 +466,9 @@ export const getWebView = async ( // WebView.contentType is assumed to be React by default. Extensions can specify otherwise const contentType = webView.contentType ? webView.contentType : WebViewContentType.React; - // Note: `webViewRequire` below is defined in `src\renderer\global-this.model.ts`. + // `webViewRequire`, `getWebViewStateById`, and `setWebViewStateById` below are defined in `src\renderer\global-this.model.ts` + // `useWebViewState` below is defined in `src\shared\global-this.model.ts` + // We have to bind `useWebViewState` to the current `window` context because calls within PAPI don't have access to a webview's `window` context /** String that sets up 'import' statements in the webview to pull in libraries and clear out internet access and such */ const imports = ` var papi = window.parent.papi; @@ -468,6 +479,11 @@ export const getWebView = async ( var createRoot = window.parent.createRoot; var SillsdevScripture = window.parent.SillsdevScripture; var require = window.parent.webViewRequire; + var getWebViewStateById = window.parent.getWebViewStateById; + var setWebViewStateById = window.parent.setWebViewStateById; + window.getWebViewState = (stateKey) => { return getWebViewStateById('${webView.id}', stateKey) }; + window.setWebViewState = (stateKey, stateValue) => { setWebViewStateById('${webView.id}', stateKey, stateValue) }; + window.useWebViewState = window.parent.useWebViewState.bind(window); window.fetch = papi.fetch; delete window.parent; delete window.top; From 53fdb3108d974522cbd52c4e12dfe10aeee60647 Mon Sep 17 00:00:00 2001 From: Ira Hopkinson Date: Tue, 17 Oct 2023 02:08:53 +1300 Subject: [PATCH 08/24] Fix resource viewer syntax/style (#554) --- .../resource-viewer.web-view.tsx | 32 +++++++++---------- .../extension-manager-tab.component.tsx | 14 ++++---- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/extensions/src/resource-viewer/resource-viewer.web-view.tsx b/extensions/src/resource-viewer/resource-viewer.web-view.tsx index 86b3758472..38a2b93d1e 100644 --- a/extensions/src/resource-viewer/resource-viewer.web-view.tsx +++ b/extensions/src/resource-viewer/resource-viewer.web-view.tsx @@ -1,7 +1,7 @@ -import { VerseRef } from '@sillsdev/scripture'; +import { VerseRef } from '@sillsdev/scripture'; import papi from 'papi-frontend'; import { ScriptureReference } from 'papi-components'; -import { useMemo } from 'react'; +import { JSX, useMemo } from 'react'; // eslint-disable-next-line import/no-unresolved import { UsfmProviderDataTypes } from 'usfm-data-provider'; import UsxEditor from 'usxeditor'; @@ -24,8 +24,15 @@ interface ElementInfo { validStyles?: StyleInfo[]; } +const { + react: { + hooks: { useData, useSetting }, + }, + logger, +} = papi; + /** All available elements for use in slate editor */ -const EditorElements: { [type: string]: ElementInfo } = { +const editorElements: { [type: string]: ElementInfo } = { verse: { inline: true, validStyles: [{ style: 'v', oneWord: true }], @@ -109,9 +116,9 @@ const EditorElements: { [type: string]: ElementInfo } = { }, }; -const UsxEditorParaMap = EditorElements.para.validStyles?.map((style) => style.style) || []; -const UsxEditorCharMap = Object.fromEntries( - EditorElements.char.validStyles?.map((style) => [style.style, {}]) || [], +const usxEditorParaMap = editorElements.para.validStyles?.map((style) => style.style) || []; +const usxEditorCharMap = Object.fromEntries( + editorElements.char.validStyles?.map((style) => [style.style, {}]) || [], ); interface ScriptureTextPanelUsxProps { @@ -134,8 +141,8 @@ function ScriptureTextPanelUsxEditor({ usx }: ScriptureTextPanelUsxProps) {
{ /* Read only */ }} @@ -144,14 +151,7 @@ function ScriptureTextPanelUsxEditor({ usx }: ScriptureTextPanelUsxProps) { ); } -const { - react: { - hooks: { useData, useSetting }, - }, - logger, -} = papi; - -globalThis.webViewComponent = function ResourceViewer() { +globalThis.webViewComponent = function ResourceViewer(): JSX.Element { logger.info('Preparing to display the Resource Viewer'); const [scrRef] = useSetting('platform.verseRef', defaultScrRef); diff --git a/src/renderer/components/extension-manager/extension-manager-tab.component.tsx b/src/renderer/components/extension-manager/extension-manager-tab.component.tsx index da7c73358f..1537134e86 100644 --- a/src/renderer/components/extension-manager/extension-manager-tab.component.tsx +++ b/src/renderer/components/extension-manager/extension-manager-tab.component.tsx @@ -16,49 +16,49 @@ export function fetchExtensions(): Extension[] { hasUpdateAvailable: false, isInstalled: true, iconFilePath: undefined, - } as Extension, + }, { name: 'Resource Viewer', description: 'View Scripture resources', hasUpdateAvailable: false, isInstalled: true, iconFilePath: undefined, - } as Extension, + }, { name: 'Parallel Passages', description: 'Compare parallel passages of Scripture', hasUpdateAvailable: true, isInstalled: true, iconFilePath: undefined, - } as Extension, + }, { name: 'Psalms layer-by-layer', description: 'Provide resources on the Psalms from Cambridge Digital Bible Research', hasUpdateAvailable: true, isInstalled: true, iconFilePath: undefined, - } as Extension, + }, { name: 'Hello World', description: 'Example Bundled Extension', hasUpdateAvailable: true, isInstalled: false, iconFilePath: undefined, - } as Extension, + }, { name: 'Hello Someone', description: 'Example Bundled Extension', hasUpdateAvailable: true, isInstalled: false, iconFilePath: undefined, - } as Extension, + }, { name: 'Quick Verse', description: 'Example Bundled Extension', hasUpdateAvailable: false, isInstalled: false, iconFilePath: 'papi-extension://quick-verse/assets/letter-q.png', - } as Extension, + }, ]; } From 851ecbcfb0abc73e677bf9ea9aea95ea577a7700 Mon Sep 17 00:00:00 2001 From: Matt Lyons Date: Mon, 16 Oct 2023 09:49:20 -0500 Subject: [PATCH 09/24] Change import to conditional import when inside the renderer (#555) --- src/shared/services/web-view.service.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/shared/services/web-view.service.ts b/src/shared/services/web-view.service.ts index 487d7d6c65..ffce2b318e 100644 --- a/src/shared/services/web-view.service.ts +++ b/src/shared/services/web-view.service.ts @@ -36,10 +36,6 @@ import AsyncVariable from '@shared/utils/async-variable'; import logger from '@shared/services/logger.service'; import LogError from '@shared/log-error.model'; import memoizeOne from 'memoize-one'; -import { - getFullWebViewStateById, - setFullWebViewStateById, -} from '@renderer/services/web-view-state.service'; /** rc-dock's onLayoutChange prop made asynchronous - resolves */ export type OnLayoutChangeRCDock = ( @@ -394,6 +390,11 @@ export const getWebView = async ( throw new Error(`getWebView failed, but you should have seen a different error than this!`); } + // Conditional import when inside the renderer + const { getFullWebViewStateById, setFullWebViewStateById } = await import( + '@renderer/services/web-view-state.service' + ); + // Get the webview definition from the webview provider const webViewProvider = await webViewProviderService.get(webViewType); From 1634958f7fac7b52d5c4e702c2a503b78a63258d Mon Sep 17 00:00:00 2001 From: tjcouch-sil Date: Mon, 9 Oct 2023 12:49:06 -0500 Subject: [PATCH 10/24] Rename project lookup service files to match new naming scheme --- lib/papi-dts/papi.d.ts | 8 ++++---- src/extension-host/extension-host.ts | 2 +- src/extension-host/services/papi-backend.service.ts | 2 +- ...-backend.service.ts => project-lookup.service.host.ts} | 2 +- src/renderer/services/papi-frontend.service.ts | 2 +- .../project-lookup.service.model.ts} | 2 +- src/shared/services/project-lookup.service.ts | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) rename src/extension-host/services/{project-lookup-backend.service.ts => project-lookup.service.host.ts} (98%) rename src/shared/{models/project-lookup.model.ts => services/project-lookup.service.model.ts} (91%) diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index 7f0afb7f95..79de6e65ac 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -2387,7 +2387,7 @@ declare module 'shared/models/project-metadata.model' { projectType: string; }; } -declare module 'shared/models/project-lookup.model' { +declare module 'shared/services/project-lookup.service.model' { import { ProjectMetadata } from 'shared/models/project-metadata.model'; /** * Provides metadata for projects known by the platform @@ -2408,7 +2408,7 @@ declare module 'shared/models/project-lookup.model' { export const projectLookupServiceNetworkObjectName = 'ProjectLookupService'; } declare module 'shared/services/project-lookup.service' { - import { ProjectLookupServiceType } from 'shared/models/project-lookup.model'; + import { ProjectLookupServiceType } from 'shared/services/project-lookup.service.model'; const projectLookupService: ProjectLookupServiceType; export default projectLookupService; } @@ -2832,7 +2832,7 @@ declare module 'papi-frontend' { import { PapiWebViewService } from 'shared/services/web-view.service'; import { InternetService } from 'shared/services/internet.service'; import { DataProviderService } from 'shared/services/data-provider.service'; - import { ProjectLookupServiceType } from 'shared/models/project-lookup.model'; + import { ProjectLookupServiceType } from 'shared/services/project-lookup.service.model'; import { PapiFrontendProjectDataProviderService } from 'shared/services/project-data-provider.service'; import { PapiContext } from 'renderer/context/papi-context/index'; import { PapiHooks } from 'renderer/hooks/papi-hooks/index'; @@ -3162,7 +3162,7 @@ declare module 'papi-backend' { import { DataProviderService } from 'shared/services/data-provider.service'; import { PapiBackendProjectDataProviderService } from 'shared/services/project-data-provider.service'; import { ExtensionStorageService } from 'extension-host/services/extension-storage.service'; - import { ProjectLookupServiceType } from 'shared/models/project-lookup.model'; + import { ProjectLookupServiceType } from 'shared/services/project-lookup.service.model'; const papi: { /** * Event manager - accepts subscriptions to an event and runs the subscription callbacks when the event is emitted diff --git a/src/extension-host/extension-host.ts b/src/extension-host/extension-host.ts index 6c0a0e388f..1af47fd4ca 100644 --- a/src/extension-host/extension-host.ts +++ b/src/extension-host/extension-host.ts @@ -9,7 +9,7 @@ import dataProviderService from '@shared/services/data-provider.service'; import extensionAssetService from '@shared/services/extension-asset.service'; import { getErrorMessage } from '@shared/utils/util'; import { CommandNames } from 'papi-shared-types'; -import { startProjectLookupService } from '@extension-host/services/project-lookup-backend.service'; +import { startProjectLookupService } from '@extension-host/services/project-lookup.service.host'; // #region Test logs diff --git a/src/extension-host/services/papi-backend.service.ts b/src/extension-host/services/papi-backend.service.ts index 5151afcaf3..830b3a6a56 100644 --- a/src/extension-host/services/papi-backend.service.ts +++ b/src/extension-host/services/papi-backend.service.ts @@ -23,7 +23,7 @@ import { import extensionStorageService, { ExtensionStorageService, } from '@extension-host/services/extension-storage.service'; -import { ProjectLookupServiceType } from '@shared/models/project-lookup.model'; +import { ProjectLookupServiceType } from '@shared/services/project-lookup.service.model'; import projectLookupService from '@shared/services/project-lookup.service'; // IMPORTANT NOTES: diff --git a/src/extension-host/services/project-lookup-backend.service.ts b/src/extension-host/services/project-lookup.service.host.ts similarity index 98% rename from src/extension-host/services/project-lookup-backend.service.ts rename to src/extension-host/services/project-lookup.service.host.ts index 428d95c102..52d547de80 100644 --- a/src/extension-host/services/project-lookup-backend.service.ts +++ b/src/extension-host/services/project-lookup.service.host.ts @@ -4,7 +4,7 @@ import { ProjectMetadata } from '@shared/models/project-metadata.model'; import { projectLookupServiceNetworkObjectName, ProjectLookupServiceType, -} from '@shared/models/project-lookup.model'; +} from '@shared/services/project-lookup.service.model'; import { joinUriPaths } from '@node/utils/util'; import logger from '@shared/services/logger.service'; import networkObjectService from '@shared/services/network-object.service'; diff --git a/src/renderer/services/papi-frontend.service.ts b/src/renderer/services/papi-frontend.service.ts index 7e40b06804..7f57a6e1ef 100644 --- a/src/renderer/services/papi-frontend.service.ts +++ b/src/renderer/services/papi-frontend.service.ts @@ -12,7 +12,7 @@ import { papiNetworkService, PapiNetworkService } from '@shared/services/network import { papiWebViewService, PapiWebViewService } from '@shared/services/web-view.service'; import internetService, { InternetService } from '@shared/services/internet.service'; import dataProviderService, { DataProviderService } from '@shared/services/data-provider.service'; -import { ProjectLookupServiceType } from '@shared/models/project-lookup.model'; +import { ProjectLookupServiceType } from '@shared/services/project-lookup.service.model'; import projectLookupService from '@shared/services/project-lookup.service'; import { papiFrontendProjectDataProviderService, diff --git a/src/shared/models/project-lookup.model.ts b/src/shared/services/project-lookup.service.model.ts similarity index 91% rename from src/shared/models/project-lookup.model.ts rename to src/shared/services/project-lookup.service.model.ts index e0e3abe957..37fed7725b 100644 --- a/src/shared/models/project-lookup.model.ts +++ b/src/shared/services/project-lookup.service.model.ts @@ -1,4 +1,4 @@ -import { ProjectMetadata } from './project-metadata.model'; +import { ProjectMetadata } from '../models/project-metadata.model'; /** JSDOC SOURCE projectLookupService * Provides metadata for projects known by the platform diff --git a/src/shared/services/project-lookup.service.ts b/src/shared/services/project-lookup.service.ts index 11ddcd864d..a2025a94db 100644 --- a/src/shared/services/project-lookup.service.ts +++ b/src/shared/services/project-lookup.service.ts @@ -1,7 +1,7 @@ import { projectLookupServiceNetworkObjectName, ProjectLookupServiceType, -} from '@shared/models/project-lookup.model'; +} from '@shared/services/project-lookup.service.model'; import networkObjectService from '@shared/services/network-object.service'; let networkObject: ProjectLookupServiceType; From 1e944c02b933f4520fe0363197fe2def29625762 Mon Sep 17 00:00:00 2001 From: tjcouch-sil Date: Tue, 10 Oct 2023 12:56:23 -0500 Subject: [PATCH 11/24] Started dialog service - getProjects, dialog response, open project -> select project, tab icons Started dialog service; getProjects pulls up dialog but does nothing more Dialog response, open project -> select project, tab icons --- extensions/src/hello-world/assets/offline.svg | 155 ++++++++++++++++++ extensions/src/hello-world/hello-world.ts | 12 ++ .../web-views/hello-world.web-view.tsx | 38 ++++- lib/papi-dts/papi.d.ts | 99 ++++++++++- .../services/papi-backend.service.ts | 4 + .../services/project-lookup.service.host.ts | 11 +- .../paranext-dock-layout.component.test.ts | 19 ++- .../paranext-dock-layout.component.tsx | 86 +++++++--- .../docking/paranext-tab-title.component.tsx | 16 +- .../open-project-tab.component.scss | 2 +- .../open-project-tab.component.tsx | 43 +++-- .../project-list.component.tsx | 10 +- .../components/web-view.component.tsx | 1 + src/renderer/index.tsx | 2 + src/renderer/services/dialog.service.host.ts | 139 ++++++++++++++++ .../services/papi-frontend.service.ts | 4 + src/renderer/testing/test-layout.data.ts | 10 -- src/shared/data/web-view.model.ts | 29 +++- src/shared/models/dialog-options.model.ts | 9 + src/shared/services/dialog.service.model.ts | 19 +++ src/shared/services/dialog.service.ts | 20 +++ src/shared/services/web-view.service.ts | 33 +++- 22 files changed, 693 insertions(+), 68 deletions(-) create mode 100644 extensions/src/hello-world/assets/offline.svg create mode 100644 src/renderer/services/dialog.service.host.ts create mode 100644 src/shared/models/dialog-options.model.ts create mode 100644 src/shared/services/dialog.service.model.ts create mode 100644 src/shared/services/dialog.service.ts diff --git a/extensions/src/hello-world/assets/offline.svg b/extensions/src/hello-world/assets/offline.svg new file mode 100644 index 0000000000..a16275fc8b --- /dev/null +++ b/extensions/src/hello-world/assets/offline.svg @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extensions/src/hello-world/hello-world.ts b/extensions/src/hello-world/hello-world.ts index abc3747c44..7dc93043b0 100644 --- a/extensions/src/hello-world/hello-world.ts +++ b/extensions/src/hello-world/hello-world.ts @@ -52,6 +52,7 @@ const reactWebViewProvider: IWebViewProviderWithType = { ); return { ...savedWebView, + iconUrl: 'papi-extension://hello-world/assets/offline.svg', title: 'Hello World React', content: helloWorldReactWebView, styles: helloWorldReactWebViewStyles, @@ -164,5 +165,16 @@ export async function activate(context: ExecutionActivationContext): Promise(false); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const [project, setProject] = useState(); + const [isSettingProject, setIsSettingProject] = useState(false); + + const selectProject = useCallback(async () => { + if (!isSettingProject) { + setIsSettingProject(true); + try { + const projectId = await papi.dialogs.getProject({ + prompt: 'Please select a project for Hello World WebView:', + iconUrl: 'papi-extension://hello-world/assets/offline.svg', + title: 'Select Hello World Project', + }); + logger.log(`RENDERER dialogs.getProject: ${projectId}`); + if (mounted.current) { + setProject(projectId); + setIsSettingProject(false); + } + } catch (e) { + logger.error(`Failed to select project! ${e}`); + if (mounted.current) setIsSettingProject(false); + } + } else logger.log(`Already choosing a project!`); + }, [isSettingProject]); + const [latestVerseText] = useData.Verse( 'quickVerse.quickVerse', 'latest', @@ -152,6 +184,10 @@ globalThis.webViewComponent = function HelloWorld() {
{personGreeting}
{personAge}
+
Selected Project: {project}
+
+ +

John 1:1

{john11}

Psalm 1

diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index 79de6e65ac..ffe73cd9d3 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -1692,6 +1692,12 @@ declare module 'shared/data/web-view.model' { * {@link TabSaver} saves this into {@link SavedTabInfo} */ export type TabInfo = SavedTabInfo & { + /** + * Url of image to show on the title bar of the tab + * + * Defaults to Platform.Bible logo + */ + tabIconUrl?: string; /** * Text to show on the title bar of the tab */ @@ -1720,8 +1726,12 @@ declare module 'shared/data/web-view.model' { * Function that takes a Paranext tab and creates a saved tab out of it. Each type of tab can * provide a {@link TabSaver}. If they do not provide one, the properties added by `TabInfo` are * stripped from TabInfo by `saveTabInfoBase` before saving (so it is just a {@link SavedTabInfo}). + * + * @param tabInfo the Paranext tab to save + * + * @returns The saved tab info for Paranext to persist. If `undefined`, does not save the tab */ - export type TabSaver = (tabInfo: TabInfo) => SavedTabInfo; + export type TabSaver = (tabInfo: TabInfo) => SavedTabInfo | undefined; /** The type of code that defines a webview's content */ export enum WebViewContentType { /** @@ -1744,6 +1754,12 @@ declare module 'shared/data/web-view.model' { id: WebViewId; /** The code for the WebView that papi puts into an iframe */ content: string; + /** + * Url of image to show on the title bar of the tab + * + * Defaults to Platform.Bible logo + */ + iconUrl?: string; /** Name of the tab for the WebView */ title?: string; /** General object to store unique state for this webview */ @@ -1779,6 +1795,14 @@ declare module 'shared/data/web-view.model' { interface TabLayout { type: 'tab'; } + /** + * Indicates where to display a floating window + * + * `cascade` - place the window a bit below and to the right of the previously created floating + * window + * `center` - center the window in the dock layout + */ + type FloatPosition = 'cascade' | 'center'; /** Information about a floating window */ export interface FloatLayout { type: 'float'; @@ -1786,6 +1810,8 @@ declare module 'shared/data/web-view.model' { width: number; height: number; }; + /** Where to display the floating window. Defaults to `cascade` */ + position?: FloatPosition; } export type PanelDirection = | 'left' @@ -1995,7 +2021,17 @@ declare module 'shared/services/web-view.service' { * {@link onLayoutChange} function */ onLayoutChangeRef: MutableRefObject; - /** Function to call to add or update a webview in the layout */ + /** + * Function to call to add or update a tab in the layout + * @param savedTabInfo info for tab to add or update + * @param layout information about where to put a new webview + */ + addTabToDock: (savedTabInfo: SavedTabInfo, layout: Layout) => void; + /** + * Function to call to add or update a webview in the layout + * @param webView web view to add or update + * @param layout information about where to put a new webview + */ addWebViewToDock: (webView: WebViewProps, layout: Layout) => void; /** * The layout to use as the default layout if the dockLayout doesn't have a layout loaded. @@ -2053,8 +2089,22 @@ declare module 'shared/services/web-view.service' { * operations * @param dockLayout dock layout element to register along with other important properties * @returns function used to unregister this dock layout + * + * WARNING: YOU CANNOT USE THIS FUNCTION IN ANYTHING BUT THE RENDERER + * + * Not exposed on the papi */ export function registerDockLayout(dockLayout: PapiDockLayout): Unsubscriber; + /** + * Add or update a tab in the layout + * @param savedTabInfo info for tab to add or update + * @param layout information about where to put a new tab + * + * WARNING: YOU CANNOT USE THIS FUNCTION IN ANYTHING BUT THE RENDERER + * + * Not exposed on the papi + */ + export const addTab: (savedTabInfo: SavedTabInfo, layout: Layout) => Promise; /** * Creates a new web view or gets an existing one depending on if you request an existing one and * if the web view provider decides to give that existing one to you (it is up to the provider). @@ -2819,6 +2869,41 @@ declare module 'renderer/hooks/papi-hooks/index' { const papiHooks: PapiHooks; export default papiHooks; } +declare module 'shared/models/dialog-options.model' { + /** General options to adjust dialogs (created from `papi.dialogs`) */ + export type DialogOptions = { + /** Dialog title to display in the header. Default depends on the dialog */ + title?: string; + /** Url of dialog icon to display in the header. Default is Platform.Bible logo */ + iconUrl?: string; + /** The message to show the user in the dialog. Default depends on the dialog */ + prompt?: string; + }; +} +declare module 'shared/services/dialog.service.model' { + import { DialogOptions } from 'shared/models/dialog-options.model'; + /** + * Prompt the user for responses with dialogs + */ + export interface DialogService { + /** + * Shows a select project dialog to the user and prompts the user to select a dialog + * + * @param options various options for configuring the dialog that shows + * + * @returns If the user selects a project, returns that project's project id. If the user cancels, + * returns `undefined` + */ + getProject(options?: DialogOptions): Promise; + } + /** Prefix on requests that indicates that the request is related to dialog operations */ + export const CATEGORY_DIALOG = 'dialog'; +} +declare module 'shared/services/dialog.service' { + import { DialogService } from 'shared/services/dialog.service.model'; + const dialogService: DialogService; + export default dialogService; +} declare module 'papi-frontend' { /** * Unified module for accessing API features in the renderer. @@ -2837,6 +2922,7 @@ declare module 'papi-frontend' { import { PapiContext } from 'renderer/context/papi-context/index'; import { PapiHooks } from 'renderer/hooks/papi-hooks/index'; import { SettingsService } from 'shared/services/settings.service'; + import { DialogService } from 'shared/services/dialog.service.model'; const papi: { /** * Event manager - accepts subscriptions to an event and runs the subscription callbacks when the event is emitted @@ -2863,6 +2949,10 @@ declare module 'papi-frontend' { * Service exposing various functions related to using webViews */ webViews: PapiWebViewService; + /** + * Prompt the user for responses with dialogs + */ + dialogs: DialogService; /** * Service that provides a way to send and receive network events */ @@ -3163,6 +3253,7 @@ declare module 'papi-backend' { import { PapiBackendProjectDataProviderService } from 'shared/services/project-data-provider.service'; import { ExtensionStorageService } from 'extension-host/services/extension-storage.service'; import { ProjectLookupServiceType } from 'shared/services/project-lookup.service.model'; + import { DialogService } from 'shared/services/dialog.service.model'; const papi: { /** * Event manager - accepts subscriptions to an event and runs the subscription callbacks when the event is emitted @@ -3193,6 +3284,10 @@ declare module 'papi-backend' { * Interface for registering webView providers */ webViewProviders: PapiWebViewProviderService; + /** + * Prompt the user for responses with dialogs + */ + dialogs: DialogService; /** * Service that provides a way to send and receive network events */ diff --git a/src/extension-host/services/papi-backend.service.ts b/src/extension-host/services/papi-backend.service.ts index 830b3a6a56..0c100ea8be 100644 --- a/src/extension-host/services/papi-backend.service.ts +++ b/src/extension-host/services/papi-backend.service.ts @@ -25,6 +25,8 @@ import extensionStorageService, { } from '@extension-host/services/extension-storage.service'; import { ProjectLookupServiceType } from '@shared/services/project-lookup.service.model'; import projectLookupService from '@shared/services/project-lookup.service'; +import dialogService from '@shared/services/dialog.service'; +import { DialogService } from '@shared/services/dialog.service.model'; // IMPORTANT NOTES: // 1) When adding new services here, consider whether they also belong in papi-frontend.service.ts. @@ -50,6 +52,8 @@ const papi = { webViews: papiWebViewService as PapiWebViewService, /** JSDOC DESTINATION papiWebViewProviderService */ webViewProviders: papiWebViewProviderService as PapiWebViewProviderService, + /** JSDOC DESTINATION dialogService */ + dialogs: dialogService as DialogService, /** JSDOC DESTINATION papiNetworkService */ network: papiNetworkService as PapiNetworkService, /** JSDOC DESTINATION logger */ diff --git a/src/extension-host/services/project-lookup.service.host.ts b/src/extension-host/services/project-lookup.service.host.ts index 52d547de80..16dd5c81c2 100644 --- a/src/extension-host/services/project-lookup.service.host.ts +++ b/src/extension-host/services/project-lookup.service.host.ts @@ -118,19 +118,16 @@ const projectLookupService: ProjectLookupServiceType = { getMetadataForProject, }; -let networkObject: ProjectLookupServiceType; - /** * Register the network object that backs the PAPI project lookup service */ +// This doesn't really represent this service module, so we're not making it default. To use this +// service, you should use `project-lookup.service.ts` +// eslint-disable-next-line import/prefer-default-export export async function startProjectLookupService(): Promise { await initialize(); - networkObject = await networkObjectService.set( + await networkObjectService.set( projectLookupServiceNetworkObjectName, projectLookupService, ); } - -export function getNetworkObject(): ProjectLookupServiceType { - return networkObject; -} diff --git a/src/renderer/components/docking/paranext-dock-layout.component.test.ts b/src/renderer/components/docking/paranext-dock-layout.component.test.ts index c52226cbe4..7919c1b877 100644 --- a/src/renderer/components/docking/paranext-dock-layout.component.test.ts +++ b/src/renderer/components/docking/paranext-dock-layout.component.test.ts @@ -17,7 +17,12 @@ jest.mock( import DockLayout, { FloatPosition } from 'rc-dock'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { FloatLayout, Layout, SavedTabInfo, WebViewProps } from '@shared/data/web-view.model'; -import { addWebViewToDock, getFloatPosition, loadTab } from './paranext-dock-layout.component'; +import { + addTabToDock, + addWebViewToDock, + getFloatPosition, + loadTab, +} from './paranext-dock-layout.component'; describe('Dock Layout Component', () => { const mockDockLayout = mock(DockLayout); @@ -83,6 +88,18 @@ describe('Dock Layout Component', () => { }); }); + describe('addTabToDock()', () => { + it('should throw when tab is not an object', () => { + const dockLayout = instance(mockDockLayout); + const layout: Layout = { type: 'tab' }; + + expect(() => + addTabToDock('this is wrong' as unknown as SavedTabInfo, layout, dockLayout), + ).toThrow(); + }); + // TODO: verify it adds an error tab if no/bad tab type provided + }); + describe('addWebViewToDock()', () => { it('should throw when no id', () => { const dockLayout = instance(mockDockLayout); diff --git a/src/renderer/components/docking/paranext-dock-layout.component.tsx b/src/renderer/components/docking/paranext-dock-layout.component.tsx index c02116620a..88631e44fb 100644 --- a/src/renderer/components/docking/paranext-dock-layout.component.tsx +++ b/src/renderer/components/docking/paranext-dock-layout.component.tsx @@ -42,8 +42,8 @@ import { } from '@shared/services/web-view.service'; import { getErrorMessage } from '@shared/utils/util'; import { - loadOpenProjectTab, - TAB_TYPE_OPEN_PROJECT_DIALOG, + loadSelectProjectTab, + saveSelectProjectTab, } from '@renderer/components/project-dialogs/open-project-tab.component'; import { loadDownloadUpdateProjectTab, @@ -61,6 +61,7 @@ import { TAB_TYPE_RUN_BASIC_CHECKS, loadRunBasicChecksTab, } from '@renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component'; +import { TAB_TYPE_SELECT_PROJECT_DIALOG } from '@renderer/services/dialog.service.host'; type TabType = string; @@ -93,7 +94,7 @@ const tabLoaderMap = new Map([ [TAB_TYPE_QUICK_VERSE_HERESY, loadQuickVerseHeresyTab], [TAB_TYPE_TEST, loadTestTab], [TAB_TYPE_WEBVIEW, loadWebViewTab], - [TAB_TYPE_OPEN_PROJECT_DIALOG, loadOpenProjectTab], + [TAB_TYPE_SELECT_PROJECT_DIALOG, loadSelectProjectTab], [TAB_TYPE_DOWNLOAD_UPDATE_PROJECT_DIALOG, loadDownloadUpdateProjectTab], [TAB_TYPE_OPEN_MULTIPLE_PROJECTS_DIALOG, loadOpenMultipleProjectsTab], [TAB_TYPE_EXTENSION_MANAGER, loadExtensionManagerTab], @@ -101,10 +102,13 @@ const tabLoaderMap = new Map([ ]); /** tab saver functions for each Paranext tab type that wants to override the default */ -const tabSaverMap = new Map([[TAB_TYPE_WEBVIEW, saveWebViewTab]]); +const tabSaverMap = new Map([ + [TAB_TYPE_WEBVIEW, saveWebViewTab], + [TAB_TYPE_SELECT_PROJECT_DIALOG, saveSelectProjectTab], +]); let previousTabId: string | undefined; -let floatPosition: FloatPosition = { left: 0, top: 0, width: 0, height: 0 }; +let previousFloatPosition: FloatPosition = { left: 0, top: 0, width: 0, height: 0 }; /** * Loads tab data from the specified saved tab information by running the tab loader provided by the @@ -139,7 +143,7 @@ export function loadTab(savedTabInfo: SavedTabInfo): RCDockTabInfo { // Translate the data from the loaded tab to be in the form needed by rc-dock return { ...tabInfo, - title: , + title: , content: {tabInfo.content}, group: TAB_GROUP, closable: true, @@ -152,7 +156,7 @@ export function loadTab(savedTabInfo: SavedTabInfo): RCDockTabInfo { * @param dockTabInfo the tab data to save * @returns saved tab info ready to be saved into the layout */ -function saveTab(dockTabInfo: RCDockTabInfo): SavedTabInfo { +function saveTab(dockTabInfo: RCDockTabInfo): SavedTabInfo | undefined { // Remove the rc-dock properties that are not also in SavedTabInfo // We don't need to use the other properties, but we need to remove them // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -200,9 +204,20 @@ export function getFloatPosition( // Defaults are added in `web-view.service.ts`. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { width, height } = layout.floatSize!; + let { left, top } = previousPosition; - left = offsetOrOverflowAxis(left, width, layoutSize.width); - top = offsetOrOverflowAxis(top, height, layoutSize.height); + + switch (layout.position) { + case 'center': + left = layoutSize.width / 2 - width / 2; + top = layoutSize.height / 2 - height / 2; + break; + case 'cascade': + default: + left = offsetOrOverflowAxis(left, width, layoutSize.width); + top = offsetOrOverflowAxis(top, height, layoutSize.height); + break; + } return { left, top, width, height }; } @@ -220,21 +235,20 @@ function findPreviousTab(dockLayout: DockLayout) { } /** - * Function to call to add or update a webview in the layout - * @param webView web view to add or update - * @param layout information about where to put a new webview + * Function to call to add or update a tab in the layout + * @param savedTabInfo info for tab to add or update + * @param layout information about where to put a new tab * @param dockLayout The rc-dock dock layout React component ref. Used to perform operations on the * layout */ -export function addWebViewToDock(webView: WebViewProps, layout: Layout, dockLayout: DockLayout) { - const tabId = webView.id; - const tab = loadTab({ id: tabId, tabType: TAB_TYPE_WEBVIEW, data: webView }); - let targetTab = dockLayout.find(tabId); +export function addTabToDock(savedTabInfo: SavedTabInfo, layout: Layout, dockLayout: DockLayout) { + const tab = loadTab(savedTabInfo); + let targetTab = dockLayout.find(tab.id); // Update existing WebView if (targetTab) { - dockLayout.updateTab(tabId, tab); - if (isTab(targetTab)) previousTabId = tabId; + dockLayout.updateTab(tab.id, tab); + if (isTab(targetTab)) previousTabId = tab.id; return; } @@ -258,14 +272,19 @@ export function addWebViewToDock(webView: WebViewProps, layout: Layout, dockLayo dockLayout.find(() => true) ?? null, 'middle', ); - previousTabId = tabId; + previousTabId = tab.id; break; - case 'float': - floatPosition = getFloatPosition(layout, floatPosition, dockLayout.getLayoutSize()); + case 'float': { + const floatPosition = getFloatPosition( + layout, + previousFloatPosition, + dockLayout.getLayoutSize(), + ); + if (!layout.position || layout.position === 'cascade') previousFloatPosition = floatPosition; dockLayout.dockMove(tab, null, 'float', floatPosition); break; - + } case 'panel': if (layout.targetTabId !== undefined) { // Look for a specific tab @@ -294,6 +313,22 @@ export function addWebViewToDock(webView: WebViewProps, layout: Layout, dockLayo } } +/** + * Function to call to add or update a webview in the layout + * @param webView web view to add or update + * @param layout information about where to put a new webview + * @param dockLayout The rc-dock dock layout React component ref. Used to perform operations on the + * layout + */ +export function addWebViewToDock(webView: WebViewProps, layout: Layout, dockLayout: DockLayout) { + const tabId = webView.id; + if (!tabId) + throw new Error( + `paranext-dock-layout error: WebView of type ${webView.webViewType} has no id!`, + ); + addTabToDock({ id: tabId, tabType: TAB_TYPE_WEBVIEW, data: webView }, layout, dockLayout); +} + // #endregion export default function ParanextDockLayout() { @@ -314,6 +349,8 @@ export default function ParanextDockLayout() { const unsub = registerDockLayout({ dockLayout: dockLayoutRef.current, onLayoutChangeRef, + addTabToDock: (savedTabInfo: SavedTabInfo, layout: Layout) => + addTabToDock(savedTabInfo, layout, dockLayoutRef.current), addWebViewToDock: (webView: WebViewProps, layout: Layout) => addWebViewToDock(webView, layout, dockLayoutRef.current), testLayout, @@ -331,7 +368,10 @@ export default function ParanextDockLayout() { defaultLayout={{ dockbox: { mode: 'horizontal', children: [] } }} dropMode="edge" loadTab={loadTab} - saveTab={saveTab} + // Type assert `saveTab` as not returning `undefined` because rc-dock's types are wrong + // Here, if `saveTab` returns `undefined` the tab is not saved + // https://github.com/ticlo/rc-dock/blob/8b6481dca4b4dd07f89107d6f48b1831bbdf0470/src/Serializer.ts#L68 + saveTab={saveTab as (dockTabInfo: RCDockTabInfo) => SavedTabInfo} onLayoutChange={(...args) => { if (onLayoutChangeRef.current) onLayoutChangeRef.current(...args); }} diff --git a/src/renderer/components/docking/paranext-tab-title.component.tsx b/src/renderer/components/docking/paranext-tab-title.component.tsx index 08cfb31289..bb9104c5e3 100644 --- a/src/renderer/components/docking/paranext-tab-title.component.tsx +++ b/src/renderer/components/docking/paranext-tab-title.component.tsx @@ -1,11 +1,18 @@ import './paranext-tab-title.component.css'; import logger from '@shared/services/logger.service'; +type ParanextTabTitleProps = { + /** Url to image to show on the tab. Defaults to Platform.Bible logo */ + iconUrl?: string; + /** Text to show on the tab */ + text: string; +}; + /** * Custom tab title for all tabs in Paranext * @param text The text to show on the tab title */ -export default function ParanextTabTitle({ text }: { text: string }) { +export default function ParanextTabTitle({ iconUrl, text }: ParanextTabTitleProps) { const toggleDropdown = () => { logger.info('Pretend a menu was shown!'); }; @@ -15,6 +22,13 @@ export default function ParanextTabTitle({ text }: { text: string }) {