From 48747437135f9bd080ae0ec71a7f962f7faf0ac8 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 09:12:40 +0200 Subject: [PATCH 01/23] split home/get-resources webviews into presentational components + stories Establish the webview-split pattern: each production webview becomes a thin data-loader plus a same-named presentational component covered by an interactive Storybook story. - Add shared .storybook/story.utils.ts: - alertCommand(command, args): happy-path callbacks announce the real command name + arguments via alert() - rejectingMock(businessError): error-handling stories simulate a business failure (not "backend unavailable") - home: surface action errors in a destructive Alert (await onSendReceiveProject); upgrade story alerts to state real commands + args; add SendReceiveError story - get-resources: extract GetResources presentational component (filters stay controlled by the webview to preserve useWebViewState persistence; text/sort internal); webview becomes a data-loader. Stories: Default, Loading, Empty, ResourcesError, InstallError, RemoveError - new-tab: add story rendering Home in new-tab config (no redundant component) Verified: npm run lint:scripts, typecheck, and storybook:build all clean. Co-Authored-By: Claude Code --- .storybook/story.utils.ts | 82 +++ .../src/get-resources.component.tsx | 543 ++++++++++++++++++ .../src/get-resources.stories.tsx | 174 ++++++ .../src/get-resources.web-view.tsx | 505 ++-------------- .../src/home.component.tsx | 50 +- .../src/home.stories.tsx | 66 ++- .../src/new-tab.stories.tsx | 79 +++ 7 files changed, 1019 insertions(+), 480 deletions(-) create mode 100644 .storybook/story.utils.ts create mode 100644 extensions/src/platform-get-resources/src/get-resources.component.tsx create mode 100644 extensions/src/platform-get-resources/src/get-resources.stories.tsx create mode 100644 extensions/src/platform-get-resources/src/new-tab.stories.tsx diff --git a/.storybook/story.utils.ts b/.storybook/story.utils.ts new file mode 100644 index 00000000000..6bedcf3da93 --- /dev/null +++ b/.storybook/story.utils.ts @@ -0,0 +1,82 @@ +/** + * Shared Storybook helpers for the bundled-extension webview component stories. + * + * Webview components are split into a presentational component (covered by these stories) and a + * thin data-loading `*.web-view.tsx` wrapper. In the real app the wrapper implements the `on*` + * action callbacks with PAPI commands; in Storybook we mock them here so reviewers can exercise the + * UI in isolation: + * + * - `alertCommand` makes a happy-path action visible by announcing the real command name and its + * arguments via `alert(...)` (e.g. `platformScriptureEditor.openScriptureEditor(projectId="abc", + * isEditable=true)`). + * - `rejectingMock` simulates a _business_ failure (e.g. "cannot delete item") so error-handling + * stories can verify the component surfaces the error. It deliberately does NOT model "backend + * unavailable". + */ + +/** + * Format a single argument for display in an alert. Strings are quoted, everything else is rendered + * as JSON so arrays/objects/booleans/numbers read naturally (e.g. `["p1"]`, `true`, `42`). + */ +function formatArg(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +/** + * Announce the command (and its arguments) that a webview's data-loader would run when an action + * callback fires. Use this for the happy-path callbacks in stories so reviewers can see exactly + * which command/method gets invoked with which arguments. + * + * @example + * + * ```tsx + * onOpenProject: (projectId, isEditable) => + * alertCommand('platformScriptureEditor.openScriptureEditor', { projectId, isEditable }); + * // alerts: platformScriptureEditor.openScriptureEditor(projectId="abc", isEditable=true) + * ``` + * + * @param command The PAPI command / method name the real webview would invoke. + * @param args Optional map of argument names to values, rendered as `name=value` pairs. + */ +export function alertCommand(command: string, args?: Record): void { + const argList = args + ? Object.entries(args) + .map(([name, value]) => `${name}=${formatArg(value)}`) + .join(', ') + : ''; + // Stories use alert() purely for demonstration so reviewers can see which command fires. This is + // the single centralized place the rule is suppressed for that purpose. + // eslint-disable-next-line no-alert + alert(`${command}(${argList})`); +} + +/** + * Build a mock action callback that rejects with a _business_ error after a short delay, for + * error-handling stories. Assign it to any `on*` action callback whose component is expected to + * surface failures (e.g. "cannot delete item"). + * + * @example + * + * ```tsx + * onDeleteComment: rejectingMock('Cannot delete comment: it has already been resolved'); + * ``` + * + * @param businessError The user-facing business reason the operation failed (not "backend + * unavailable"). + * @param delayMs How long to wait before rejecting, to mimic a real async round-trip. Defaults to + * 300ms. + * @returns An async callback (usable for any action signature) that rejects with `businessError`. + */ +export function rejectingMock( + businessError: string, + delayMs = 300, +): (...args: unknown[]) => Promise { + return () => + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error(businessError)), delayMs); + }); +} diff --git a/extensions/src/platform-get-resources/src/get-resources.component.tsx b/extensions/src/platform-get-resources/src/get-resources.component.tsx new file mode 100644 index 00000000000..626f3cabde2 --- /dev/null +++ b/extensions/src/platform-get-resources/src/get-resources.component.tsx @@ -0,0 +1,543 @@ +import { + AlertCircle, + BookOpen, + ChevronDown, + ChevronsUpDown, + ChevronUp, + Ellipsis, + Globe, + Shapes, +} from 'lucide-react'; +import { + Alert, + AlertDescription, + AlertTitle, + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Filter, + Label, + MultiSelectComboBoxEntry, + SearchBar, + Spinner, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'platform-bible-react'; +import type { DblResourceData, LocalizedStringValue } from 'platform-bible-utils'; +import { getErrorMessage } from 'platform-bible-utils'; +import { useMemo, useState } from 'react'; + +/** + * Object containing all keys used for localization in this component. If you're using this + * component in an extension, you can pass it into the useLocalizedStrings hook to easily obtain the + * localized strings and pass them into the localizedStringsWithLoadingState prop of this + * component. + */ +export const GET_RESOURCES_STRING_KEYS = Object.freeze([ + '%general_error_title%', + '%resources_action%', + '%resources_any_language%', + '%resources_any_type%', + '%resources_dialog_subtitle%', + '%resources_dialog_title%', + '%resources_filterBy%', + '%resources_filterInput%', + '%resources_fullName%', + '%resources_get%', + '%resources_installed%', + '%resources_language%', + '%resources_languages%', + '%resources_noResults%', + '%resources_noResultsError%', + '%resources_open%', + '%resources_remove%', + '%resources_results%', + '%resources_showing%', + '%resources_size%', + '%resources_type%', + '%resources_types%', + '%resources_type_Scripture%', + '%resources_type_Commentary%', + '%resources_type_ER%', + '%resources_type_SLR%', + '%resources_type_XR%', + '%resources_type_unknown%', + '%resources_update%', +] as const); + +type GetResourcesLocalizedStringKey = (typeof GET_RESOURCES_STRING_KEYS)[number]; +type GetResourcesLocalizedStrings = { + [localizedKey in GetResourcesLocalizedStringKey]?: LocalizedStringValue; +}; + +export type ResourceAction = 'install' | 'remove'; + +type SortConfig = { + key: 'fullName' | 'bestLanguageName' | 'type' | 'size' | 'action'; + direction: 'ascending' | 'descending'; +}; + +const emptyResources: DblResourceData[] = []; + +const getLanguageOptions = ( + resources: DblResourceData[], + selectedLanguages: string[], +): MultiSelectComboBoxEntry[] => { + const allLanguages: string[] = Array.from( + new Set(resources.map((resource) => resource.bestLanguageName)), + ); + + const starredLanguages = new Set( + resources.filter((resource) => resource.installed).map((resource) => resource.bestLanguageName), + ); + + const prioritizedLanguages = new Set(selectedLanguages.concat(Array.from(starredLanguages))); + + const sortedLanguages = allLanguages.sort((a, b) => { + const aIsPrioritized = prioritizedLanguages.has(a); + const bIsPrioritized = prioritizedLanguages.has(b); + + if (aIsPrioritized && bIsPrioritized) { + return a.localeCompare(b); + } + if (aIsPrioritized) return -1; + if (bIsPrioritized) return 1; + + return a.localeCompare(b); + }); + + return sortedLanguages.map((language) => { + const count = resources.filter((resource) => resource.bestLanguageName === language).length; + return { + label: language, + value: language, + starred: starredLanguages.has(language), + secondaryLabel: count.toString(), + }; + }); +}; + +const getActionButtonContent = ( + resource: DblResourceData, + buttonText: string, + onInstallOrRemoveResource: (dblEntryUid: string, action: ResourceAction) => void, +) => { + return ( + + ); +}; + +const getActionContent = ( + resource: DblResourceData, + idsBeingHandled: string[], + getText: string, + updateText: string, + installedText: string, + onInstallOrRemoveResource: (dblEntryUid: string, action: ResourceAction) => void, +) => { + const isBeingHandled = idsBeingHandled.includes(resource.dblEntryUid); + if (isBeingHandled) { + return ( + + ); + } + if (!resource.installed) { + return getActionButtonContent(resource, getText, onInstallOrRemoveResource); + } + if (resource.updateAvailable) { + return getActionButtonContent(resource, updateText, onInstallOrRemoveResource); + } + return ; +}; + +export type GetResourcesProps = { + /** + * Array of [Object with localized strings for the component, isLoading]. Import + * `GET_RESOURCES_STRING_KEYS` from this module, pass it into the Platform's localization hook, + * and pass the result here. + */ + localizedStringsWithLoadingState?: [GetResourcesLocalizedStrings, boolean]; + /** The list of DBL resources to display (already resolved; pass an empty array on error). */ + resources?: DblResourceData[]; + /** Whether the resource list is currently loading. */ + isLoadingResources?: boolean; + /** Whether loading the resource list failed (shows the error message instead of the table). */ + isResourcesError?: boolean; + /** DBL entry UIDs that are currently installing/removing (shown with a spinner). */ + idsBeingHandled?: string[]; + /** Currently selected resource type filter values. */ + selectedTypes?: string[]; + /** Currently selected language filter values. */ + selectedLanguages?: string[]; + /** Callback fired when the selected resource types change. */ + onSelectedTypesChange?: (types: string[]) => void; + /** Callback fired when the selected languages change. */ + onSelectedLanguagesChange?: (languages: string[]) => void; + /** + * Callback to install or remove a resource. May be async; if it rejects, the component shows the + * error message in a destructive alert. + * + * @param dblEntryUid - The DBL entry UID of the resource. + * @param action - Whether to `install` or `remove` the resource. + */ + onInstallOrRemoveResource?: (dblEntryUid: string, action: ResourceAction) => void | Promise; + /** + * Callback to open an installed resource. + * + * @param projectId - The project ID of the resource to open. + */ + onOpenResource?: (projectId: string) => void; +}; + +/** + * A component that displays the list of available DBL resources with type/language filters, text + * search, and per-resource install/remove/open actions. The localized-strings prop uses the tuple + * `[strings, isLoading]` shape (matching the Home component). + * + * @returns The Get Resources dialog UI. + */ +export function GetResources({ + localizedStringsWithLoadingState = [{}, false], + resources = emptyResources, + isLoadingResources = false, + isResourcesError = false, + idsBeingHandled = [], + selectedTypes = [], + selectedLanguages = [], + onSelectedTypesChange = () => {}, + onSelectedLanguagesChange = () => {}, + onInstallOrRemoveResource = () => {}, + onOpenResource = () => {}, +}: GetResourcesProps) { + const getLocalizedString = (key: GetResourcesLocalizedStringKey): string => + localizedStringsWithLoadingState[0][key] ?? key; + + const errorTitleText: string = getLocalizedString('%general_error_title%'); + const actionText: string = getLocalizedString('%resources_action%'); + const anyLanguage: string = getLocalizedString('%resources_any_language%'); + const anyType: string = getLocalizedString('%resources_any_type%'); + const dialogSubtitleText: string = getLocalizedString('%resources_dialog_subtitle%'); + const dialogTitleText: string = getLocalizedString('%resources_dialog_title%'); + const filterInputText: string = getLocalizedString('%resources_filterInput%'); + const filterByText: string = getLocalizedString('%resources_filterBy%'); + const fullNameText: string = getLocalizedString('%resources_fullName%'); + const getText: string = getLocalizedString('%resources_get%'); + const installedText: string = getLocalizedString('%resources_installed%'); + const languageText: string = getLocalizedString('%resources_language%'); + const languagesText: string = getLocalizedString('%resources_languages%'); + const noResultsText: string = getLocalizedString('%resources_noResults%'); + const noResultsErrorText: string = getLocalizedString('%resources_noResultsError%'); + const openText: string = getLocalizedString('%resources_open%'); + const removeText: string = getLocalizedString('%resources_remove%'); + const resultsText: string = getLocalizedString('%resources_results%'); + const showingText: string = getLocalizedString('%resources_showing%'); + const sizeText: string = getLocalizedString('%resources_size%'); + const typeText: string = getLocalizedString('%resources_type%'); + const typesText: string = getLocalizedString('%resources_types%'); + const typeScriptureText: string = getLocalizedString('%resources_type_Scripture%'); + const typeCommentaryText: string = getLocalizedString('%resources_type_Commentary%'); + const typeErText: string = getLocalizedString('%resources_type_ER%'); + const typeSlrText: string = getLocalizedString('%resources_type_SLR%'); + const typeXrText: string = getLocalizedString('%resources_type_XR%'); + const typeUnknownText: string = getLocalizedString('%resources_type_unknown%'); + const updateText: string = getLocalizedString('%resources_update%'); + + // Surfaces a business error (e.g. "resource is no longer available") when an install/remove + // action callback rejects, so failures are visible rather than only logged by the webview. + const [actionError, setActionError] = useState(undefined); + + const handleInstallOrRemoveResource = async (dblEntryUid: string, action: ResourceAction) => { + setActionError(undefined); + try { + await onInstallOrRemoveResource(dblEntryUid, action); + } catch (e) { + setActionError(getErrorMessage(e)); + } + }; + + const [textFilter, setTextFilter] = useState(''); + + const textFilteredResources = useMemo(() => { + return resources.filter((resource) => { + const filter = textFilter.toLowerCase(); + return ( + resource.displayName.toLowerCase().includes(filter) || + resource.fullName.toLowerCase().includes(filter) || + resource.bestLanguageName.toLowerCase().includes(filter) + ); + }); + }, [resources, textFilter]); + + const typeOptions: MultiSelectComboBoxEntry[] = useMemo(() => { + const getTypeCount = (type: string): string => + (resources.filter((resource) => resource.type === type).length ?? 0).toString(); + + return [ + { + value: 'ScriptureResource', + label: typeScriptureText, + secondaryLabel: getTypeCount('ScriptureResource'), + }, + { + value: 'CommentaryResource', + label: typeCommentaryText, + secondaryLabel: getTypeCount('CommentaryResource'), + }, + { + value: 'EnhancedResource', + label: typeErText, + secondaryLabel: getTypeCount('EnhancedResource'), + }, + { + value: 'SourceLanguageResource', + label: typeSlrText, + secondaryLabel: getTypeCount('SourceLanguageResource'), + }, + { + value: 'XmlResource', + label: typeXrText, + secondaryLabel: getTypeCount('XmlResource'), + }, + ]; + }, [typeScriptureText, typeCommentaryText, typeErText, typeSlrText, typeXrText, resources]); + + const textAndTypeFilteredResources = useMemo(() => { + if (selectedTypes.length === 0) return textFilteredResources; + return textFilteredResources.filter((resource) => selectedTypes.includes(resource.type)); + }, [textFilteredResources, selectedTypes]); + + const textAndTypeAndLanguageFilteredResources = useMemo(() => { + if (selectedLanguages.length === 0) return textAndTypeFilteredResources; + return textAndTypeFilteredResources.filter((resource) => + selectedLanguages.includes(resource.bestLanguageName), + ); + }, [selectedLanguages, textAndTypeFilteredResources]); + + const [sortConfig, setSortConfig] = useState({ + key: 'bestLanguageName', + direction: 'ascending', + }); + + const sortedResources = useMemo(() => { + return [...textAndTypeAndLanguageFilteredResources].sort((a, b) => { + let aValue: string | number; + let bValue: string | number; + if (sortConfig.key === 'action') { + aValue = (a.installed ? 10 : 0) + (a.updateAvailable ? 1 : 0); + bValue = (b.installed ? 10 : 0) + (b.updateAvailable ? 1 : 0); + } else { + aValue = a[sortConfig.key]; + bValue = b[sortConfig.key]; + } + + if (aValue < bValue) { + return sortConfig.direction === 'ascending' ? -1 : 1; + } + if (aValue > bValue) { + return sortConfig.direction === 'ascending' ? 1 : -1; + } + return 0; + }); + }, [sortConfig.direction, sortConfig.key, textAndTypeAndLanguageFilteredResources]); + + const handleSort = (key: SortConfig['key']) => { + const newSortConfig: SortConfig = { key, direction: 'ascending' }; + if (sortConfig.key === key && sortConfig.direction === 'ascending') { + newSortConfig.direction = 'descending'; + } + setSortConfig(newSortConfig); + }; + + const buildTableHead = (key: SortConfig['key'], label: string) => ( + handleSort(key)}> +
+
{label}
+ {sortConfig.key !== key && } + {sortConfig.key === key && + (sortConfig.direction === 'ascending' ? ( + + ) : ( + + ))} +
+
+ ); + + return ( +
+ + +
+
+ +
+ {dialogTitleText} + {dialogSubtitleText} + +
+
+
+ + } + badgesPlaceholder={anyType} + isDisabled={isLoadingResources} + /> + + } + badgesPlaceholder={anyLanguage} + isDisabled={isLoadingResources} + /> +
+
+
+ {actionError && ( +
+ + + {errorTitleText} + {actionError} + +
+ )} + + {isLoadingResources ? ( +
+ +
+ ) : ( + // Can't use if-else here because of how the return statement is structured + /* eslint-disable no-nested-ternary */ +
+ {isResourcesError ? ( +
+ +
+ ) : sortedResources.length === 0 ? ( +
+ +
+ ) : ( + + + + + + {buildTableHead('fullName', fullNameText)} + {buildTableHead('bestLanguageName', languageText)} + {buildTableHead('type', typeText)} + {buildTableHead('size', sizeText)} + {buildTableHead('action', actionText)} + + + + {sortedResources.map((resource) => ( + { + if (resource.installed) onOpenResource(resource.projectId); + }} + key={resource.displayName + resource.fullName} + > + + + + {resource.displayName} + + {resource.fullName} + + {resource.bestLanguageName} + + {typeOptions.find((type) => type.value === resource.type)?.label ?? + typeUnknownText} + + {resource.size} + +
+ {getActionContent( + resource, + idsBeingHandled, + getText, + updateText, + installedText, + handleInstallOrRemoveResource, + )} + {resource.installed && ( + + + + + + onOpenResource(resource.projectId)} + > + {openText} + + + + + handleInstallOrRemoveResource(resource.dblEntryUid, 'remove') + } + > + {removeText} + + + + )} +
+
+
+ ))} +
+
+ )} +
+ )} +
+ + {sortedResources.length > 0 && ( + + )} + +
+
+ ); +} diff --git a/extensions/src/platform-get-resources/src/get-resources.stories.tsx b/extensions/src/platform-get-resources/src/get-resources.stories.tsx new file mode 100644 index 00000000000..a4664c7fbc1 --- /dev/null +++ b/extensions/src/platform-get-resources/src/get-resources.stories.tsx @@ -0,0 +1,174 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import type { DblResourceData } from 'platform-bible-utils'; +import { ReactElement, useState } from 'react'; +import { getLocalizedStrings } from '../../../../.storybook/localization.utils'; +import { alertCommand, rejectingMock } from '../../../../.storybook/story.utils'; +import { + GetResources, + GetResourcesProps, + GET_RESOURCES_STRING_KEYS, +} from './get-resources.component'; + +// Get all localized strings needed by the GetResources component +const localizedStrings = getLocalizedStrings([...GET_RESOURCES_STRING_KEYS]); + +const staticResources: DblResourceData[] = [ + { + dblEntryUid: 'uid-1', + displayName: 'WEB', + fullName: 'World English Bible', + bestLanguageName: 'English', + type: 'ScriptureResource', + size: 1200, + installed: true, + updateAvailable: false, + projectId: 'project-web', + }, + { + dblEntryUid: 'uid-2', + displayName: 'ASV', + fullName: 'American Standard Version', + bestLanguageName: 'English', + type: 'ScriptureResource', + size: 1100, + installed: false, + updateAvailable: false, + projectId: 'project-asv', + }, + { + dblEntryUid: 'uid-3', + displayName: 'LSG', + fullName: 'Louis Segond', + bestLanguageName: 'French', + type: 'ScriptureResource', + size: 1300, + installed: true, + updateAvailable: true, + projectId: 'project-lsg', + }, + { + dblEntryUid: 'uid-4', + displayName: 'TSK', + fullName: 'Treasury of Scripture Knowledge', + bestLanguageName: 'English', + type: 'CommentaryResource', + size: 4200, + installed: false, + updateAvailable: false, + projectId: 'project-tsk', + }, + { + dblEntryUid: 'uid-5', + displayName: 'SDBG', + fullName: 'Semantic Dictionary of Biblical Greek', + bestLanguageName: 'Greek', + type: 'EnhancedResource', + size: 800, + installed: false, + updateAvailable: false, + projectId: 'project-sdbg', + }, +]; + +const meta: Meta = { + title: 'Bundled Extensions/platform-get-resources/GetResources', + component: GetResources, + tags: ['autodocs'], + argTypes: {}, + decorators: [], +}; +export default meta; + +type Story = StoryObj; + +type DecoratorConfig = { + resources?: DblResourceData[]; + isLoadingResources?: boolean; + isResourcesError?: boolean; + onInstallOrRemoveResource?: GetResourcesProps['onInstallOrRemoveResource']; +}; + +/** + * Builds a story decorator that wires the controlled type/language filters to local state and mocks + * the action callbacks. By default actions announce the command they would run via `alertCommand`; + * pass `onInstallOrRemoveResource` to simulate a business failure for the error-handling stories. + */ +function createDecorator(config: DecoratorConfig) { + return function GetResourcesDecorator( + Story: (update?: { args: GetResourcesProps }) => ReactElement, + ) { + const [selectedTypes, setSelectedTypes] = useState(['ScriptureResource']); + const [selectedLanguages, setSelectedLanguages] = useState([]); + + return ( + + alertCommand('platformScriptureEditor.openResourceViewer', { projectId }), + onInstallOrRemoveResource: + config.onInstallOrRemoveResource ?? + ((dblEntryUid, action) => + alertCommand( + action === 'install' + ? 'platformGetResources.dblResourcesProvider.installDblResource' + : 'platformGetResources.dblResourcesProvider.uninstallDblResource', + { dblEntryUid }, + )), + }} + /> + ); + }; +} + +export const Default: Story = { + decorators: [createDecorator({})], +}; + +export const Loading: Story = { + decorators: [createDecorator({ isLoadingResources: true, resources: [] })], +}; + +export const Empty: Story = { + decorators: [createDecorator({ resources: [] })], +}; + +export const ResourcesError: Story = { + decorators: [createDecorator({ isResourcesError: true, resources: [] })], +}; + +/** + * Clicking "Get" on a not-yet-installed resource triggers a rejected install; the component shows + * the business error in a destructive alert. + */ +export const InstallError: Story = { + decorators: [ + createDecorator({ + onInstallOrRemoveResource: rejectingMock( + 'Cannot install resource: it is no longer available in the DBL', + ), + }), + ], +}; + +/** + * Choosing "Remove" from an installed resource's menu triggers a rejected uninstall; the component + * shows the business error in a destructive alert. + */ +export const RemoveError: Story = { + decorators: [ + createDecorator({ + onInstallOrRemoveResource: rejectingMock( + 'Cannot remove resource: it is currently open in another window', + ), + }), + ], +}; diff --git a/extensions/src/platform-get-resources/src/get-resources.web-view.tsx b/extensions/src/platform-get-resources/src/get-resources.web-view.tsx index c9804b6a893..3d78750b07f 100644 --- a/extensions/src/platform-get-resources/src/get-resources.web-view.tsx +++ b/extensions/src/platform-get-resources/src/get-resources.web-view.tsx @@ -1,201 +1,20 @@ import { WebViewProps } from '@papi/core'; import papi, { logger } from '@papi/frontend'; import { useDataProvider, useLocalizedStrings } from '@papi/frontend/react'; -import { - BookOpen, - ChevronDown, - ChevronsUpDown, - ChevronUp, - Ellipsis, - Globe, - Shapes, -} from 'lucide-react'; -import { - Button, - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - Filter, - Label, - MultiSelectComboBoxEntry, - SearchBar, - Spinner, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - usePromise, -} from 'platform-bible-react'; -import { DblResourceData, getErrorMessage, LocalizeKey } from 'platform-bible-utils'; +import { usePromise } from 'platform-bible-react'; +import { getErrorMessage } from 'platform-bible-utils'; import { useCallback, useEffect, useMemo, useState } from 'react'; - -const GET_RESOURCES_STRING_KEYS: LocalizeKey[] = [ - '%resources_action%', - '%resources_any_language%', - '%resources_any_type%', - '%resources_dialog_subtitle%', - '%resources_dialog_title%', - '%resources_filterBy%', - '%resources_filterInput%', - '%resources_fullName%', - '%resources_get%', - '%resources_installed%', - '%resources_language%', - '%resources_languages%', - '%resources_noResults%', - '%resources_noResultsError%', - '%resources_open%', - '%resources_remove%', - '%resources_results%', - '%resources_showing%', - '%resources_size%', - '%resources_type%', - '%resources_types%', - '%resources_type_Scripture%', - '%resources_type_Commentary%', - '%resources_type_ER%', - '%resources_type_SLR%', - '%resources_type_XR%', - '%resources_type_unknown%', - '%resources_update%', -]; +import { GetResources, GET_RESOURCES_STRING_KEYS, ResourceAction } from './get-resources.component'; type InstallInfo = { dblEntryUid: string; action: 'installing' | 'removing'; }; -type SortConfig = { - key: 'fullName' | 'bestLanguageName' | 'type' | 'size' | 'action'; - direction: 'ascending' | 'descending'; -}; - -const getLanguageOptions = ( - resources: DblResourceData[], - selectedLanguages: string[], -): MultiSelectComboBoxEntry[] => { - const allLanguages: string[] = Array.from( - new Set( - resources.map((resource) => { - return resource.bestLanguageName; - }), - ), - ); - - const starredLanguages = new Set( - resources.filter((resource) => resource.installed).map((resource) => resource.bestLanguageName), - ); - - const prioritizedLanguages = new Set(selectedLanguages.concat(Array.from(starredLanguages))); - - const sortedLanguages = allLanguages.sort((a, b) => { - const aIsPrioritized = prioritizedLanguages.has(a); - const bIsPrioritized = prioritizedLanguages.has(b); - - if (aIsPrioritized && bIsPrioritized) { - return a.localeCompare(b); - } - if (aIsPrioritized) return -1; - if (bIsPrioritized) return 1; - - return a.localeCompare(b); - }); - - return sortedLanguages.map((language) => { - const count = resources.filter((resource) => resource.bestLanguageName === language).length; - return { - label: language, - value: language, - starred: starredLanguages.has(language), - secondaryLabel: count.toString(), - }; - }); -}; - -const getActionButtonContent = ( - resource: DblResourceData, - buttonText: string, - installResource: (dblEntryUid: string, action: 'install' | 'remove') => void, -) => { - return ( - - ); -}; - -const getActionContent = ( - resource: DblResourceData, - idsBeingHandled: string[], - getText: string, - updateText: string, - installedText: string, - installResource: (dblEntryUid: string, action: 'install' | 'remove') => void, -) => { - const isBeingHandled = idsBeingHandled.includes(resource.dblEntryUid); - if (isBeingHandled) { - return ( - - ); - } - if (!resource.installed) { - return getActionButtonContent(resource, getText, installResource); - } - if (resource.updateAvailable) { - return getActionButtonContent(resource, updateText, installResource); - } - return ; -}; - -const emptyArray: DblResourceData[] = []; - globalThis.webViewComponent = function GetResourcesDialog({ useWebViewState }: WebViewProps) { - const [localizedStrings] = useLocalizedStrings(GET_RESOURCES_STRING_KEYS); - - const actionText: string = localizedStrings['%resources_action%']; - const anyLanguage: string = localizedStrings['%resources_any_language%']; - const anyType: string = localizedStrings['%resources_any_type%']; - const dialogSubtitleText: string = localizedStrings['%resources_dialog_subtitle%']; - const dialogTitleText: string = localizedStrings['%resources_dialog_title%']; - const filterInputText: string = localizedStrings['%resources_filterInput%']; - const filterByText: string = localizedStrings['%resources_filterBy%']; - const fullNameText: string = localizedStrings['%resources_fullName%']; - const getText: string = localizedStrings['%resources_get%']; - const installedText: string = localizedStrings['%resources_installed%']; - const languageText: string = localizedStrings['%resources_language%']; - const languagesText: string = localizedStrings['%resources_languages%']; - const noResultsText: string = localizedStrings['%resources_noResults%']; - const noResultsErrorText: string = localizedStrings['%resources_noResultsError%']; - const openText: string = localizedStrings['%resources_open%']; - const removeText: string = localizedStrings['%resources_remove%']; - const resultsText: string = localizedStrings['%resources_results%']; - const showingText: string = localizedStrings['%resources_showing%']; - const sizeText: string = localizedStrings['%resources_size%']; - const typeText: string = localizedStrings['%resources_type%']; - const typesText: string = localizedStrings['%resources_types%']; - const typeScriptureText: string = localizedStrings['%resources_type_Scripture%']; - const typeCommentaryText: string = localizedStrings['%resources_type_Commentary%']; - const typeErText: string = localizedStrings['%resources_type_ER%']; - const typeSlrText: string = localizedStrings['%resources_type_SLR%']; - const typeXrText: string = localizedStrings['%resources_type_XR%']; - const typeUnknownText: string = localizedStrings['%resources_type_unknown%']; - const updateText: string = localizedStrings['%resources_update%']; + const localizedStringsWithLoadingState = useLocalizedStrings( + useMemo(() => [...GET_RESOURCES_STRING_KEYS], []), + ); const dblResourcesProvider = useDataProvider('platformGetResources.dblResourcesProvider'); const installResource = dblResourcesProvider?.installDblResource; @@ -216,6 +35,8 @@ globalThis.webViewComponent = function GetResourcesDialog({ useWebViewState }: W undefined, ); + const resolvedResources = useMemo(() => resources ?? [], [resources]); + const [isInitialized, setIsInitialized] = useState(false); const [selectedTypes, setSelectedTypes] = useWebViewState('typeFilter', [ @@ -238,11 +59,11 @@ globalThis.webViewComponent = function GetResourcesDialog({ useWebViewState }: W setIsInitialized(true); return; } - if (resources && resources.length > 0 && selectedLanguages.length === 0) { + if (resolvedResources.length > 0 && selectedLanguages.length === 0) { setSelectedLanguages( Array.from( new Set( - resources + resolvedResources .filter((resource) => resource.installed === true) .map((resource) => resource.bestLanguageName), ), @@ -250,13 +71,19 @@ globalThis.webViewComponent = function GetResourcesDialog({ useWebViewState }: W ); setIsInitialized(true); } - }, [selectedLanguages.length, setSelectedLanguages, isInitialized, setIsInitialized, resources]); + }, [ + selectedLanguages.length, + setSelectedLanguages, + isInitialized, + setIsInitialized, + resolvedResources, + ]); const [installInfo, setInstallInfo] = useState([]); const installOrRemoveResource = useCallback( - (dblEntryUid: string, action: 'install' | 'remove'): void => { - if (!installResource || !uninstallResource) return; + (dblEntryUid: string, action: ResourceAction): Promise | void => { + if (!installResource || !uninstallResource) return undefined; const newInstallInfo: InstallInfo = { dblEntryUid, action: action === 'install' ? 'installing' : 'removing', @@ -266,10 +93,17 @@ globalThis.webViewComponent = function GetResourcesDialog({ useWebViewState }: W const actionFunction = action === 'install' ? installResource : uninstallResource; - actionFunction(dblEntryUid) - .then(() => setFetchResources(true)) + return actionFunction(dblEntryUid) + .then(() => { + // Trigger a refetch so the resource list reflects the new installed state. + setFetchResources(true); + }) .catch((error) => { logger.debug(getErrorMessage(error)); + // The action failed, so clear its optimistic in-progress entry and re-throw so the + // component can surface the error to the user. + setInstallInfo((prevInfo) => prevInfo.filter((info) => info.dblEntryUid !== dblEntryUid)); + throw error; }); }, [installResource, uninstallResource], @@ -279,9 +113,7 @@ globalThis.webViewComponent = function GetResourcesDialog({ useWebViewState }: W useEffect(() => { setInstallInfo((currentInstallInfo) => currentInstallInfo.filter((info) => { - if (!resources) return true; - - const resource = resources.find((res) => res.dblEntryUid === info.dblEntryUid); + const resource = resolvedResources.find((res) => res.dblEntryUid === info.dblEntryUid); if (!resource) return true; if (info.action === 'installing' && resource.installed) return false; @@ -290,274 +122,23 @@ globalThis.webViewComponent = function GetResourcesDialog({ useWebViewState }: W return true; }), ); - }, [resources]); - - const [textFilter, setTextFilter] = useState(''); - - const textFilteredResources = useMemo(() => { - if (!resources) return []; - return resources.filter((resource) => { - const filter = textFilter.toLowerCase(); - return ( - resource.displayName.toLowerCase().includes(filter) || - resource.fullName.toLowerCase().includes(filter) || - resource.bestLanguageName.toLowerCase().includes(filter) - ); - }); - }, [resources, textFilter]); - - const typeOptions: MultiSelectComboBoxEntry[] = useMemo(() => { - const getTypeCount = (type: string): string => { - if (!resources) return '0'; - return (resources.filter((resource) => resource.type === type).length ?? 0).toString(); - }; - - return [ - { - value: 'ScriptureResource', - label: typeScriptureText, - secondaryLabel: getTypeCount('ScriptureResource'), - }, - { - value: 'CommentaryResource', - label: typeCommentaryText, - secondaryLabel: getTypeCount('CommentaryResource'), - }, - { - value: 'EnhancedResource', - label: typeErText, - secondaryLabel: getTypeCount('EnhancedResource'), - }, - { - value: 'SourceLanguageResource', - label: typeSlrText, - secondaryLabel: getTypeCount('SourceLanguageResource'), - }, - { - value: 'XmlResource', - label: typeXrText, - secondaryLabel: getTypeCount('XmlResource'), - }, - ]; - }, [typeScriptureText, typeCommentaryText, typeErText, typeSlrText, typeXrText, resources]); - - const textAndTypeFilteredResources = useMemo(() => { - if (selectedTypes.length === 0) return textFilteredResources; - return textFilteredResources.filter((resource) => { - return selectedTypes.includes(resource.type); - }); - }, [textFilteredResources, selectedTypes]); - - const textAndTypeAndLanguageFilteredResources = useMemo(() => { - if (selectedLanguages.length === 0) return textAndTypeFilteredResources; - return textAndTypeFilteredResources.filter((resource) => { - return selectedLanguages.includes(resource.bestLanguageName); - }); - }, [selectedLanguages, textAndTypeFilteredResources]); + }, [resolvedResources]); const idsBeingHandled = useMemo(() => installInfo.map((info) => info.dblEntryUid), [installInfo]); - const [sortConfig, setSortConfig] = useState({ - key: 'bestLanguageName', - direction: 'ascending', - }); - - const sortedResources = useMemo(() => { - return [...textAndTypeAndLanguageFilteredResources].sort((a, b) => { - let aValue: string | number; - let bValue: string | number; - if (sortConfig.key === 'action') { - aValue = (a.installed ? 10 : 0) + (a.updateAvailable ? 1 : 0); - bValue = (b.installed ? 10 : 0) + (b.updateAvailable ? 1 : 0); - } else { - aValue = a[sortConfig.key]; - bValue = b[sortConfig.key]; - } - - if (aValue < bValue) { - return sortConfig.direction === 'ascending' ? -1 : 1; - } - if (aValue > bValue) { - return sortConfig.direction === 'ascending' ? 1 : -1; - } - return 0; - }); - }, [sortConfig.direction, sortConfig.key, textAndTypeAndLanguageFilteredResources]); - - const handleSort = useCallback( - (key: SortConfig['key']) => { - const newSortConfig: SortConfig = { key, direction: 'ascending' }; - if (sortConfig.key === key && sortConfig.direction === 'ascending') { - newSortConfig.direction = 'descending'; - } - setSortConfig(newSortConfig); - }, - [sortConfig], - ); - - const buildTableHead = useCallback( - (key: SortConfig['key'], label: string) => ( - handleSort(key)}> -
-
{label}
- {sortConfig.key !== key && } - {sortConfig.key === key && - (sortConfig.direction === 'ascending' ? ( - - ) : ( - - ))} -
-
- ), - [handleSort, sortConfig], - ); - return ( -
- - -
-
- -
- {dialogTitleText} - {dialogSubtitleText} - -
-
-
- - } - badgesPlaceholder={anyType} - isDisabled={isLoadingResources} - /> - - } - badgesPlaceholder={anyLanguage} - isDisabled={isLoadingResources} - /> -
-
-
- - {isLoadingResources ? ( -
- -
- ) : ( - // Can't use if-else here because of how the return statement is structured - /* eslint-disable no-nested-ternary */ -
- {resources === undefined && !isLoadingResources ? ( -
- -
- ) : sortedResources.length === 0 ? ( -
- -
- ) : ( - - - - - - {buildTableHead('fullName', fullNameText)} - {buildTableHead('bestLanguageName', languageText)} - {buildTableHead('type', typeText)} - {buildTableHead('size', sizeText)} - {buildTableHead('action', actionText)} - - - - {sortedResources.map((resource) => ( - { - if (resource.installed) openResource(resource.projectId); - }} - key={resource.displayName + resource.fullName} - > - - - - {resource.displayName} - - {resource.fullName} - - {resource.bestLanguageName} - - {typeOptions.find((type) => type.value === resource.type)?.label ?? - typeUnknownText} - - {resource.size} - -
- {getActionContent( - resource, - idsBeingHandled, - getText, - updateText, - installedText, - installOrRemoveResource, - )} - {resource.installed && ( - - - - - - openResource(resource.projectId)} - > - {openText} - - - - - installOrRemoveResource(resource.dblEntryUid, 'remove') - } - > - {removeText} - - - - )} -
-
-
- ))} -
-
- )} -
- )} -
- - {sortedResources.length > 0 && ( - - )} - -
-
+ ); }; diff --git a/extensions/src/platform-get-resources/src/home.component.tsx b/extensions/src/platform-get-resources/src/home.component.tsx index 2fe50e57ba8..27f51aeaf90 100644 --- a/extensions/src/platform-get-resources/src/home.component.tsx +++ b/extensions/src/platform-get-resources/src/home.component.tsx @@ -1,5 +1,15 @@ -import { BookOpen, ChevronDown, ChevronsUpDown, ChevronUp, ScrollText } from 'lucide-react'; import { + AlertCircle, + BookOpen, + ChevronDown, + ChevronsUpDown, + ChevronUp, + ScrollText, +} from 'lucide-react'; +import { + Alert, + AlertDescription, + AlertTitle, Button, Card, CardContent, @@ -18,7 +28,7 @@ import { TableRow, } from 'platform-bible-react'; import type { LocalizedStringValue } from 'platform-bible-utils'; -import { formatTimeSpan } from 'platform-bible-utils'; +import { formatTimeSpan, getErrorMessage } from 'platform-bible-utils'; import type { EditedStatus, SharedProjectsInfo } from 'platform-scripture'; import { ReactNode, useMemo, useState } from 'react'; import { HomeItemDropdownMenu } from './home-item-menu'; @@ -29,6 +39,7 @@ import { HomeItemDropdownMenu } from './home-item-menu'; * localized strings and pass them into the localizedStrings prop of this component */ export const HOME_STRING_KEYS = Object.freeze([ + '%general_error_title%', '%resources_action%', '%resources_activity%', '%resources_clearSearch%', @@ -104,11 +115,12 @@ export type HomeProps = { */ onOpenProject?: (projectId: string, isPublished: boolean) => void; /** - * Callback function to send/receive a project. + * Callback function to send/receive a project. May be async; if it rejects, the component shows + * the error message in a destructive alert. * * @param projectId - The ID of the project to send/receive. */ - onSendReceiveProject?: (projectId: string) => void; + onSendReceiveProject?: (projectId: string) => void | Promise; /** Callback function to open the get started website of platform. */ onGetStarted?: () => void; /** Whether to show the Get Resources button. */ @@ -196,6 +208,20 @@ export function Home({ const openText: string = getLocalizedString('%resources_open%'); const searchedForText: string = getLocalizedString('%resources_searchedFor%'); const syncText: string = getLocalizedString('%resources_sync%'); + const errorTitleText: string = getLocalizedString('%general_error_title%'); + + // Surfaces a business error (e.g. a project locked by another user) when an async action + // callback rejects, so failures are visible in the UI rather than only logged by the webview. + const [actionError, setActionError] = useState(undefined); + + const handleSendReceiveProject = async (projectId: string) => { + setActionError(undefined); + try { + await onSendReceiveProject(projectId); + } catch (e) { + setActionError(getErrorMessage(e)); + } + }; const mergedProjectInfo: MergedProjectInfo[] = useMemo(() => { const newMergedProjectInfo: MergedProjectInfo[] = []; @@ -336,14 +362,14 @@ export function Home({ const syncOrGetButton = (project: MergedProjectInfo, isMenuItem?: boolean) => { if (isMenuItem) return ( - onSendReceiveProject(project.projectId)}> + handleSendReceiveProject(project.projectId)}> {getSendReceiveButtonContent(project)} ); return ( @@ -388,6 +414,15 @@ export function Home({ )} + {actionError && ( +
+ + + {errorTitleText} + {actionError} + +
+ )} {isLoadingLocalProjects || isLoadingRemoteProjects ? ( @@ -459,7 +494,8 @@ export function Home({ onDoubleClick={() => project.isLocallyAvailable ? onOpenProject(project.projectId, project.isPublished) - : !isSendReceiveInProgress && onSendReceiveProject(project.projectId) + : !isSendReceiveInProgress && + handleSendReceiveProject(project.projectId) } key={project.projectId} className={cn('tw:rounded-sm', { diff --git a/extensions/src/platform-get-resources/src/home.stories.tsx b/extensions/src/platform-get-resources/src/home.stories.tsx index 7c983972873..dfd5e5a94a8 100644 --- a/extensions/src/platform-get-resources/src/home.stories.tsx +++ b/extensions/src/platform-get-resources/src/home.stories.tsx @@ -4,8 +4,12 @@ import { CardTitle } from 'platform-bible-react'; import type { SharedProjectsInfo } from 'platform-scripture'; import { ReactElement, useEffect, useState } from 'react'; import { getLocalizedStrings } from '../../../../.storybook/localization.utils'; +import { alertCommand, rejectingMock } from '../../../../.storybook/story.utils'; import { Home, HomeProps, LocalProjectInfo, HOME_STRING_KEYS } from './home.component'; +const GET_STARTED_URL = + 'https://github.com/paranext/paranext/wiki/Getting-Started-with-Platform.Bible-and-Paratext-10-Studio'; + // Get all localized strings needed by the Home component const localizedStrings = getLocalizedStrings([...HOME_STRING_KEYS]); @@ -124,19 +128,22 @@ function DefaultHomeDecorator(Story: (update?: { args: HomeProps }) => ReactElem headerContent: ( <> - Home or New Tab + Home ), - onOpenProject: () => { - // Show an alert for demonstration purposes - // eslint-disable-next-line no-alert - alert('Open project'); - }, - onSendReceiveProject: () => { - // Show an alert for demonstration purposes - // eslint-disable-next-line no-alert - alert('Send/Receive project'); - }, + onOpenProject: (projectId, isPublished) => + alertCommand( + isPublished + ? 'platformScriptureEditor.openResourceViewer' + : 'platformScriptureEditor.openScriptureEditor', + { projectId }, + ), + onSendReceiveProject: (projectId) => + alertCommand('paratextBibleSendReceive.sendReceiveProjects', { + projectIds: [projectId], + }), + onOpenGetResources: () => alertCommand('platformGetResources.openGetResources'), + onGetStarted: () => alertCommand('platform.openWindow', { url: GET_STARTED_URL }), }} /> ); @@ -174,3 +181,40 @@ function OnlyWebProjectDecorator(Story: (update?: { args: HomeProps }) => ReactE export const OnlyWebProject: Story = { decorators: [OnlyWebProjectDecorator], }; + +/** + * Demonstrates how the component surfaces a _business_ failure: clicking the sync/get button for a + * shared project triggers a rejected `onSendReceiveProject`, and the component shows the error in a + * destructive alert (not a "backend unavailable" error). + */ +function SendReceiveErrorDecorator(Story: (update?: { args: HomeProps }) => ReactElement) { + return ( + + + Home + + ), + onOpenProject: (projectId, isPublished) => + alertCommand( + isPublished + ? 'platformScriptureEditor.openResourceViewer' + : 'platformScriptureEditor.openScriptureEditor', + { projectId }, + ), + onSendReceiveProject: rejectingMock( + 'Cannot send/receive: project is locked by another user', + ), + }} + /> + ); +} + +export const SendReceiveError: Story = { + decorators: [SendReceiveErrorDecorator], +}; diff --git a/extensions/src/platform-get-resources/src/new-tab.stories.tsx b/extensions/src/platform-get-resources/src/new-tab.stories.tsx new file mode 100644 index 00000000000..0dbff533dcd --- /dev/null +++ b/extensions/src/platform-get-resources/src/new-tab.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { Plus } from 'lucide-react'; +import { CardTitle } from 'platform-bible-react'; +import { ReactElement } from 'react'; +import { getLocalizedStrings } from '../../../../.storybook/localization.utils'; +import { alertCommand } from '../../../../.storybook/story.utils'; +import { Home, HomeProps, LocalProjectInfo, HOME_STRING_KEYS } from './home.component'; + +/** + * The "New Tab" view reuses the `Home` component with the Get Resources button hidden and a Plus + * header. There is no separate `NewTab` component — these stories document how `Home` is configured + * by `new-tab.web-view.tsx`. + */ + +const localizedStrings = getLocalizedStrings([...HOME_STRING_KEYS]); + +// Sample view id, matching the webViewId the real new-tab web view passes to the open command. +const SAMPLE_WEB_VIEW_ID = 'platformGetResources.newTab'; + +const staticLocalProjects: LocalProjectInfo[] = [ + { + projectId: '1', + isPublished: false, + fullName: 'Project 1 - editable', + name: 'Pr1', + language: 'English', + }, + { + projectId: '2', + isPublished: true, + fullName: 'Resource 2 - read-only', + name: 'Res2', + language: 'French', + }, +]; + +const meta: Meta = { + title: 'Bundled Extensions/platform-get-resources/NewTab', + component: Home, + tags: ['autodocs'], + argTypes: {}, + decorators: [], +}; +export default meta; + +type Story = StoryObj; + +function NewTabDecorator(Story: (update?: { args: HomeProps }) => ReactElement) { + return ( + + + New Tab + + ), + onOpenProject: (projectId, isPublished) => + alertCommand( + isPublished + ? 'platformScriptureEditor.openResourceViewer' + : 'platformScriptureEditor.openScriptureEditor', + { projectId, webViewId: SAMPLE_WEB_VIEW_ID }, + ), + }} + /> + ); +} + +export const Default: Story = { + decorators: [NewTabDecorator], +}; + +export const Empty: Story = { + decorators: [], +}; From 1cc8aafe629b470f26faec0a40247eeb26157d03 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 09:13:21 +0200 Subject: [PATCH 02/23] Remove obsolete find-header-demo story --- .../find/find-header-demo.stories-helper.tsx | 393 ------------------ .../src/find/find-header-demo.stories.tsx | 21 - 2 files changed, 414 deletions(-) delete mode 100644 extensions/src/platform-scripture/src/find/find-header-demo.stories-helper.tsx delete mode 100644 extensions/src/platform-scripture/src/find/find-header-demo.stories.tsx diff --git a/extensions/src/platform-scripture/src/find/find-header-demo.stories-helper.tsx b/extensions/src/platform-scripture/src/find/find-header-demo.stories-helper.tsx deleted file mode 100644 index fd2dc8720d9..00000000000 --- a/extensions/src/platform-scripture/src/find/find-header-demo.stories-helper.tsx +++ /dev/null @@ -1,393 +0,0 @@ -import { SerializedVerseRef } from '@sillsdev/scripture'; -import { - ArrowRight, - ChevronDown, - ChevronUp, - Info, - Replace, - ReplaceAll, - TextSearch, - X, -} from 'lucide-react'; -import { - Button, - Checkbox, - Input, - Label, - Popover, - PopoverContent, - PopoverTrigger, - RecentSearches, - Scope, - ScopeSelector, - SCOPE_SELECTOR_STRING_KEYS, - Spinner, - ToggleGroup, - ToggleGroupItem, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, - useRecentSearches, -} from 'platform-bible-react'; -import { FindJobStatus, WordRestriction } from 'platform-scripture'; -import { formatReplacementString } from 'platform-bible-utils'; -import { SetStateAction, useEffect, useMemo, useRef, useState } from 'react'; -import { getLocalizedStrings } from '../../../../../.storybook/localization.utils'; -import { FindFilters } from './find-filters.component'; -import { SearchTextType } from './find-types'; - -const filterLocalizedStrings = getLocalizedStrings([ - '%webView_find_toggleFilters%', - '%webView_find_matchContentIn%', - '%webView_find_allText%', - '%webView_find_allText_tooltip%', - '%webView_find_verseTextOnly%', - '%webView_find_restrictions%', - '%webView_find_restrictions_none%', - '%webView_find_restrictions_wholeWord%', - '%webView_find_restrictions_startOfWord%', - '%webView_find_restrictions_endOfWord%', - '%webView_find_capitalization%', - '%webView_find_matchCase%', - '%webView_find_pattern%', - '%webView_find_allowRegex%', -]); - -const replaceLocalizedStrings = getLocalizedStrings([ - '%webView_find_replace%', - '%webView_find_replaceAll%', - '%webView_find_replaceTerm_placeholder%', - '%webView_find_preserveCase%', - '%webView_find_preserveCase_tooltip%', -]); - -const localizedStrings = getLocalizedStrings([ - '%webView_find_findTab%', - '%webView_find_replaceTab%', - '%webView_find_searchPlaceholder%', - '%webView_find_showRecentSearches%', - '%webView_find_recent%', - '%webView_find_findInProject%', - '%webView_find_showing%', - '%webView_find_previousResult%', - '%webView_find_nextResult%', -]); - -const scopeSelectorLocalizedStrings = getLocalizedStrings([...SCOPE_SELECTOR_STRING_KEYS]); -export function FindHeaderDemo() { - const [searchTerm, setSearchTerm] = useState(''); - - // custom for demo - const [verseRefSetting] = useState({ - book: 'GEN', - chapterNum: 1, - verseNum: 1, - }); - - const [scope, setScope] = useState('book'); - - const [recentSearches, setRecentSearches] = useState([]); - const addRecentSearchItem = useRecentSearches(recentSearches, setRecentSearches); - - const [selectedBookIds, setSelectedBookIds] = useState([]); - const [shouldMatchCase, setShouldMatchCase] = useState(false); - const [searchTextType, setSearchTextType] = useState('all'); - const [wordRestriction, setWordRestriction] = useState('none'); - const [isRegexAllowed, setIsRegexAllowed] = useState(false); - - const [activeMode, setActiveMode] = useState<'find' | 'replace'>('find'); - const [replaceTerm, setReplaceTerm] = useState(''); - const [preserveCase, setPreserveCase] = useState(false); - - const [searchStatus, setSearchStatus] = useState(undefined); - - // custom for demo - const [focusedResultIndex, setFocusedResultIndex] = useState(undefined); - const demoTotalResults = searchStatus === 'completed' ? 5 : 0; - - const areFiltersActive = - shouldMatchCase || wordRestriction !== 'none' || searchTextType !== 'all' || isRegexAllowed; - - const isSearchQueryValid = useMemo(() => { - if (searchTerm.trim() === '') return false; - if (scope === 'selectedBooks' && selectedBookIds.length === 0) return false; - return true; - }, [searchTerm, scope, selectedBookIds]); - - // custom for demo - const [findButtonText, setFindButtonText] = useState(''); - useEffect(() => { - const timeout = setTimeout( - () => setFindButtonText(localizedStrings['%webView_find_findTab%']), - 1000, - ); - return () => clearTimeout(timeout); - }, []); - - // custom for demo - const searchTimeoutRef = useRef | undefined>(undefined); - useEffect(() => { - return () => { - if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); - }; - }, []); - const handleStartSearch = () => { - setSearchStatus('running'); - if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); - searchTimeoutRef.current = setTimeout(() => { - setSearchStatus('completed'); - }, 1000); - - addRecentSearchItem(searchTerm); - }; - - // custom for demo - const availableBookIds = - '111111111111111111111111111111111111111111111111111111111111111111100001000000000000000000001100000000000000101000000000000'; - - // custom for demo: simplified scope display text - const scopeDisplayText = useMemo(() => { - switch (scope) { - case 'chapter': - return `${verseRefSetting.book} ${verseRefSetting.chapterNum}`; - case 'book': - return verseRefSetting.book; - case 'selectedBooks': - return selectedBookIds.length > 0 ? selectedBookIds.join(', ') : '…'; - default: - return ''; - } - }, [scope, selectedBookIds, verseRefSetting]); - - return ( -
- {/* Find/Replace mode toggle */} - { - if (value === 'find' || value === 'replace') setActiveMode(value); - }} - className="tw:w-fit tw:rounded-lg tw:bg-muted tw:p-1" - > - - {localizedStrings['%webView_find_findTab%']} - - - {localizedStrings['%webView_find_replaceTab%']} - - - - {/* Find input row */} -
-
- - } }) => - setSearchTerm(e.target.value) - } - onKeyDown={(e: { key: string }) => { - if (e.key === 'Enter') { - handleStartSearch(); - } - }} - placeholder={localizedStrings['%webView_find_searchPlaceholder%']} - className={`tw:w-full tw:min-w-16 tw:text-ellipsis tw:!pl-8 ${searchTerm ? 'tw:!pe-8' : 'tw:!pr-4'}`} - /> - {searchTerm && ( - - )} -
- - - - - - - - - - -

{localizedStrings['%webView_find_findInProject%']}

-
-
-
-
- - {/* Replace input row — shown in Replace mode */} - {activeMode === 'replace' && ( - <> -
- - } }) => - setReplaceTerm(e.target.value) - } - placeholder={replaceLocalizedStrings['%webView_find_replaceTerm_placeholder%']} - className="tw:w-full tw:min-w-16 tw:!pl-8 tw:!pr-4" - /> -
-
-
- setPreserveCase(checked === true)} - /> - - - - - - - -

- {replaceLocalizedStrings['%webView_find_preserveCase_tooltip%']} -

-
-
-
-
-
- - -
-
- - )} - - {/* Scope selector row */} -
- - - - - - - - - {demoTotalResults > 0 && ( -
- - {formatReplacementString('{current} of {total}', { - current: focusedResultIndex !== undefined ? String(focusedResultIndex + 1) : '–', - total: String(demoTotalResults), - })} - - - -
- )} -
-
- ); -} diff --git a/extensions/src/platform-scripture/src/find/find-header-demo.stories.tsx b/extensions/src/platform-scripture/src/find/find-header-demo.stories.tsx deleted file mode 100644 index a73e68b70e4..00000000000 --- a/extensions/src/platform-scripture/src/find/find-header-demo.stories.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5'; -import { FindHeaderDemo } from './find-header-demo.stories-helper'; - -const meta: Meta = { - title: 'Bundled Extensions/find/FindHeaderDemo', - component: FindHeaderDemo, - tags: ['autodocs'], - decorators: [ - (Story) => ( -
- -
- ), - ], -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; From f07c5fe3efacee0fd04768e0c10266dddbed2d4d Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 12:12:59 +0200 Subject: [PATCH 03/23] split model text panel webview into presentational component + story Apply the webview-split pattern to platform-scripture-editor's model text panel: the webview becomes a thin data-loader that resolves PAPI data and computes a `status`, and a new same-named presentational ModelTextPanel component renders each state from props. - Extract ModelTextPanel (pure, props-driven): renders the noProject / loadingModelTexts / noModelText / unknownResource / installing / loadingText / active states. Owns the editor ref, options, and read-only setUsj effect. - model-text-panel.web-view.tsx: keep all PAPI data resolution; collapse the render branches into a single `status` value passed to the component. - Add model-text-panel.stories.tsx: one story per state, with the active state rendering the read-only Scripture editor on a sample USJ. Verified: typecheck, extensions lint, and storybook:build all clean. Co-Authored-By: Claude Code --- .../src/model-text-panel.component.tsx | 170 ++++++++++++++++++ .../src/model-text-panel.stories.tsx | 73 ++++++++ .../src/model-text-panel.web-view.tsx | 157 +++++----------- 3 files changed, 290 insertions(+), 110 deletions(-) create mode 100644 extensions/src/platform-scripture-editor/src/model-text-panel.component.tsx create mode 100644 extensions/src/platform-scripture-editor/src/model-text-panel.stories.tsx diff --git a/extensions/src/platform-scripture-editor/src/model-text-panel.component.tsx b/extensions/src/platform-scripture-editor/src/model-text-panel.component.tsx new file mode 100644 index 00000000000..83e59d81a6b --- /dev/null +++ b/extensions/src/platform-scripture-editor/src/model-text-panel.component.tsx @@ -0,0 +1,170 @@ +import { + Editorial, + EditorOptions, + EditorRef, + getDefaultViewOptions, + UsjNodeOptions, +} from '@eten-tech-foundation/platform-editor'; +import { Usj } from '@eten-tech-foundation/scripture-utilities'; +import { SerializedVerseRef } from '@sillsdev/scripture'; +import { Button, Spinner } from 'platform-bible-react'; +import type { LocalizedStringValue } from 'platform-bible-utils'; +import { ComponentProps, useEffect, useMemo, useRef } from 'react'; + +const DEFAULT_TEXT_DIRECTION = 'ltr'; + +/** + * Object containing all keys used for localization in this component. If you're using this + * component in an extension, pass these keys into the Platform's localization hook and pass the + * resulting localized strings into the `localizedStrings` prop. + */ +export const MODEL_TEXT_PANEL_STRING_KEYS = Object.freeze([ + '%webView_modelTextPanel_installing%', + '%webView_modelTextPanel_noProject%', + '%webView_modelTextPanel_pickModelText%', + '%webView_modelTextPanel_unknownResource%', + '%webView_modelTextPanel_emptyState_prompt%', +] as const); + +type ModelTextPanelLocalizedStringKey = (typeof MODEL_TEXT_PANEL_STRING_KEYS)[number]; +type ModelTextPanelLocalizedStrings = { + [key in ModelTextPanelLocalizedStringKey]?: LocalizedStringValue; +}; + +/** + * Which of the model-text panel's mutually-exclusive states to render. The web view computes this + * from its data sources (see `model-text-panel.web-view.tsx`) and the component renders it: + * + * - `noProject`: the panel was opened without a project id. + * - `loadingModelTexts`: still resolving which model text is configured. + * - `noModelText`: no model text is configured — show the prompt + picker button. + * - `unknownResource`: the configured resource id isn't in the DBL list. + * - `installing`: the resource is found but not yet installed. + * - `loadingText`: the resource is installed but its USJ hasn't loaded yet. + * - `active`: render the read-only Scripture editor with the model text. + */ +export type ModelTextPanelStatus = + | 'noProject' + | 'loadingModelTexts' + | 'noModelText' + | 'unknownResource' + | 'installing' + | 'loadingText' + | 'active'; + +export type ModelTextPanelProps = { + /** Localized strings; import `MODEL_TEXT_PANEL_STRING_KEYS` to resolve them. */ + localizedStrings: ModelTextPanelLocalizedStrings; + /** Which state to render. */ + status: ModelTextPanelStatus; + /** Called when the user clicks the "pick model text" button in the empty state. */ + onPickModelText: () => void; + /** The model text USJ to show in the editor (used by the `active` state). */ + usj?: Usj; + /** Text direction of the model text (used by the `active` state). Defaults to `ltr`. */ + textDirection?: string; + /** Current Scripture reference for the editor (used by the `active` state). */ + scrRef?: SerializedVerseRef; + /** Called when the editor changes the Scripture reference (used by the `active` state). */ + onScrRefChange?: (scrRef: SerializedVerseRef) => void; + /** Logger forwarded to the editor (used by the `active` state). */ + logger?: ComponentProps['logger']; +}; + +const DEFAULT_SCR_REF: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + +/** + * Read-only panel that displays a project's configured "model text" Scripture resource. It renders + * one of several states (no project, loading, prompt-to-pick, installing, or the active editor) as + * directed by the `status` prop. This is the presentational half of the model-text-panel web view; + * the web view resolves the data and drives `status`. + */ +export function ModelTextPanel({ + localizedStrings, + status, + onPickModelText, + usj, + textDirection = DEFAULT_TEXT_DIRECTION, + scrRef = DEFAULT_SCR_REF, + onScrRefChange = () => {}, + logger, +}: ModelTextPanelProps) { + // EditorRef requires null initial value per React ref convention + // eslint-disable-next-line no-null/no-null + const editorRef = useRef(null); + const options: EditorOptions = useMemo( + () => ({ + isReadonly: true, + hasSpellCheck: false, + // UsjNodeOptions is a complex type; empty-object initializer requires assertion + // eslint-disable-next-line no-type-assertion/no-type-assertion + nodes: {} as UsjNodeOptions, + textDirection, + view: getDefaultViewOptions(), + }), + [textDirection], + ); + + // Read-only: push incoming USJ directly into the editor whenever it changes. + useEffect(() => { + if (usj) editorRef.current?.setUsj(usj); + }, [usj]); + + switch (status) { + case 'noProject': + return ( +
+

{localizedStrings['%webView_modelTextPanel_noProject%']}

+
+ ); + case 'loadingModelTexts': + return ( +
+ +
+ ); + case 'noModelText': + return ( +
+

{localizedStrings['%webView_modelTextPanel_emptyState_prompt%']}

+ +
+ ); + case 'unknownResource': + return ( +
+

{localizedStrings['%webView_modelTextPanel_unknownResource%']}

+
+ ); + case 'installing': + return ( +
+ + {localizedStrings['%webView_modelTextPanel_installing%']} +
+ ); + case 'loadingText': + return ( +
+ +
+ ); + case 'active': + default: + return ( +
+ +
+ ); + } +} + +export default ModelTextPanel; diff --git a/extensions/src/platform-scripture-editor/src/model-text-panel.stories.tsx b/extensions/src/platform-scripture-editor/src/model-text-panel.stories.tsx new file mode 100644 index 00000000000..dd9d7dc5549 --- /dev/null +++ b/extensions/src/platform-scripture-editor/src/model-text-panel.stories.tsx @@ -0,0 +1,73 @@ +import { usxStringToUsj } from '@eten-tech-foundation/scripture-utilities'; +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { getLocalizedStrings } from '../../../../.storybook/localization.utils'; +import { alertCommand } from '../../../../.storybook/story.utils'; +import { ModelTextPanel, MODEL_TEXT_PANEL_STRING_KEYS } from './model-text-panel.component'; + +/** + * `ModelTextPanel` is the presentational half of the model-text panel web view. It shows a + * project's configured "model text" Scripture resource read-only, rendering whichever state the web + * view resolves: no project, loading, a prompt to pick a model text, an unknown/installing + * resource, or the active editor. The web view owns all PAPI data resolution and drives `status`. + */ + +const localizedStrings = getLocalizedStrings([...MODEL_TEXT_PANEL_STRING_KEYS]); + +const sampleUsj = usxStringToUsj(` + + World English Bible (WEB) + + + In the beginning, God created the heavens and the earth. + +`); + +const meta: Meta = { + title: 'Bundled Extensions/platform-scripture-editor/ModelTextPanel', + component: ModelTextPanel, + tags: ['autodocs'], + args: { + localizedStrings, + onPickModelText: () => alertCommand('platform.resourcePicker'), + scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, + onScrRefChange: () => {}, + }, +}; +export default meta; + +type Story = StoryObj; + +/** The panel was opened without a project id. */ +export const NoProject: Story = { + args: { status: 'noProject' }, +}; + +/** Still resolving which model text is configured. */ +export const LoadingModelTexts: Story = { + args: { status: 'loadingModelTexts' }, +}; + +/** No model text configured yet — prompt the user to pick one. */ +export const NoModelText: Story = { + args: { status: 'noModelText' }, +}; + +/** The configured resource id isn't present in the DBL list. */ +export const UnknownResource: Story = { + args: { status: 'unknownResource' }, +}; + +/** The resource was found but is still installing. */ +export const Installing: Story = { + args: { status: 'installing' }, +}; + +/** The resource is installed but its text hasn't loaded yet. */ +export const LoadingText: Story = { + args: { status: 'loadingText' }, +}; + +/** The active state: a read-only Scripture editor showing the model text. */ +export const Active: Story = { + args: { status: 'active', usj: sampleUsj, textDirection: 'ltr' }, +}; diff --git a/extensions/src/platform-scripture-editor/src/model-text-panel.web-view.tsx b/extensions/src/platform-scripture-editor/src/model-text-panel.web-view.tsx index 6478f7c488c..0763250c3a4 100644 --- a/extensions/src/platform-scripture-editor/src/model-text-panel.web-view.tsx +++ b/extensions/src/platform-scripture-editor/src/model-text-panel.web-view.tsx @@ -1,10 +1,3 @@ -import { - Editorial, - EditorOptions, - EditorRef, - getDefaultViewOptions, - UsjNodeOptions, -} from '@eten-tech-foundation/platform-editor'; import { Usj, USJ_TYPE, USJ_VERSION } from '@eten-tech-foundation/scripture-utilities'; import type { WebViewProps } from '@papi/core'; import papi, { logger } from '@papi/frontend'; @@ -16,7 +9,7 @@ import { useProjectDataProvider, useProjectSetting, } from '@papi/frontend/react'; -import { Button, Spinner, usePromise } from 'platform-bible-react'; +import { usePromise } from 'platform-bible-react'; import { DblResourceData, formatReplacementString, @@ -24,11 +17,16 @@ import { isPlatformError, LocalizeKey, } from 'platform-bible-utils'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { DblResourceReference, EffectiveResourceReference } from 'platform-scripture'; import { useEffectiveResourceReferenceList } from './use-effective-resource-reference-list.hook'; import { isDblResourceReference } from './resource-reference.utils'; import { DEFAULT_RESOURCE_REFERENCE_LIST, selectTextConnection } from './select-dbl-resource'; +import { + ModelTextPanel, + ModelTextPanelStatus, + MODEL_TEXT_PANEL_STRING_KEYS, +} from './model-text-panel.component'; const DEFAULT_TEXT_DIRECTION = 'ltr'; @@ -38,22 +36,20 @@ const defaultUsj: Usj = { content: [], }; -const MODEL_TEXT_PANEL_STRING_KEYS: LocalizeKey[] = [ - '%webView_modelTextPanel_installing%', - '%webView_modelTextPanel_noProject%', - '%webView_modelTextPanel_pickModelText%', +// Webview-only localized strings — used for the dynamic title via updateWebViewDefinition. The +// presentational component doesn't know about the title. +const ALL_STRING_KEYS: LocalizeKey[] = [ + ...MODEL_TEXT_PANEL_STRING_KEYS, '%webView_modelTextPanel_title%', '%webView_modelTextPanel_title_withResource%', - '%webView_modelTextPanel_unknownResource%', - '%webView_modelTextPanel_emptyState_prompt%', ]; -globalThis.webViewComponent = function ModelTextPanel({ +globalThis.webViewComponent = function ModelTextPanelWebView({ projectId, updateWebViewDefinition, useWebViewScrollGroupScrRef, }: WebViewProps) { - const [localizedStrings] = useLocalizedStrings(useMemo(() => MODEL_TEXT_PANEL_STRING_KEYS, [])); + const [localizedStrings] = useLocalizedStrings(useMemo(() => ALL_STRING_KEYS, [])); const [scrRef, setScrRef] = useWebViewScrollGroupScrRef(); @@ -95,7 +91,7 @@ globalThis.webViewComponent = function ModelTextPanel({ const dblResources = resourcesPossiblyUndefined ?? []; const effectiveModelText = effectiveModelTexts?.items[0]; - // EffectiveResourceReference is a discriminated union; checking `.type` narrows to DblResourceReference + // EffectiveResourceReference is a discriminated union; isDblResourceReference narrows it let dblRef: (EffectiveResourceReference & DblResourceReference) | undefined; if (isDblResourceReference(effectiveModelText)) { dblRef = effectiveModelText; @@ -211,102 +207,43 @@ globalThis.webViewComponent = function ModelTextPanel({ ), ); - // --- Editor options --- - - // EditorRef requires null initial value per React ref convention - // eslint-disable-next-line no-null/no-null - const editorRef = useRef(null); - const options: EditorOptions = useMemo( - () => ({ - isReadonly: true, - hasSpellCheck: false, - // UsjNodeOptions is a complex type; empty-object initializer requires assertion - // eslint-disable-next-line no-type-assertion/no-type-assertion - nodes: {} as UsjNodeOptions, - textDirection, - view: getDefaultViewOptions(), - }), - [textDirection], - ); - - // --- PDP sync (read-only: push incoming USJ directly into the editor) --- - - useEffect(() => { - if (usjFromPdp) editorRef.current?.setUsj(usjFromPdp); - }, [usjFromPdp]); - - // --- Render --- + // --- Resolve which mutually-exclusive state to render --- + // Mirrors original priority order: no project → loading/empty → unknown → installing → + // loading text → active. - // No project state: panel was opened without a project ID - // Note: It's expected that this isn't shown very long and that the `platform-scripture-editor` - // extension will show the most recent project (or the picked project). + let status: ModelTextPanelStatus; if (!projectId) { - return ( -
-

{localizedStrings['%webView_modelTextPanel_noProject%']}

-
- ); - } - - // Zero state: no model text configured (or still loading) - if (isLoadingResources || !effectiveModelTexts || effectiveModelTexts.items.length === 0) { - return ( -
- {/* Also shows spinner for if loading resources, except if there is no model text then */} - {/* it should directly show the button to pick a model text bellow */} - {isEffectiveModelTextsLoading || - (isLoadingResources && effectiveModelText && effectiveModelTexts.items.length !== 0) ? ( - - ) : ( - <> -

{localizedStrings['%webView_modelTextPanel_emptyState_prompt%']}

- - - )} -
- ); - } - - // Error state: UID not found in DBL list at all - if (dblRef && match === undefined) { - return ( -
-

{localizedStrings['%webView_modelTextPanel_unknownResource%']}

-
- ); - } - - // Installing state: resource found but not yet installed - if (isInstalling) { - return ( -
- - {localizedStrings['%webView_modelTextPanel_installing%']} -
- ); - } - - // Loading state: USJ not yet fetched (usjPossiblyError is undefined while the subscription is initializing) - if (!resourceProjectId || usjPossiblyError === undefined) { - return ( -
- -
- ); + // It's expected this isn't shown long; the `platform-scripture-editor` extension will show + // the most recent project (or the picked project). + status = 'noProject'; + } else if (!effectiveModelTexts || effectiveModelTexts.items.length === 0) { + status = isEffectiveModelTextsLoading ? 'loadingModelTexts' : 'noModelText'; + } else if (isLoadingResources) { + // PT-3991: a model text is configured but the DBL resource list is still loading — show a + // spinner instead of falling through to 'unknownResource' (which would happen because the + // empty dblResources array yields match === undefined). + status = 'loadingModelTexts'; + } else if (dblRef && match === undefined) { + status = 'unknownResource'; + } else if (isInstalling) { + status = 'installing'; + } else if (!resourceProjectId || usjPossiblyError === undefined) { + // usjPossiblyError is undefined while the subscription is initializing + status = 'loadingText'; + } else { + status = 'active'; } - // Active state return ( -
- -
+ showResourcePicker()} + usj={usjFromPdp} + textDirection={textDirection} + scrRef={scrRef} + onScrRefChange={setScrRef} + logger={logger} + /> ); }; From 9d4e88d299ed97a197fc8e81e38745039da0a4b3 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 12:21:32 +0200 Subject: [PATCH 04/23] split comment list webview into presentational component + story Apply the webview-split pattern to legacy-comment-manager's comment list: the web view stays a thin data-loader (PAPI threads, registration, PDP callbacks) and a new CommentListPanel presentational component renders the toolbar + list from props. - Extract CommentListPanel (pure, props-driven): filter toolbar + loading skeletons + empty states + the platform-bible-react CommentList. Forwarded prop types are derived from CommentList so they stay in sync. The comment/ scope filter constants, types, and guards move here and are re-exported. - comment-list.web-view.tsx: keep all PAPI wiring; render with the resolved threads, controlled filters, and callbacks. - Add comment-list.stories.tsx: Loading, Populated, Empty, and EmptyFiltered states with sample threads. Verified: typecheck, extensions lint, and storybook:build all clean. Co-Authored-By: Claude Code --- .../src/comment-list.component.tsx | 221 ++++++++++++++++++ .../src/comment-list.stories.tsx | 191 +++++++++++++++ .../src/comment-list.web-view.tsx | 180 +++----------- 3 files changed, 445 insertions(+), 147 deletions(-) create mode 100644 extensions/src/legacy-comment-manager/src/comment-list.component.tsx create mode 100644 extensions/src/legacy-comment-manager/src/comment-list.stories.tsx diff --git a/extensions/src/legacy-comment-manager/src/comment-list.component.tsx b/extensions/src/legacy-comment-manager/src/comment-list.component.tsx new file mode 100644 index 00000000000..7aec2c1d41e --- /dev/null +++ b/extensions/src/legacy-comment-manager/src/comment-list.component.tsx @@ -0,0 +1,221 @@ +import { + CommentList, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, +} from 'platform-bible-react'; +import type { LanguageStrings } from 'platform-bible-utils'; +import { ComponentProps } from 'react'; + +// Filter constants and types — shared between this presentational panel (which renders the filter +// toolbar) and the web view (which uses the values to build its comment-thread query). + +export const UNFILTERED = 'unfiltered'; +export const FILTER_UNRESOLVED_ASSIGNED = 'unresolved-assigned-to-me'; +export const FILTER_UNREAD_ASSIGNED = 'unread-assigned-to-me'; +export const SCOPE_FILTER_CURRENT_CHAPTER = 'current-chapter'; + +export const commentFilterToLabelKey = { + [FILTER_UNRESOLVED_ASSIGNED]: '%comment_filter_unresolved_assigned_to_me%', + [FILTER_UNREAD_ASSIGNED]: '%comment_filter_unread_assigned_to_me%', + [UNFILTERED]: '%comment_filter_all%', +} as const; + +export type CommentFilter = keyof typeof commentFilterToLabelKey; + +export const scopeFilterToLabelKey = { + [SCOPE_FILTER_CURRENT_CHAPTER]: '%comment_filter_scope_current_chapter%', + [UNFILTERED]: '%comment_filter_scope_all_books%', +} as const; + +export type ScopeFilter = keyof typeof scopeFilterToLabelKey; + +export function isCommentFilter(value: string): value is CommentFilter { + return value in commentFilterToLabelKey; +} +export function isScopeFilter(value: string): value is ScopeFilter { + return value in scopeFilterToLabelKey; +} + +/** Extra localization keys this panel needs beyond `COMMENT_LIST_STRING_KEYS`. */ +export const COMMENT_LIST_PANEL_EXTRA_STRING_KEYS = [ + '%comment_filter_all%', + '%comment_filter_scope_all_books%', + '%comment_filter_scope_current_chapter%', + '%comment_filter_unread_assigned_to_me%', + '%comment_filter_unresolved_assigned_to_me%', + '%no_comments%', + '%no_comments_match_filter%', +] as const; + +// Reuse the underlying CommentList prop types so this panel stays in sync with platform-bible-react. +type CommentListProps = ComponentProps; + +export type CommentListPanelProps = Pick< + CommentListProps, + | 'threads' + | 'currentUser' + | 'handleAddCommentToThread' + | 'handleUpdateComment' + | 'handleDeleteComment' + | 'handleReadStatusChange' + | 'assignableUsers' + | 'canUserAddCommentToThread' + | 'canUserAssignThreadCallback' + | 'canUserResolveThreadCallback' + | 'canUserEditOrDeleteCommentCallback' + | 'selectedThreadId' + | 'onSelectedThreadChange' + | 'onVerseRefClick' +> & { + /** Localized strings for the panel toolbar/empty states and the underlying comment list. */ + localizedStrings: LanguageStrings; + /** Whether comment threads are still loading (renders skeletons). */ + isLoading: boolean; + /** Currently selected comment filter (controlled by the web view, which drives the query). */ + commentFilter: CommentFilter; + /** Called when the comment filter changes. */ + onCommentFilterChange: (filter: CommentFilter) => void; + /** Currently selected scope filter (controlled by the web view, which drives the query). */ + scopeFilter: ScopeFilter; + /** Called when the scope filter changes. */ + onScopeFilterChange: (filter: ScopeFilter) => void; +}; + +/** + * Presentational half of the comment-list web view: a filter toolbar plus the comment list (or a + * loading/empty state). All data and PAPI-backed callbacks are supplied by the web view via props; + * the comment/scope filters are controlled because the web view uses them to query threads. + */ +export function CommentListPanel({ + localizedStrings, + isLoading, + threads, + currentUser, + commentFilter, + onCommentFilterChange, + scopeFilter, + onScopeFilterChange, + handleAddCommentToThread, + handleUpdateComment, + handleDeleteComment, + handleReadStatusChange, + assignableUsers, + canUserAddCommentToThread, + canUserAssignThreadCallback, + canUserResolveThreadCallback, + canUserEditOrDeleteCommentCallback, + selectedThreadId, + onSelectedThreadChange, + onVerseRefClick, +}: CommentListPanelProps) { + if (isLoading) { + return ( +
+ {[...Array(10)].map((_, index) => ( + + ))} +
+ ); + } + + return ( +
+ {/* Filter toolbar */} +
+ {/* Comment filter dropdown */} + + + {/* Scope filter dropdown */} + +
+ + {/* Comments list */} +
+ {threads.length === 0 ? ( +
+ +
+ ) : ( + + )} +
+
+ ); +} + +export default CommentListPanel; diff --git a/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx b/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx new file mode 100644 index 00000000000..07bd30040df --- /dev/null +++ b/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx @@ -0,0 +1,191 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { COMMENT_LIST_STRING_KEYS } from 'platform-bible-react'; +import type { LegacyComment, LegacyCommentThread } from 'platform-bible-utils'; +import { ReactElement, useState } from 'react'; +import { getLocalizedStrings } from '../../../../.storybook/localization.utils'; +import { alertCommand } from '../../../../.storybook/story.utils'; +import { + CommentFilter, + CommentListPanel, + CommentListPanelProps, + COMMENT_LIST_PANEL_EXTRA_STRING_KEYS, + ScopeFilter, + UNFILTERED, +} from './comment-list.component'; + +/** + * `CommentListPanel` is the presentational half of the legacy comment-list web view: a comment / + * scope filter toolbar above the comment list, with loading and empty states. The web view supplies + * the threads and the PAPI-backed callbacks; the filters are controlled because the web view uses + * them to query threads. + */ + +const localizedStrings = getLocalizedStrings([ + ...Array.from(COMMENT_LIST_STRING_KEYS), + ...COMMENT_LIST_PANEL_EXTRA_STRING_KEYS, +]); + +const CURRENT_USER = 'Current User'; + +const makeComment = ( + overrides: Partial & Pick, +): LegacyComment => ({ + user: 'Alice', + date: '2024-01-02T09:30:00.0000000-00:00', + contents: 'We should double-check this rendering against the source text.', + deleted: false, + hideInTextWindow: false, + language: 'en', + isRead: true, + startPosition: 0, + thread: overrides.thread ?? 'thread-1', + verseRef: overrides.verseRef ?? 'GEN 1:1', + ...overrides, +}); + +const sampleThreads: LegacyCommentThread[] = [ + { + id: 'thread-1', + verseRef: 'GEN 1:1', + status: 'Todo', + type: 'Normal', + modifiedDate: '2024-01-02T10:00:00.0000000-00:00', + isSpellingNote: false, + isBTNote: false, + isConsultantNote: false, + isRead: false, + assignedUser: CURRENT_USER, + comments: [ + makeComment({ id: 'c1', thread: 'thread-1', verseRef: 'GEN 1:1' }), + makeComment({ + id: 'c2', + thread: 'thread-1', + verseRef: 'GEN 1:1', + user: 'Bob', + contents: 'Good catch — I will update the draft.', + date: '2024-01-02T10:00:00.0000000-00:00', + }), + ], + }, + { + id: 'thread-2', + verseRef: 'GEN 1:3', + status: 'Resolved', + type: 'Normal', + modifiedDate: '2024-01-03T14:15:00.0000000-00:00', + isSpellingNote: false, + isBTNote: false, + isConsultantNote: false, + isRead: true, + comments: [ + makeComment({ + id: 'c3', + thread: 'thread-2', + verseRef: 'GEN 1:3', + user: 'Charlie', + contents: 'Resolved in the latest send/receive.', + status: 'Resolved', + }), + ], + }, +]; + +const resolveTrue = () => Promise.resolve(true); + +const meta: Meta = { + title: 'Bundled Extensions/legacy-comment-manager/CommentListPanel', + component: CommentListPanel, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +type DecoratorConfig = { + isLoading?: boolean; + threads?: LegacyCommentThread[]; + initialCommentFilter?: CommentFilter; +}; + +/** + * Wires the controlled filters and the selected-thread state to local state, and mocks the + * PAPI-backed callbacks so reviewers can exercise the panel in isolation. + */ +function createDecorator(config: DecoratorConfig) { + return function CommentListPanelDecorator( + Story: (update?: { args: CommentListPanelProps }) => ReactElement, + ) { + const [commentFilter, setCommentFilter] = useState( + config.initialCommentFilter ?? UNFILTERED, + ); + const [scopeFilter, setScopeFilter] = useState(UNFILTERED); + const [selectedThreadId, setSelectedThreadId] = useState(undefined); + + return ( +
+ { + alertCommand('legacyCommentManager.comments.addCommentToThread', { + threadId: options.threadId, + }); + return Promise.resolve(`${options.threadId}-new`); + }, + handleUpdateComment: (commentId) => { + alertCommand('legacyCommentManager.comments.updateComment', { commentId }); + return Promise.resolve(true); + }, + handleDeleteComment: (commentId) => { + alertCommand('legacyCommentManager.comments.deleteComment', { commentId }); + return Promise.resolve(true); + }, + handleReadStatusChange: (threadId, markAsRead) => { + alertCommand('legacyCommentManager.comments.setIsCommentThreadRead', { + threadId, + markAsRead, + }); + return Promise.resolve(true); + }, + selectedThreadId, + onSelectedThreadChange: setSelectedThreadId, + onVerseRefClick: (thread) => + alertCommand('platformScriptureEditor.selectRange', { verseRef: thread.verseRef }), + }} + /> +
+ ); + }; +} + +/** Threads are still loading — show skeletons. */ +export const Loading: Story = { + decorators: [createDecorator({ isLoading: true })], +}; + +/** A populated, unfiltered list with an open and a resolved thread. */ +export const Populated: Story = { + decorators: [createDecorator({})], +}; + +/** No comments exist at all (both filters unset) — shows the "no comments" message. */ +export const Empty: Story = { + decorators: [createDecorator({ threads: [] })], +}; + +/** No comments match the active filter — shows the "no comments match filter" message. */ +export const EmptyFiltered: Story = { + decorators: [createDecorator({ threads: [], initialCommentFilter: 'unread-assigned-to-me' })], +}; diff --git a/extensions/src/legacy-comment-manager/src/comment-list.web-view.tsx b/extensions/src/legacy-comment-manager/src/comment-list.web-view.tsx index af219bb959e..dda6cf26bbe 100644 --- a/extensions/src/legacy-comment-manager/src/comment-list.web-view.tsx +++ b/extensions/src/legacy-comment-manager/src/comment-list.web-view.tsx @@ -3,14 +3,6 @@ import papi, { logger } from '@papi/frontend'; import { AddCommentToThreadOptions, COMMENT_LIST_STRING_KEYS, - CommentList, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Skeleton, usePromise, } from 'platform-bible-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -24,6 +16,16 @@ import { isPlatformError, LegacyCommentThread, serialize } from 'platform-bible- import { VerseRef } from '@sillsdev/scripture'; import type { LegacyCommentThreadSelector } from 'legacy-comment-manager'; import { CommentListWebViewMessage } from './comment-list-messages.model'; +import { + CommentFilter, + CommentListPanel, + COMMENT_LIST_PANEL_EXTRA_STRING_KEYS, + FILTER_UNREAD_ASSIGNED, + FILTER_UNRESOLVED_ASSIGNED, + ScopeFilter, + SCOPE_FILTER_CURRENT_CHAPTER, + UNFILTERED, +} from './comment-list.component'; const DEFAULT_LEGACY_COMMENT_THREADS: LegacyCommentThread[] = []; @@ -50,34 +52,6 @@ async function withPdp( return action(pdp); } -// Filter constants and types -const UNFILTERED = 'unfiltered'; -const FILTER_UNRESOLVED_ASSIGNED = 'unresolved-assigned-to-me'; -const FILTER_UNREAD_ASSIGNED = 'unread-assigned-to-me'; -const SCOPE_FILTER_CURRENT_CHAPTER = 'current-chapter'; - -const commentFilterToLabelKey = { - [FILTER_UNRESOLVED_ASSIGNED]: '%comment_filter_unresolved_assigned_to_me%', - [FILTER_UNREAD_ASSIGNED]: '%comment_filter_unread_assigned_to_me%', - [UNFILTERED]: '%comment_filter_all%', -} as const; - -type CommentFilter = keyof typeof commentFilterToLabelKey; - -const scopeFilterToLabelKey = { - [SCOPE_FILTER_CURRENT_CHAPTER]: '%comment_filter_scope_current_chapter%', - [UNFILTERED]: '%comment_filter_scope_all_books%', -} as const; - -type ScopeFilter = keyof typeof scopeFilterToLabelKey; - -function isCommentFilter(value: string): value is CommentFilter { - return value in commentFilterToLabelKey; -} -function isScopeFilter(value: string): value is ScopeFilter { - return value in scopeFilterToLabelKey; -} - global.webViewComponent = function CommentListWebView({ useWebViewScrollGroupScrRef, useWebViewState, @@ -85,16 +59,7 @@ global.webViewComponent = function CommentListWebView({ }: WebViewProps) { const [localizedStrings] = useLocalizedStrings( useMemo(() => { - return [ - ...Array.from(COMMENT_LIST_STRING_KEYS), - '%comment_filter_all%', - '%comment_filter_scope_all_books%', - '%comment_filter_scope_current_chapter%', - '%comment_filter_unread_assigned_to_me%', - '%comment_filter_unresolved_assigned_to_me%', - '%no_comments%', - '%no_comments_match_filter%', - ]; + return [...Array.from(COMMENT_LIST_STRING_KEYS), ...COMMENT_LIST_PANEL_EXTRA_STRING_KEYS]; }, []), ); const [scrRef, setScrRef] = useWebViewScrollGroupScrRef(); @@ -362,107 +327,28 @@ global.webViewComponent = function CommentListWebView({ [setScrRef, editorWebViewId, editorWebViewController], ); - if (isLoadingCommentThreads || !commentsPdp) { - return ( -
- {[...Array(10)].map((_, index) => ( - - ))} -
- ); - } - return ( -
- {/* Filter toolbar */} -
- {/* Comment filter dropdown */} - - - {/* Scope filter dropdown */} - -
- - {/* Comments list */} -
- {safeCommentThreads.length === 0 ? ( -
- -
- ) : ( - - )} -
-
+ ); }; From 532c37852fb87ffde1d66487512c5acd637ac39f Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 12:31:18 +0200 Subject: [PATCH 05/23] split internet settings webview into presentational component + story Apply the webview-split pattern to paratext-registration's Internet settings: the web view stays the data-loader (load/save settings via PAPI commands, the save-and-restart flow, useWebViewState persistence) and a new InternetSettingsForm presentational component renders the controlled form. - Extract InternetSettingsForm (pure, props-driven): internet-use selector, optional proxy settings card, server selector, success/error alerts, and the save-and-restart button. The server/internet-use/proxy option lists, localize key helpers, and the string-keys list move here and are re-exported. - internet-settings.web-view.tsx: keep all PAPI wiring and derived disabled/ unsaved-changes logic; render with the values. - Add internet-settings.stories.tsx: Default, ProxyOnly, Restarting, SaveError. Verified: typecheck, extensions lint, and storybook:build all clean. Co-Authored-By: Claude Code --- .../src/internet-settings.component.tsx | 277 ++++++++++++++++++ .../src/internet-settings.stories.tsx | 109 +++++++ .../src/internet-settings.web-view.tsx | 245 +--------------- 3 files changed, 402 insertions(+), 229 deletions(-) create mode 100644 extensions/src/paratext-registration/src/internet-settings.component.tsx create mode 100644 extensions/src/paratext-registration/src/internet-settings.stories.tsx diff --git a/extensions/src/paratext-registration/src/internet-settings.component.tsx b/extensions/src/paratext-registration/src/internet-settings.component.tsx new file mode 100644 index 00000000000..9260ddf5dd9 --- /dev/null +++ b/extensions/src/paratext-registration/src/internet-settings.component.tsx @@ -0,0 +1,277 @@ +import { AlertCircle, CircleCheck } from 'lucide-react'; +import { InternetSettings, InternetUse, ServerType } from 'paratext-registration'; +import { + Alert, + AlertDescription, + AlertTitle, + Button, + Card, + CardContent, + CardHeader, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Spinner, +} from 'platform-bible-react'; +import type { LanguageStrings, LocalizeKey } from 'platform-bible-utils'; +import { Grid } from './components/grid.component'; +import { scrollToRef, SaveState } from './utils'; + +export const SERVER_TYPE_OPTIONS: ServerType[] = [ + 'Production', + 'QualityAssurance', + 'Development', + 'Test', +]; +export const INTERNET_USE_OPTIONS: InternetUse[] = [ + 'Enabled', + 'VpnRequired', + 'Disabled', + 'ProxyOnly', +]; +// For some reason, these aren't an enum in C#. So just following the existing conventions. Maybe +// there can be other values, but let's just keep it to these for now. +// InternetAccess.httpProxyMode, InternetAccess.socksProxyMode +export const PROXY_MODE_OPTIONS = ['Http', 'Socks']; + +export function getLocalizeKeyForInternetUse(option: InternetUse): LocalizeKey { + return `%paratextRegistration_description_internetUse_option_${option}%`; +} + +export function getLocalizeKeyForServerType(option: ServerType): LocalizeKey { + return `%paratextRegistration_label_serverType_option_${option}%`; +} + +export function getLocalizeKeyForProxyMode(option: string): LocalizeKey { + return `%paratextRegistration_label_proxyMode_option_${option}%`; +} + +/** + * All localization keys used by the InternetSettingsForm. Pass these into the Platform's + * localization hook and forward the result into the `localizedStrings` prop. + */ +export const INTERNET_SETTINGS_STRING_KEYS: LocalizeKey[] = [ + '%general_error_title%', + '%paratextRegistration_alert_updatedInternetSettings%', + '%paratextRegistration_alert_updatedRegistration_description%', + '%paratextRegistration_alert_updatedRegistration_description_hasRestarted%', + '%paratextRegistration_button_saveAndRestart%', + '%paratextRegistration_button_restarting%', + '%paratextRegistration_description_internetUse_disclaimer%', + ...INTERNET_USE_OPTIONS.map(getLocalizeKeyForInternetUse), + '%paratextRegistration_label_proxyHost%', + '%paratextRegistration_label_proxyMode%', + ...PROXY_MODE_OPTIONS.map(getLocalizeKeyForProxyMode), + '%paratextRegistration_label_proxyPassword%', + '%paratextRegistration_label_proxyPort%', + '%paratextRegistration_label_proxyUsername%', + '%paratextRegistration_label_selectedServer%', + ...SERVER_TYPE_OPTIONS.map(getLocalizeKeyForServerType), + '%paratextRegistration_section_internetSettings%', + '%paratextRegistration_section_internetSettings_tooltip%', + '%paratextRegistration_section_proxySettings%', +]; + +export type InternetSettingsFormProps = { + /** Localized strings; import `INTERNET_SETTINGS_STRING_KEYS` to resolve them. */ + localizedStrings: LanguageStrings; + /** The current (editable) internet settings shown in the form. */ + internetSettings: InternetSettings; + /** Called whenever a field changes with the next settings object. */ + onInternetSettingsChange: (internetSettings: InternetSettings) => void; + /** Whether the form fields are disabled (loading or saving). */ + isFormDisabled: boolean; + /** Whether the save-and-restart button is disabled. */ + isSaveDisabled: boolean; + /** Progress of the save/restart flow; drives the success alert and button label. */ + saveState: SaveState; + /** A save error message to surface in a destructive alert, or empty for none. */ + saveError: string; + /** Called when the user clicks save-and-restart. */ + onSaveAndRestart: () => void; +}; + +/** + * Presentational half of the Internet settings web view: the internet-use selector, optional proxy + * settings, server selector, save/restart button, and success/error alerts. The web view loads and + * persists the settings and owns the save-and-restart flow; this component is fully controlled. + */ +export function InternetSettingsForm({ + localizedStrings, + internetSettings, + onInternetSettingsChange, + isFormDisabled, + isSaveDisabled, + saveState, + saveError, + onSaveAndRestart, +}: InternetSettingsFormProps) { + const formatSuccessAlertDescription = () => { + if (saveState === SaveState.IsRestarting) { + return localizedStrings['%paratextRegistration_alert_updatedRegistration_description%']; + } + + return localizedStrings[ + '%paratextRegistration_alert_updatedRegistration_description_hasRestarted%' + ]; + }; + + return ( +
+
+ {localizedStrings['%paratextRegistration_description_internetUse_disclaimer%']} +
+
+ +
+ {internetSettings.permittedInternetUse === 'ProxyOnly' && ( + + + {localizedStrings['%paratextRegistration_section_proxySettings%']} + + + + {localizedStrings['%paratextRegistration_label_proxyMode%']} + + {localizedStrings['%paratextRegistration_label_proxyHost%']} + + onInternetSettingsChange({ ...internetSettings, proxyHost: e.target.value }) + } + /> + {localizedStrings['%paratextRegistration_label_proxyPort%']} + + onInternetSettingsChange({ + ...internetSettings, + proxyPort: e.target.value.length > 0 ? parseInt(e.target.value, 10) : 0, + }) + } + /> + {localizedStrings['%paratextRegistration_label_proxyUsername%']} + + onInternetSettingsChange({ ...internetSettings, proxyUsername: e.target.value }) + } + /> + {localizedStrings['%paratextRegistration_label_proxyPassword%']} + + onInternetSettingsChange({ ...internetSettings, proxyPassword: e.target.value }) + } + /> + + + + )} + + {localizedStrings['%paratextRegistration_label_selectedServer%']} + + +
+ {!saveError && + (saveState === SaveState.IsRestarting || saveState === SaveState.HasSaved) && ( +
+ + + + {localizedStrings['%paratextRegistration_alert_updatedInternetSettings%']} + + {formatSuccessAlertDescription()} + +
+ )} + {saveError && ( +
+ + + {localizedStrings['%general_error_title%']} + {saveError} + +
+ )} + + + + +
+
+ ); +} + +export default InternetSettingsForm; diff --git a/extensions/src/paratext-registration/src/internet-settings.stories.tsx b/extensions/src/paratext-registration/src/internet-settings.stories.tsx new file mode 100644 index 00000000000..ecea90f3689 --- /dev/null +++ b/extensions/src/paratext-registration/src/internet-settings.stories.tsx @@ -0,0 +1,109 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { InternetSettings } from 'paratext-registration'; +import { ReactElement, useState } from 'react'; +import { getLocalizedStrings } from '../../../../.storybook/localization.utils'; +import { alertCommand } from '../../../../.storybook/story.utils'; +import { + INTERNET_SETTINGS_STRING_KEYS, + InternetSettingsForm, + InternetSettingsFormProps, +} from './internet-settings.component'; +import { SaveState } from './utils'; + +/** + * `InternetSettingsForm` is the presentational half of the Internet settings web view: the + * internet-use selector, optional proxy settings, server selector, and the save-and-restart button + * with success/error alerts. The web view loads and persists settings and runs the restart; this + * component is fully controlled. + */ + +const localizedStrings = getLocalizedStrings(INTERNET_SETTINGS_STRING_KEYS); + +const defaultSettings: InternetSettings = { + permittedInternetUse: 'VpnRequired', + selectedServer: 'Production', + proxyPort: 0, +}; + +const proxySettings: InternetSettings = { + permittedInternetUse: 'ProxyOnly', + selectedServer: 'Production', + proxyMode: 'Http', + proxyHost: 'proxy.example.org', + proxyPort: 8080, + proxyUsername: 'translator', +}; + +const meta: Meta = { + title: 'Bundled Extensions/paratext-registration/InternetSettingsForm', + component: InternetSettingsForm, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +type DecoratorConfig = { + initialSettings?: InternetSettings; + isFormDisabled?: boolean; + isSaveDisabled?: boolean; + saveState?: SaveState; + saveError?: string; +}; + +/** Holds the edited settings in local state so the selects and inputs are interactive. */ +function createDecorator(config: DecoratorConfig) { + return function InternetSettingsDecorator( + Story: (update?: { args: InternetSettingsFormProps }) => ReactElement, + ) { + const [internetSettings, setInternetSettings] = useState( + config.initialSettings ?? defaultSettings, + ); + + return ( + + alertCommand('paratextRegistration.setParatextDataInternetSettings', internetSettings), + }} + /> + ); + }; +} + +/** Default: VPN-required internet use, no proxy. */ +export const Default: Story = { + decorators: [createDecorator({})], +}; + +/** Proxy-only internet use reveals the proxy settings card. */ +export const ProxyOnly: Story = { + decorators: [createDecorator({ initialSettings: proxySettings })], +}; + +/** Mid-restart: fields disabled, button shows the restarting spinner, success alert visible. */ +export const Restarting: Story = { + decorators: [ + createDecorator({ + isFormDisabled: true, + isSaveDisabled: true, + saveState: SaveState.IsRestarting, + }), + ], +}; + +/** A save failure surfaces the error in a destructive alert. */ +export const SaveError: Story = { + decorators: [ + createDecorator({ + saveError: 'Could not reach the registration server. Check your connection and try again.', + }), + ], +}; diff --git a/extensions/src/paratext-registration/src/internet-settings.web-view.tsx b/extensions/src/paratext-registration/src/internet-settings.web-view.tsx index 42fddcfc8a5..46a2c8a405a 100644 --- a/extensions/src/paratext-registration/src/internet-settings.web-view.tsx +++ b/extensions/src/paratext-registration/src/internet-settings.web-view.tsx @@ -1,36 +1,12 @@ import { WebViewProps } from '@papi/core'; import papi, { logger } from '@papi/frontend'; import { useLocalizedStrings } from '@papi/frontend/react'; -import { InternetSettings, InternetUse, ServerType } from 'paratext-registration'; -import { - Alert, - AlertDescription, - AlertTitle, - Button, - Card, - CardContent, - CardHeader, - Input, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Spinner, - usePromise, -} from 'platform-bible-react'; -import { deepEqual, getErrorMessage, LocalizeKey, wait } from 'platform-bible-utils'; +import { InternetSettings } from 'paratext-registration'; +import { usePromise } from 'platform-bible-react'; +import { deepEqual, getErrorMessage, wait } from 'platform-bible-utils'; import { useEffect, useRef, useState } from 'react'; -import { AlertCircle, CircleCheck } from 'lucide-react'; -import { Grid } from './components/grid.component'; -import { scrollToRef, SaveState } from './utils'; - -const SERVER_TYPE_OPTIONS: ServerType[] = ['Production', 'QualityAssurance', 'Development', 'Test']; -const INTERNET_USE_OPTIONS: InternetUse[] = ['Enabled', 'VpnRequired', 'Disabled', 'ProxyOnly']; -// For some reason, these aren't an enum in C#. So just following the existing conventions. Maybe -// there can be other values, but let's just keep it to these for now. -// InternetAccess.httpProxyMode, InternetAccess.socksProxyMode -const PROXY_MODE_OPTIONS = ['Http', 'Socks']; +import { SaveState } from './utils'; +import { INTERNET_SETTINGS_STRING_KEYS, InternetSettingsForm } from './internet-settings.component'; /** * Time in milliseconds to wait before restarting the application after changing Paratext @@ -53,44 +29,6 @@ async function saveInternetSettings(internetSettings: InternetSettings) { // #endregion -// #region localized strings - -function getLocalizeKeyForInternetUse(option: InternetUse): LocalizeKey { - return `%paratextRegistration_description_internetUse_option_${option}%`; -} - -function getLocalizeKeyForServerType(option: ServerType): LocalizeKey { - return `%paratextRegistration_label_serverType_option_${option}%`; -} - -function getLocalizeKeyForProxyMode(option: string): LocalizeKey { - return `%paratextRegistration_label_proxyMode_option_${option}%`; -} - -const LOCALIZED_STRING_KEYS: LocalizeKey[] = [ - '%general_error_title%', - '%paratextRegistration_alert_updatedInternetSettings%', - '%paratextRegistration_alert_updatedRegistration_description%', - '%paratextRegistration_alert_updatedRegistration_description_hasRestarted%', - '%paratextRegistration_button_saveAndRestart%', - '%paratextRegistration_button_restarting%', - '%paratextRegistration_description_internetUse_disclaimer%', - ...INTERNET_USE_OPTIONS.map(getLocalizeKeyForInternetUse), - '%paratextRegistration_label_proxyHost%', - '%paratextRegistration_label_proxyMode%', - ...PROXY_MODE_OPTIONS.map(getLocalizeKeyForProxyMode), - '%paratextRegistration_label_proxyPassword%', - '%paratextRegistration_label_proxyPort%', - '%paratextRegistration_label_proxyUsername%', - '%paratextRegistration_label_selectedServer%', - ...SERVER_TYPE_OPTIONS.map(getLocalizeKeyForServerType), - '%paratextRegistration_section_internetSettings%', - '%paratextRegistration_section_internetSettings_tooltip%', - '%paratextRegistration_section_proxySettings%', -]; - -// #endregion - globalThis.webViewComponent = function InternetSettingsComponent({ useWebViewState, }: WebViewProps) { @@ -102,7 +40,7 @@ globalThis.webViewComponent = function InternetSettingsComponent({ }; }, []); - const [localizedStrings] = useLocalizedStrings(LOCALIZED_STRING_KEYS); + const [localizedStrings] = useLocalizedStrings(INTERNET_SETTINGS_STRING_KEYS); // How much progress the form has made in saving registration data const [saveState, setSaveState] = useWebViewState( @@ -192,167 +130,16 @@ globalThis.webViewComponent = function InternetSettingsComponent({ } }; - const formatSuccessAlertDescription = () => { - if (saveState === SaveState.IsRestarting) { - return localizedStrings['%paratextRegistration_alert_updatedRegistration_description%']; - } - - return localizedStrings[ - '%paratextRegistration_alert_updatedRegistration_description_hasRestarted%' - ]; - }; - return ( -
-
- {localizedStrings['%paratextRegistration_description_internetUse_disclaimer%']} -
-
- -
- {internetSettings.permittedInternetUse === 'ProxyOnly' && ( - - - {localizedStrings['%paratextRegistration_section_proxySettings%']} - - - - {localizedStrings['%paratextRegistration_label_proxyMode%']} - - {localizedStrings['%paratextRegistration_label_proxyHost%']} - - setInternetSettings({ ...internetSettings, proxyHost: e.target.value }) - } - /> - {localizedStrings['%paratextRegistration_label_proxyPort%']} - - setInternetSettings({ - ...internetSettings, - proxyPort: e.target.value.length > 0 ? parseInt(e.target.value, 10) : 0, - }) - } - /> - {localizedStrings['%paratextRegistration_label_proxyUsername%']} - - setInternetSettings({ ...internetSettings, proxyUsername: e.target.value }) - } - /> - {localizedStrings['%paratextRegistration_label_proxyPassword%']} - - setInternetSettings({ ...internetSettings, proxyPassword: e.target.value }) - } - /> - - - - )} - - {localizedStrings['%paratextRegistration_label_selectedServer%']} - - -
- {!saveError && - (saveState === SaveState.IsRestarting || saveState === SaveState.HasSaved) && ( -
- - - - {localizedStrings['%paratextRegistration_alert_updatedInternetSettings%']} - - {formatSuccessAlertDescription()} - -
- )} - {saveError && ( -
- - - {localizedStrings['%general_error_title%']} - {saveError} - -
- )} - - - - -
-
+ ); }; From e2d2bc18a45b3712157b832c4674a3a9742161d9 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 12:31:50 +0200 Subject: [PATCH 06/23] split registration form into presentational view + story Separate paratext-registration's RegistrationForm into a data/logic container and a pure presentational view, so the form can be exercised in Storybook. The form's heavy logic (debounced backend validation, save + restart, and the webview-only updateWebViewDefinition title side-effect) is unsafe to move into the web view wholesale, so the container keeps it and a new RegistrationFormView holds only the JSX, fully controlled via props. - Extract RegistrationFormView (pure, props-driven): editable/read-only name + code fields, validation hint, success/error alerts, and the change / save-and-restart buttons. The code-format regex + length constants used by the inputs move here and are re-exported for the container's validation. - registration-form.component.tsx: keep all state, validation, and PAPI calls; render with the values and handlers. - Add registration-form-view.stories.tsx: InitialRegistration, ExistingRegistration, ValidRegistration, SaveError, Restarting. Verified: typecheck, extensions lint, and storybook:build all clean. Co-Authored-By: Claude Code --- .../registration-form-view.component.tsx | 220 ++++++++++++++++++ .../registration-form-view.stories.tsx | 167 +++++++++++++ .../registration-form.component.tsx | 169 +++----------- 3 files changed, 415 insertions(+), 141 deletions(-) create mode 100644 extensions/src/paratext-registration/src/components/registration-form-view.component.tsx create mode 100644 extensions/src/paratext-registration/src/components/registration-form-view.stories.tsx diff --git a/extensions/src/paratext-registration/src/components/registration-form-view.component.tsx b/extensions/src/paratext-registration/src/components/registration-form-view.component.tsx new file mode 100644 index 00000000000..d30ff6783ba --- /dev/null +++ b/extensions/src/paratext-registration/src/components/registration-form-view.component.tsx @@ -0,0 +1,220 @@ +import { AlertCircle, CircleCheck, PenIcon } from 'lucide-react'; +import { + Alert, + AlertDescription, + AlertTitle, + Button, + cn, + Input, + Spinner, +} from 'platform-bible-react'; +import type { LanguageStrings } from 'platform-bible-utils'; +import { ChangeEvent } from 'react'; +import { SaveState, scrollToRef } from '../utils'; +import { Grid } from './grid.component'; + +export const REGISTRATION_CODE_LENGTH_WITH_DASHES = 34; +export const REGISTRATION_CODE_REGEX_STRING = + '^(?:[a-zA-Z0-9]{6}-[a-zA-Z0-9]{6}-[a-zA-Z0-9]{6}-[a-zA-Z0-9]{6}-[a-zA-Z0-9]{6}|\\*{6}-\\*{6}-\\*{6}-\\*{6}-\\*{6})$'; + +export type RegistrationFormViewProps = { + /** Localized strings for the form. */ + localizedStrings: LanguageStrings; + /** Whether the form is in editing mode (inputs) vs. read-only display. */ + isEditing: boolean; + /** Current value of the registration name field. */ + name: string; + /** Current value of the registration code field. */ + registrationCode: string; + /** The persisted registration name (shown when not editing). */ + savedName: string; + /** The persisted registration code (shown when not editing). */ + savedCode: string; + /** Whether the persisted registration code is non-empty (controls the Cancel button). */ + hasSavedCode: boolean; + /** Whether the form fields are disabled (loading, saving, restarting, or not editing). */ + isFormDisabled: boolean; + /** Whether the save-and-restart button is disabled. */ + isSaveDisabled: boolean; + /** Whether to flag the registration code input as invalid (length hint). */ + showInvalidCode: boolean; + /** Progress of the save/restart flow; drives the success alert and button label. */ + saveState: SaveState; + /** Whether the registration code is currently being validated against the backend. */ + isLoading: boolean; + /** Whether the current registration is valid (drives the success alert). */ + registrationIsValid: boolean; + /** Error alert title, or empty for none. */ + error: string; + /** Error alert description. */ + errorDescription: string; + /** Called when the name input changes. */ + onNameChange: (event: ChangeEvent) => void; + /** Called when the registration code input changes. */ + onRegistrationCodeChange: (event: ChangeEvent) => void; + /** Called when the user clicks "Change" to enter editing mode. */ + onClickChange: () => void; + /** Called when the user cancels editing. */ + onCancelEditing: () => void; + /** Called when the user clicks save-and-restart. */ + onSaveAndRestart: () => void; +}; + +/** + * Presentational Paratext registration form: name/code fields (editable or read-only), validation + * hints, success/error alerts, and the change / save-and-restart buttons. All state, validation, + * and PAPI calls live in the `RegistrationForm` container; this view is fully controlled via + * props. + */ +export function RegistrationFormView({ + localizedStrings, + isEditing, + name, + registrationCode, + savedName, + savedCode, + hasSavedCode, + isFormDisabled, + isSaveDisabled, + showInvalidCode, + saveState, + isLoading, + registrationIsValid, + error, + errorDescription, + onNameChange, + onRegistrationCodeChange, + onClickChange, + onCancelEditing, + onSaveAndRestart, +}: RegistrationFormViewProps) { + const formatSuccessAlertDescription = () => { + if (saveState === SaveState.IsRestarting) { + return localizedStrings['%paratextRegistration_alert_updatedRegistration_description%']; + } + + if (saveState === SaveState.HasSaved) { + return localizedStrings[ + '%paratextRegistration_alert_updatedRegistration_description_hasRestarted%' + ]; + } + + return localizedStrings['%paratextRegistration_alert_validRegistration_description%']; + }; + + return ( +
+
+ + {!isEditing && ( + <> +

+ {localizedStrings['%paratextRegistration_label_yourRegistration%']} +

+ + + )} + + {localizedStrings['%paratextRegistration_label_registrationName%']} + + {isEditing ? ( + + ) : ( + {savedName} + )} + + {localizedStrings['%paratextRegistration_label_registrationCode%']} + + {isEditing ? ( + + ) : ( + {savedCode} + )} + + {showInvalidCode && ( +

+ {localizedStrings['%paratextRegistration_warning_invalid_registration_length%']} +

+ )} +
+ {/* UX said to remove supporter info until we are using it in P10S. Leaving here for uncommenting when the time is right */} + {/*
Please specify who provides Paratext support to you:
+ + Supporter name + setSupporter(e.target.value)} /> + */} +
+
+ {!error && + (saveState === SaveState.IsRestarting || + saveState === SaveState.HasSaved || + (!isLoading && registrationIsValid)) && ( +
+ + + + {saveState === SaveState.IsRestarting || saveState === SaveState.HasSaved + ? localizedStrings['%paratextRegistration_alert_updatedRegistration%'] + : localizedStrings['%paratextRegistration_alert_validRegistration%']} + + {formatSuccessAlertDescription()} + +
+ )} + {error && ( +
+ + + {error} + {errorDescription} + +
+ )} + + + {isEditing ? ( +
+ {hasSavedCode && ( + + )} + +
+ ) : ( + + )} +
+
+
+ ); +} + +export default RegistrationFormView; diff --git a/extensions/src/paratext-registration/src/components/registration-form-view.stories.tsx b/extensions/src/paratext-registration/src/components/registration-form-view.stories.tsx new file mode 100644 index 00000000000..f439a927b40 --- /dev/null +++ b/extensions/src/paratext-registration/src/components/registration-form-view.stories.tsx @@ -0,0 +1,167 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { ReactElement, useState } from 'react'; +import { getLocalizedStrings } from '../../../../../.storybook/localization.utils'; +import { alertCommand } from '../../../../../.storybook/story.utils'; +import { + RegistrationFormView, + RegistrationFormViewProps, +} from './registration-form-view.component'; +import { SaveState } from '../utils'; + +/** + * `RegistrationFormView` is the presentational Paratext registration form. The `RegistrationForm` + * container owns all state, debounced backend validation, and PAPI calls; this view only renders + * from props, so these stories drive it with local state and mocked callbacks. + */ + +const localizedStrings = getLocalizedStrings([ + '%general_cancel%', + '%paratextRegistration_alert_updatedRegistration%', + '%paratextRegistration_alert_updatedRegistration_description%', + '%paratextRegistration_alert_updatedRegistration_description_hasRestarted%', + '%paratextRegistration_alert_validRegistration%', + '%paratextRegistration_alert_validRegistration_description%', + '%paratextRegistration_button_change%', + '%paratextRegistration_button_restarting%', + '%paratextRegistration_button_saveAndRestart%', + '%paratextRegistration_label_registrationCode%', + '%paratextRegistration_label_registrationName%', + '%paratextRegistration_label_yourRegistration%', + '%paratextRegistration_warning_invalid_registration_length%', +]); + +const SAMPLE_CODE = 'ABC123-DEF456-GHI789-JKL012-MNO345'; + +const meta: Meta = { + title: 'Bundled Extensions/paratext-registration/RegistrationFormView', + component: RegistrationFormView, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +type DecoratorConfig = Partial< + Pick< + RegistrationFormViewProps, + | 'isEditing' + | 'savedName' + | 'savedCode' + | 'hasSavedCode' + | 'isFormDisabled' + | 'isSaveDisabled' + | 'showInvalidCode' + | 'saveState' + | 'isLoading' + | 'registrationIsValid' + | 'error' + | 'errorDescription' + > +> & { + initialName?: string; + initialCode?: string; +}; + +/** Holds the name/code fields in local state so typing works (validation lives in the container). */ +function createDecorator(config: DecoratorConfig) { + return function RegistrationFormViewDecorator( + Story: (update?: { args: RegistrationFormViewProps }) => ReactElement, + ) { + const [name, setName] = useState(config.initialName ?? ''); + const [registrationCode, setRegistrationCode] = useState(config.initialCode ?? ''); + + return ( +
+ setName(e.target.value), + onRegistrationCodeChange: (e) => setRegistrationCode(e.target.value), + onClickChange: () => alertCommand('registration.edit'), + onCancelEditing: () => alertCommand('registration.cancel'), + onSaveAndRestart: () => + alertCommand('paratextRegistration.setParatextRegistrationData', { + name, + code: registrationCode, + }), + }} + /> +
+ ); + }; +} + +/** First-time registration: empty editable fields, no saved registration to cancel back to. */ +export const InitialRegistration: Story = { + decorators: [createDecorator({ isEditing: true })], +}; + +/** An existing registration shown read-only with a Change button. */ +export const ExistingRegistration: Story = { + decorators: [ + createDecorator({ + isEditing: false, + savedName: 'Jane Translator', + savedCode: SAMPLE_CODE, + hasSavedCode: true, + }), + ], +}; + +/** Editing a valid registration code — the success alert is shown. */ +export const ValidRegistration: Story = { + decorators: [ + createDecorator({ + isEditing: true, + initialName: 'Jane Translator', + initialCode: SAMPLE_CODE, + savedName: 'Jane Translator', + savedCode: SAMPLE_CODE, + hasSavedCode: true, + registrationIsValid: true, + }), + ], +}; + +/** A save failure surfaces the error in a destructive alert. */ +export const SaveError: Story = { + decorators: [ + createDecorator({ + isEditing: true, + initialName: 'Jane Translator', + initialCode: SAMPLE_CODE, + hasSavedCode: true, + error: 'Registration failed', + errorDescription: 'The registration code is not valid for this name.', + }), + ], +}; + +/** Mid-restart: fields disabled, button shows the restarting spinner. */ +export const Restarting: Story = { + decorators: [ + createDecorator({ + isEditing: true, + initialName: 'Jane Translator', + initialCode: SAMPLE_CODE, + hasSavedCode: true, + isFormDisabled: true, + isSaveDisabled: true, + saveState: SaveState.IsRestarting, + }), + ], +}; diff --git a/extensions/src/paratext-registration/src/components/registration-form.component.tsx b/extensions/src/paratext-registration/src/components/registration-form.component.tsx index 41d7c662665..93033107150 100644 --- a/extensions/src/paratext-registration/src/components/registration-form.component.tsx +++ b/extensions/src/paratext-registration/src/components/registration-form.component.tsx @@ -1,25 +1,15 @@ import { UseWebViewStateHook } from '@papi/core'; import papi, { logger } from '@papi/frontend'; import { useLocalizedStrings } from '@papi/frontend/react'; -import { - Alert, - AlertDescription, - AlertTitle, - Button, - cn, - Input, - Spinner, - usePromise, -} from 'platform-bible-react'; +import { usePromise } from 'platform-bible-react'; import { getErrorMessage, LocalizeKey, wait } from 'platform-bible-utils'; import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; -import { AlertCircle, CircleCheck, PenIcon } from 'lucide-react'; -import { SaveState, scrollToRef } from '../utils'; -import { Grid } from './grid.component'; +import { SaveState } from '../utils'; +import { + REGISTRATION_CODE_REGEX_STRING, + RegistrationFormView, +} from './registration-form-view.component'; -const REGISTRATION_CODE_LENGTH_WITH_DASHES = 34; -const REGISTRATION_CODE_REGEX_STRING = - '^(?:[a-zA-Z0-9]{6}-[a-zA-Z0-9]{6}-[a-zA-Z0-9]{6}-[a-zA-Z0-9]{6}-[a-zA-Z0-9]{6}|\\*{6}-\\*{6}-\\*{6}-\\*{6}-\\*{6})$'; const REGISTRATION_CODE_CHARACTER_VALIDATION_REGEX = '^[a-zA-Z0-9\\-]*$'; const REGISTRATION_CODE_INSERT_DASH_REGEX_STRING = '^[a-zA-Z0-9]{6}$|-[[a-zA-Z0-9\\-]{6}$'; /** @@ -227,20 +217,6 @@ export function RegistrationForm({ useWebViewState, handleFormTypeChange }: Regi const isButtonDisabled = isFormDisabled || !hasUnsavedChanges || isLoading || !registrationIsValid || !!error; - const formatSuccessAlertDescription = () => { - if (saveState === SaveState.IsRestarting) { - return localizedStrings['%paratextRegistration_alert_updatedRegistration_description%']; - } - - if (saveState === SaveState.HasSaved) { - return localizedStrings[ - '%paratextRegistration_alert_updatedRegistration_description_hasRestarted%' - ]; - } - - return localizedStrings['%paratextRegistration_alert_validRegistration_description%']; - }; - const onEditingChange = () => { setSaveState(SaveState.HasNotSaved); setRegistrationIsValid(false); @@ -349,116 +325,27 @@ export function RegistrationForm({ useWebViewState, handleFormTypeChange }: Regi }; return ( -
-
- - {!isEditing && ( - <> -

- {localizedStrings['%paratextRegistration_label_yourRegistration%']} -

- - - )} - - {localizedStrings['%paratextRegistration_label_registrationName%']} - - {isEditing ? ( - - ) : ( - {currentRegistrationData.name} - )} - - {localizedStrings['%paratextRegistration_label_registrationCode%']} - - {isEditing ? ( - - ) : ( - {currentRegistrationData.code} - )} - - {showInvalidCode && ( -

- {localizedStrings['%paratextRegistration_warning_invalid_registration_length%']} -

- )} -
- {/* UX said to remove supporter info until we are using it in P10S. Leaving here for uncommenting when the time is right */} - {/*
Please specify who provides Paratext support to you:
- - Supporter name - setSupporter(e.target.value)} /> - */} -
-
- {!error && - (saveState === SaveState.IsRestarting || - saveState === SaveState.HasSaved || - (!isLoading && registrationIsValid)) && ( -
- - - - {saveState === SaveState.IsRestarting || saveState === SaveState.HasSaved - ? localizedStrings['%paratextRegistration_alert_updatedRegistration%'] - : localizedStrings['%paratextRegistration_alert_validRegistration%']} - - {formatSuccessAlertDescription()} - -
- )} - {error && ( -
- - - {error} - {errorDescription} - -
- )} - - - {isEditing ? ( -
- {currentRegistrationData.code !== '' && ( - - )} - -
- ) : ( - - )} -
-
-
+ ); } From d59e65a8cfa6180415032f55e6814585cd3eea23 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 12:34:35 +0200 Subject: [PATCH 07/23] Revert "Remove obsolete find-header-demo story" This reverts commit 109b4047458cbf8334889e2f670b896f8c17c0f3. --- .../find/find-header-demo.stories-helper.tsx | 393 ++++++++++++++++++ .../src/find/find-header-demo.stories.tsx | 21 + 2 files changed, 414 insertions(+) create mode 100644 extensions/src/platform-scripture/src/find/find-header-demo.stories-helper.tsx create mode 100644 extensions/src/platform-scripture/src/find/find-header-demo.stories.tsx diff --git a/extensions/src/platform-scripture/src/find/find-header-demo.stories-helper.tsx b/extensions/src/platform-scripture/src/find/find-header-demo.stories-helper.tsx new file mode 100644 index 00000000000..fd2dc8720d9 --- /dev/null +++ b/extensions/src/platform-scripture/src/find/find-header-demo.stories-helper.tsx @@ -0,0 +1,393 @@ +import { SerializedVerseRef } from '@sillsdev/scripture'; +import { + ArrowRight, + ChevronDown, + ChevronUp, + Info, + Replace, + ReplaceAll, + TextSearch, + X, +} from 'lucide-react'; +import { + Button, + Checkbox, + Input, + Label, + Popover, + PopoverContent, + PopoverTrigger, + RecentSearches, + Scope, + ScopeSelector, + SCOPE_SELECTOR_STRING_KEYS, + Spinner, + ToggleGroup, + ToggleGroupItem, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + useRecentSearches, +} from 'platform-bible-react'; +import { FindJobStatus, WordRestriction } from 'platform-scripture'; +import { formatReplacementString } from 'platform-bible-utils'; +import { SetStateAction, useEffect, useMemo, useRef, useState } from 'react'; +import { getLocalizedStrings } from '../../../../../.storybook/localization.utils'; +import { FindFilters } from './find-filters.component'; +import { SearchTextType } from './find-types'; + +const filterLocalizedStrings = getLocalizedStrings([ + '%webView_find_toggleFilters%', + '%webView_find_matchContentIn%', + '%webView_find_allText%', + '%webView_find_allText_tooltip%', + '%webView_find_verseTextOnly%', + '%webView_find_restrictions%', + '%webView_find_restrictions_none%', + '%webView_find_restrictions_wholeWord%', + '%webView_find_restrictions_startOfWord%', + '%webView_find_restrictions_endOfWord%', + '%webView_find_capitalization%', + '%webView_find_matchCase%', + '%webView_find_pattern%', + '%webView_find_allowRegex%', +]); + +const replaceLocalizedStrings = getLocalizedStrings([ + '%webView_find_replace%', + '%webView_find_replaceAll%', + '%webView_find_replaceTerm_placeholder%', + '%webView_find_preserveCase%', + '%webView_find_preserveCase_tooltip%', +]); + +const localizedStrings = getLocalizedStrings([ + '%webView_find_findTab%', + '%webView_find_replaceTab%', + '%webView_find_searchPlaceholder%', + '%webView_find_showRecentSearches%', + '%webView_find_recent%', + '%webView_find_findInProject%', + '%webView_find_showing%', + '%webView_find_previousResult%', + '%webView_find_nextResult%', +]); + +const scopeSelectorLocalizedStrings = getLocalizedStrings([...SCOPE_SELECTOR_STRING_KEYS]); +export function FindHeaderDemo() { + const [searchTerm, setSearchTerm] = useState(''); + + // custom for demo + const [verseRefSetting] = useState({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + const [scope, setScope] = useState('book'); + + const [recentSearches, setRecentSearches] = useState([]); + const addRecentSearchItem = useRecentSearches(recentSearches, setRecentSearches); + + const [selectedBookIds, setSelectedBookIds] = useState([]); + const [shouldMatchCase, setShouldMatchCase] = useState(false); + const [searchTextType, setSearchTextType] = useState('all'); + const [wordRestriction, setWordRestriction] = useState('none'); + const [isRegexAllowed, setIsRegexAllowed] = useState(false); + + const [activeMode, setActiveMode] = useState<'find' | 'replace'>('find'); + const [replaceTerm, setReplaceTerm] = useState(''); + const [preserveCase, setPreserveCase] = useState(false); + + const [searchStatus, setSearchStatus] = useState(undefined); + + // custom for demo + const [focusedResultIndex, setFocusedResultIndex] = useState(undefined); + const demoTotalResults = searchStatus === 'completed' ? 5 : 0; + + const areFiltersActive = + shouldMatchCase || wordRestriction !== 'none' || searchTextType !== 'all' || isRegexAllowed; + + const isSearchQueryValid = useMemo(() => { + if (searchTerm.trim() === '') return false; + if (scope === 'selectedBooks' && selectedBookIds.length === 0) return false; + return true; + }, [searchTerm, scope, selectedBookIds]); + + // custom for demo + const [findButtonText, setFindButtonText] = useState(''); + useEffect(() => { + const timeout = setTimeout( + () => setFindButtonText(localizedStrings['%webView_find_findTab%']), + 1000, + ); + return () => clearTimeout(timeout); + }, []); + + // custom for demo + const searchTimeoutRef = useRef | undefined>(undefined); + useEffect(() => { + return () => { + if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); + }; + }, []); + const handleStartSearch = () => { + setSearchStatus('running'); + if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); + searchTimeoutRef.current = setTimeout(() => { + setSearchStatus('completed'); + }, 1000); + + addRecentSearchItem(searchTerm); + }; + + // custom for demo + const availableBookIds = + '111111111111111111111111111111111111111111111111111111111111111111100001000000000000000000001100000000000000101000000000000'; + + // custom for demo: simplified scope display text + const scopeDisplayText = useMemo(() => { + switch (scope) { + case 'chapter': + return `${verseRefSetting.book} ${verseRefSetting.chapterNum}`; + case 'book': + return verseRefSetting.book; + case 'selectedBooks': + return selectedBookIds.length > 0 ? selectedBookIds.join(', ') : '…'; + default: + return ''; + } + }, [scope, selectedBookIds, verseRefSetting]); + + return ( +
+ {/* Find/Replace mode toggle */} + { + if (value === 'find' || value === 'replace') setActiveMode(value); + }} + className="tw:w-fit tw:rounded-lg tw:bg-muted tw:p-1" + > + + {localizedStrings['%webView_find_findTab%']} + + + {localizedStrings['%webView_find_replaceTab%']} + + + + {/* Find input row */} +
+
+ + } }) => + setSearchTerm(e.target.value) + } + onKeyDown={(e: { key: string }) => { + if (e.key === 'Enter') { + handleStartSearch(); + } + }} + placeholder={localizedStrings['%webView_find_searchPlaceholder%']} + className={`tw:w-full tw:min-w-16 tw:text-ellipsis tw:!pl-8 ${searchTerm ? 'tw:!pe-8' : 'tw:!pr-4'}`} + /> + {searchTerm && ( + + )} +
+ + + + + + + + + + +

{localizedStrings['%webView_find_findInProject%']}

+
+
+
+
+ + {/* Replace input row — shown in Replace mode */} + {activeMode === 'replace' && ( + <> +
+ + } }) => + setReplaceTerm(e.target.value) + } + placeholder={replaceLocalizedStrings['%webView_find_replaceTerm_placeholder%']} + className="tw:w-full tw:min-w-16 tw:!pl-8 tw:!pr-4" + /> +
+
+
+ setPreserveCase(checked === true)} + /> + + + + + + + +

+ {replaceLocalizedStrings['%webView_find_preserveCase_tooltip%']} +

+
+
+
+
+
+ + +
+
+ + )} + + {/* Scope selector row */} +
+ + + + + + + + + {demoTotalResults > 0 && ( +
+ + {formatReplacementString('{current} of {total}', { + current: focusedResultIndex !== undefined ? String(focusedResultIndex + 1) : '–', + total: String(demoTotalResults), + })} + + + +
+ )} +
+
+ ); +} diff --git a/extensions/src/platform-scripture/src/find/find-header-demo.stories.tsx b/extensions/src/platform-scripture/src/find/find-header-demo.stories.tsx new file mode 100644 index 00000000000..a73e68b70e4 --- /dev/null +++ b/extensions/src/platform-scripture/src/find/find-header-demo.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { FindHeaderDemo } from './find-header-demo.stories-helper'; + +const meta: Meta = { + title: 'Bundled Extensions/find/FindHeaderDemo', + component: FindHeaderDemo, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; From bbd5d04249737609fddb0dfa9d1b4bfc6e89f8e5 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 12:53:43 +0200 Subject: [PATCH 08/23] scope editor usj-nodes table styles to .usfm to stop global leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo scripture-editor stylesheet (loaded in Storybook via ten-layout-shared and the footnote/scripture-editor stories) declared bare `table`, `td`, `td.markercell`, and `rt` element selectors. Once the stylesheet loaded, those rules styled every table on the page — notably giving the Get Resources / Home tables a solid black border after an editor-based story had been viewed. Scope them to the editor's `.usfm` content root so editor tables keep their styling but unrelated UI is unaffected. The upstream editor package ships the same unscoped rules. Co-Authored-By: Claude Code --- .../components/demo/scripture-editor/usj-nodes.css | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/platform-bible-react/src/components/demo/scripture-editor/usj-nodes.css b/lib/platform-bible-react/src/components/demo/scripture-editor/usj-nodes.css index 6c875ebe16d..971ee181a47 100644 --- a/lib/platform-bible-react/src/components/demo/scripture-editor/usj-nodes.css +++ b/lib/platform-bible-react/src/components/demo/scripture-editor/usj-nodes.css @@ -2210,11 +2210,15 @@ color: #7777ff; } -table { +/* Scope these to the editor's `.usfm` content root. As bare element selectors they styled every + /
/ on the page once this stylesheet loaded, leaking into unrelated UI — e.g. + giving Storybook tables (Get Resources / Home) a solid black border after an editor story had + been viewed. The upstream editor package ships the same unscoped rules. */ +.usfm table { border-collapse: collapse; } -td { +.usfm td { border: 1px solid #000000; page-break-inside: avoid; /* FB27281 adding padding based on font size*/ @@ -2222,11 +2226,11 @@ td { padding-left: 0.28em; } -td.markercell { +.usfm td.markercell { border-style: none; } -rt { +.usfm rt { cursor: pointer; } From 5a5731a6a2743817f1c2d8cfa948f4c7b04eaa5c Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 12:54:38 +0200 Subject: [PATCH 09/23] show populated state as the default story for split webview components Storybook opens the first exported story by default, so lead with the data-present state instead of loading/empty: - comment-list: Populated first (was Loading) - registration-form-view: ExistingRegistration first (was the empty InitialRegistration) Loading/empty remain as later stories. Co-Authored-By: Claude Code --- .../src/comment-list.stories.tsx | 10 +++++----- .../src/components/registration-form-view.stories.tsx | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx b/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx index 07bd30040df..abe163a8c7c 100644 --- a/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx +++ b/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx @@ -170,16 +170,16 @@ function createDecorator(config: DecoratorConfig) { }; } -/** Threads are still loading — show skeletons. */ -export const Loading: Story = { - decorators: [createDecorator({ isLoading: true })], -}; - /** A populated, unfiltered list with an open and a resolved thread. */ export const Populated: Story = { decorators: [createDecorator({})], }; +/** Threads are still loading — show skeletons. */ +export const Loading: Story = { + decorators: [createDecorator({ isLoading: true })], +}; + /** No comments exist at all (both filters unset) — shows the "no comments" message. */ export const Empty: Story = { decorators: [createDecorator({ threads: [] })], diff --git a/extensions/src/paratext-registration/src/components/registration-form-view.stories.tsx b/extensions/src/paratext-registration/src/components/registration-form-view.stories.tsx index f439a927b40..3c8e230f8ef 100644 --- a/extensions/src/paratext-registration/src/components/registration-form-view.stories.tsx +++ b/extensions/src/paratext-registration/src/components/registration-form-view.stories.tsx @@ -105,11 +105,6 @@ function createDecorator(config: DecoratorConfig) { }; } -/** First-time registration: empty editable fields, no saved registration to cancel back to. */ -export const InitialRegistration: Story = { - decorators: [createDecorator({ isEditing: true })], -}; - /** An existing registration shown read-only with a Change button. */ export const ExistingRegistration: Story = { decorators: [ @@ -122,6 +117,11 @@ export const ExistingRegistration: Story = { ], }; +/** First-time registration: empty editable fields, no saved registration to cancel back to. */ +export const InitialRegistration: Story = { + decorators: [createDecorator({ isEditing: true })], +}; + /** Editing a valid registration code — the success alert is shown. */ export const ValidRegistration: Story = { decorators: [ From 4987988acc8f847831adb25bc2bb8fae98c4d0e4 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 14:41:08 +0200 Subject: [PATCH 10/23] rework model text panel split: orchestration in component + interactive picker story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redo the model-text-panel split so the story exercises the real component through the same interface the webview uses, with a thin in-memory backend (per the agreed pattern). The component now owns the orchestration instead of receiving a derived `status`/`usj`. - ModelTextPanel: owns resolution (configured model text → match DBL resource → auto-install → load USJ). Receives raw data as props (effectiveModelTexts, dblResources, adminModelTexts, canWriteProjectSettings, scrRef) and operations as callbacks (installResource, setAdminModelTexts, setUserModelTexts, showResourcePicker, and getResourceChapter — a callback because the resource project to read is resolved inside the component). No @papi imports. - webview: thin data-loader wiring PAPI to those props/callbacks; getResourceChapter and showResourcePicker use the imperative papi.projectDataProviders.get / papi.dialogs.showDialog APIs. - story: thin in-memory CRUD service (resources + admin/user model-text lists); renders the REAL ResourcePickerDialog inline for showResourcePicker; install flips state; settings writes are console-logged. Active story leads; NoModelText is fully interactive (pick → install → render). Verified: typecheck, extensions lint, and storybook:build all clean. Co-Authored-By: Claude Code --- .../src/model-text-panel.component.tsx | 354 +++++++++++++----- .../src/model-text-panel.stories.tsx | 231 ++++++++++-- .../src/model-text-panel.web-view.tsx | 256 ++++++------- 3 files changed, 559 insertions(+), 282 deletions(-) diff --git a/extensions/src/platform-scripture-editor/src/model-text-panel.component.tsx b/extensions/src/platform-scripture-editor/src/model-text-panel.component.tsx index 83e59d81a6b..8b64d8cea71 100644 --- a/extensions/src/platform-scripture-editor/src/model-text-panel.component.tsx +++ b/extensions/src/platform-scripture-editor/src/model-text-panel.component.tsx @@ -8,15 +8,22 @@ import { import { Usj } from '@eten-tech-foundation/scripture-utilities'; import { SerializedVerseRef } from '@sillsdev/scripture'; import { Button, Spinner } from 'platform-bible-react'; -import type { LocalizedStringValue } from 'platform-bible-utils'; -import { ComponentProps, useEffect, useMemo, useRef } from 'react'; +import type { DblResourceData, LocalizedStringValue } from 'platform-bible-utils'; +import type { + DblResourceReference, + EffectiveResourceReference, + EffectiveResourceReferenceList, + ResourceReferenceList, +} from 'platform-scripture'; +import { ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +const CURRENT_DATA_VERSION = '1.0.0'; const DEFAULT_TEXT_DIRECTION = 'ltr'; /** - * Object containing all keys used for localization in this component. If you're using this - * component in an extension, pass these keys into the Platform's localization hook and pass the - * resulting localized strings into the `localizedStrings` prop. + * Object containing all keys used for localization in this component. Pass these keys into the + * Platform's localization hook and pass the resulting localized strings into the `localizedStrings` + * prop. */ export const MODEL_TEXT_PANEL_STRING_KEYS = Object.freeze([ '%webView_modelTextPanel_installing%', @@ -31,64 +38,137 @@ type ModelTextPanelLocalizedStrings = { [key in ModelTextPanelLocalizedStringKey]?: LocalizedStringValue; }; -/** - * Which of the model-text panel's mutually-exclusive states to render. The web view computes this - * from its data sources (see `model-text-panel.web-view.tsx`) and the component renders it: - * - * - `noProject`: the panel was opened without a project id. - * - `loadingModelTexts`: still resolving which model text is configured. - * - `noModelText`: no model text is configured — show the prompt + picker button. - * - `unknownResource`: the configured resource id isn't in the DBL list. - * - `installing`: the resource is found but not yet installed. - * - `loadingText`: the resource is installed but its USJ hasn't loaded yet. - * - `active`: render the read-only Scripture editor with the model text. - */ -export type ModelTextPanelStatus = - | 'noProject' - | 'loadingModelTexts' - | 'noModelText' - | 'unknownResource' - | 'installing' - | 'loadingText' - | 'active'; +const DEFAULT_SCR_REF: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; export type ModelTextPanelProps = { /** Localized strings; import `MODEL_TEXT_PANEL_STRING_KEYS` to resolve them. */ localizedStrings: ModelTextPanelLocalizedStrings; - /** Which state to render. */ - status: ModelTextPanelStatus; - /** Called when the user clicks the "pick model text" button in the empty state. */ - onPickModelText: () => void; - /** The model text USJ to show in the editor (used by the `active` state). */ - usj?: Usj; - /** Text direction of the model text (used by the `active` state). Defaults to `ltr`. */ - textDirection?: string; - /** Current Scripture reference for the editor (used by the `active` state). */ + /** Whether the panel has a project context (opened with a project id). */ + hasProject: boolean; + /** + * The resolved ("effective") model-text references for this project, or `undefined` while still + * resolving. The first item is the configured model text. + */ + effectiveModelTexts: EffectiveResourceReferenceList | undefined; + /** Whether the effective model texts are still loading. */ + isEffectiveModelTextsLoading: boolean; + /** All DBL resources — used to match the configured model text and to feed the resource picker. */ + dblResources: DblResourceData[]; + /** The project's admin-level model-text setting (used when writing an admin choice). */ + adminModelTexts: ResourceReferenceList | undefined; + /** Whether the user may write project (admin) settings; decides admin vs. user persistence. */ + canWriteProjectSettings: boolean; + /** Current Scripture reference for the editor. */ scrRef?: SerializedVerseRef; - /** Called when the editor changes the Scripture reference (used by the `active` state). */ + /** Called when the editor changes the Scripture reference. */ onScrRefChange?: (scrRef: SerializedVerseRef) => void; - /** Logger forwarded to the editor (used by the `active` state). */ + /** + * Install a DBL resource by its entry uid (fire-and-forget; the panel re-resolves once + * installed). + */ + installResource: (dblEntryUid: string) => void; + /** Persist an admin-level model-text list. */ + setAdminModelTexts: (list: ResourceReferenceList) => void; + /** Persist a user-level model-text list. */ + setUserModelTexts: (list: ResourceReferenceList) => void | Promise; + /** + * Open the resource picker for the user to choose a model text. Resolves with the chosen + * resource, or `undefined` if the picker was cancelled. In the app this opens the + * `platform.resourcePicker` dialog; in Storybook it renders the real ResourcePickerDialog + * inline. + */ + showResourcePicker: (selectedResourceIds: string[]) => Promise; + /** + * Retrieve the resolved resource's chapter USJ and text direction. This is a callback (not a + * prop) because the resource project to read from is resolved inside this component. + */ + getResourceChapter: ( + resourceProjectId: string, + scrRef: SerializedVerseRef, + ) => Promise<{ usj: Usj | undefined; textDirection: string }>; + /** Logger forwarded to the editor (the webview supplies the PAPI logger; stories may omit it). */ logger?: ComponentProps['logger']; }; -const DEFAULT_SCR_REF: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; - /** - * Read-only panel that displays a project's configured "model text" Scripture resource. It renders - * one of several states (no project, loading, prompt-to-pick, installing, or the active editor) as - * directed by the `status` prop. This is the presentational half of the model-text-panel web view; - * the web view resolves the data and drives `status`. + * Read-only panel that displays a project's configured "model text" Scripture resource. It owns the + * orchestration (resolve the configured model text → match a DBL resource → auto-install if needed + * → load that resource's chapter USJ) so the app webview and Storybook share the same logic; only + * the data (props) and the PAPI-backed operations (callbacks) differ between them. */ export function ModelTextPanel({ localizedStrings, - status, - onPickModelText, - usj, - textDirection = DEFAULT_TEXT_DIRECTION, + hasProject, + effectiveModelTexts, + isEffectiveModelTextsLoading, + dblResources, + adminModelTexts, + canWriteProjectSettings, scrRef = DEFAULT_SCR_REF, onScrRefChange = () => {}, + installResource, + setAdminModelTexts, + setUserModelTexts, + showResourcePicker, + getResourceChapter, logger, }: ModelTextPanelProps) { + // --- Resolve the configured model text against the DBL resource list --- + + const effectiveModelText = effectiveModelTexts?.items[0]; + let dblRef: (EffectiveResourceReference & DblResourceReference) | undefined; + if (effectiveModelText?.type === 'dblResource') { + // EffectiveResourceReference union check doesn't satisfy TS discriminated-union refinement + // eslint-disable-next-line no-type-assertion/no-type-assertion + dblRef = effectiveModelText as EffectiveResourceReference & DblResourceReference; + } + const match = dblRef ? dblResources.find((r) => r.dblEntryUid === dblRef.id) : undefined; + + // Auto-install when the resource exists but isn't installed yet + const isInstalling = dblRef !== undefined && match !== undefined && !match.installed; + const matchDblEntryUid = match?.dblEntryUid; + useEffect(() => { + if (isInstalling && matchDblEntryUid !== undefined) installResource(matchDblEntryUid); + }, [isInstalling, matchDblEntryUid, installResource]); + + const resourceProjectId = match?.installed ? match.projectId : undefined; + + // --- Load the resolved resource's chapter USJ (re-fetch on resource/reference change) --- + + const [usj, setUsj] = useState(undefined); + const [textDirection, setTextDirection] = useState(DEFAULT_TEXT_DIRECTION); + // `undefined` means "not yet fetched" so we can show the loading state, matching the original. + const [isUsjLoading, setIsUsjLoading] = useState(false); + + useEffect(() => { + if (!resourceProjectId) { + setUsj(undefined); + return undefined; + } + let isActive = true; + setIsUsjLoading(true); + const load = async () => { + const { usj: nextUsj, textDirection: nextTextDirection } = await getResourceChapter( + resourceProjectId, + scrRef, + ); + if (!isActive) return; + setUsj(nextUsj); + setTextDirection(nextTextDirection || DEFAULT_TEXT_DIRECTION); + setIsUsjLoading(false); + }; + load().catch(() => { + if (!isActive) return; + setUsj(undefined); + setIsUsjLoading(false); + }); + return () => { + isActive = false; + }; + }, [resourceProjectId, scrRef, getResourceChapter]); + + // --- Editor --- + // EditorRef requires null initial value per React ref convention // eslint-disable-next-line no-null/no-null const editorRef = useRef(null); @@ -99,7 +179,8 @@ export function ModelTextPanel({ // UsjNodeOptions is a complex type; empty-object initializer requires assertion // eslint-disable-next-line no-type-assertion/no-type-assertion nodes: {} as UsjNodeOptions, - textDirection, + // Narrow the resource's (string) text-direction setting to the editor's union without a cast. + textDirection: textDirection === 'rtl' || textDirection === 'auto' ? textDirection : 'ltr', view: getDefaultViewOptions(), }), [textDirection], @@ -110,61 +191,134 @@ export function ModelTextPanel({ if (usj) editorRef.current?.setUsj(usj); }, [usj]); - switch (status) { - case 'noProject': - return ( -
-

{localizedStrings['%webView_modelTextPanel_noProject%']}

-
- ); - case 'loadingModelTexts': - return ( -
- -
- ); - case 'noModelText': - return ( -
-

{localizedStrings['%webView_modelTextPanel_emptyState_prompt%']}

- -
- ); - case 'unknownResource': - return ( -
-

{localizedStrings['%webView_modelTextPanel_unknownResource%']}

-
- ); - case 'installing': - return ( -
- - {localizedStrings['%webView_modelTextPanel_installing%']} -
- ); - case 'loadingText': - return ( -
+ // --- Resource picker / selection --- + + const currentModelTextIds = useMemo(() => { + const items = effectiveModelTexts?.items ?? []; + const dblItems = items.filter( + (r): r is EffectiveResourceReference & DblResourceReference => r.type === 'dblResource', + ); + const adminDblItems = dblItems.filter((r) => r.source === 'admin'); + const relevantItems = + adminDblItems.length > 0 ? adminDblItems : dblItems.filter((r) => r.source === 'user'); + return relevantItems.map((r) => r.id); + }, [effectiveModelTexts]); + + const handleResourceSelect = useCallback( + async (resource: DblResourceData) => { + const newRef: DblResourceReference = { + type: 'dblResource', + name: resource.displayName, + id: resource.dblEntryUid, + }; + + if (canWriteProjectSettings && adminModelTexts) { + const existingItems = adminModelTexts.items.filter((item): item is DblResourceReference => { + if (item.type !== 'dblResource') return false; + // DblResourceReference.id exists after .type check; the union still requires a cast + // eslint-disable-next-line no-type-assertion/no-type-assertion + return (item as DblResourceReference).id !== resource.dblEntryUid; + }); + setAdminModelTexts({ + dataVersion: adminModelTexts.dataVersion, + items: [newRef, ...existingItems], + }); + } else { + const currentUserDblItems = (effectiveModelTexts?.items ?? []) + .filter((r) => r.source === 'user') + .filter( + (r): r is EffectiveResourceReference & DblResourceReference => + r.type === 'dblResource' && r.id !== resource.dblEntryUid, + ); + await setUserModelTexts({ + dataVersion: CURRENT_DATA_VERSION, + items: [newRef, ...currentUserDblItems], + }); + } + }, + [ + adminModelTexts, + canWriteProjectSettings, + effectiveModelTexts, + setAdminModelTexts, + setUserModelTexts, + ], + ); + + const handlePickModelText = useCallback(async () => { + const resource = await showResourcePicker(currentModelTextIds); + if (resource) await handleResourceSelect(resource); + }, [showResourcePicker, currentModelTextIds, handleResourceSelect]); + + // --- Render the resolved state --- + + // No project: opened without a project id (expected to be brief). + if (!hasProject) { + return ( +
+

{localizedStrings['%webView_modelTextPanel_noProject%']}

+
+ ); + } + + // Zero state: no model text configured (or still loading the list). + if (!effectiveModelTexts || effectiveModelTexts.items.length === 0) { + return ( +
+ {isEffectiveModelTextsLoading ? ( -
- ); - case 'active': - default: - return ( -
- -
- ); + ) : ( + <> +

{localizedStrings['%webView_modelTextPanel_emptyState_prompt%']}

+ + + )} +
+ ); + } + + // Error state: the configured uid isn't in the DBL list at all. + if (dblRef && match === undefined) { + return ( +
+

{localizedStrings['%webView_modelTextPanel_unknownResource%']}

+
+ ); + } + + // Installing: resource found but not yet installed. + if (isInstalling) { + return ( +
+ + {localizedStrings['%webView_modelTextPanel_installing%']} +
+ ); + } + + // Loading: USJ not yet fetched for the resolved resource. + if (!resourceProjectId || (usj === undefined && isUsjLoading)) { + return ( +
+ +
+ ); } + + // Active: read-only editor showing the model text. + return ( +
+ +
+ ); } export default ModelTextPanel; diff --git a/extensions/src/platform-scripture-editor/src/model-text-panel.stories.tsx b/extensions/src/platform-scripture-editor/src/model-text-panel.stories.tsx index dd9d7dc5549..88042126c9e 100644 --- a/extensions/src/platform-scripture-editor/src/model-text-panel.stories.tsx +++ b/extensions/src/platform-scripture-editor/src/model-text-panel.stories.tsx @@ -1,17 +1,32 @@ import { usxStringToUsj } from '@eten-tech-foundation/scripture-utilities'; +import { SerializedVerseRef } from '@sillsdev/scripture'; import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { + Dialog, + DialogContent, + ResourcePickerDialog, + RESOURCE_PICKER_DIALOG_STRING_KEYS, +} from 'platform-bible-react'; +import type { DblResourceData } from 'platform-bible-utils'; +import type { + DblResourceReference, + EffectiveResourceReference, + EffectiveResourceReferenceList, +} from 'platform-scripture'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { getLocalizedStrings } from '../../../../.storybook/localization.utils'; -import { alertCommand } from '../../../../.storybook/story.utils'; import { ModelTextPanel, MODEL_TEXT_PANEL_STRING_KEYS } from './model-text-panel.component'; /** - * `ModelTextPanel` is the presentational half of the model-text panel web view. It shows a - * project's configured "model text" Scripture resource read-only, rendering whichever state the web - * view resolves: no project, loading, a prompt to pick a model text, an unknown/installing - * resource, or the active editor. The web view owns all PAPI data resolution and drives `status`. + * `ModelTextPanel` shows a project's configured "model text" Scripture resource read-only. It owns + * the orchestration (resolve configured model text → match a DBL resource → auto-install → load + * USJ); the webview feeds it PAPI in the app. These stories feed it from a thin in-memory service + * so the flow is fully interactive: pick a model text via the REAL resource picker, watch it + * install, then render in the editor. */ const localizedStrings = getLocalizedStrings([...MODEL_TEXT_PANEL_STRING_KEYS]); +const pickerStrings = getLocalizedStrings([...RESOURCE_PICKER_DIALOG_STRING_KEYS]); const sampleUsj = usxStringToUsj(` @@ -22,52 +37,204 @@ const sampleUsj = usxStringToUsj(` `); +const DATA_VERSION = '1.0.0'; + +const seedResources: DblResourceData[] = [ + { + dblEntryUid: 'uid-web', + displayName: 'WEB', + fullName: 'World English Bible', + bestLanguageName: 'English', + type: 'ScriptureResource', + size: 1200, + installed: true, + updateAvailable: false, + projectId: 'project-web', + }, + { + dblEntryUid: 'uid-asv', + displayName: 'ASV', + fullName: 'American Standard Version', + bestLanguageName: 'English', + type: 'ScriptureResource', + size: 1100, + installed: false, + updateAvailable: false, + projectId: 'project-asv', + }, +]; + +const dblRef = (resource: DblResourceData): DblResourceReference => ({ + type: 'dblResource', + id: resource.dblEntryUid, + name: resource.displayName, +}); + +type DecoratorConfig = { + /** Initially-configured model text refs (admin-level). */ + initialAdmin?: DblResourceReference[]; + /** Resource list seed. */ + resources?: DblResourceData[]; + /** Whether the panel has a project. */ + hasProject?: boolean; + /** Whether the user can write admin settings. */ + canWriteProjectSettings?: boolean; + /** Disable install so the Installing state is observable (otherwise it auto-completes). */ + disableInstall?: boolean; +}; + +/** + * Thin in-memory service container: holds the resources + the admin/user model-text lists, derives + * the effective list, mutates state on install/select so the panel updates, and wires the real + * ResourcePickerDialog for `showResourcePicker`. + */ +function ModelTextPanelHarness({ config }: { config: DecoratorConfig }) { + const [resources, setResources] = useState(config.resources ?? seedResources); + const [adminItems, setAdminItems] = useState(config.initialAdmin ?? []); + const [userItems, setUserItems] = useState([]); + const [scrRef, setScrRef] = useState({ + book: 'GEN', + chapterNum: 1, + verseNum: 1, + }); + + const effectiveModelTexts = useMemo( + () => ({ + dataVersion: DATA_VERSION, + items: [ + ...adminItems.map((r): EffectiveResourceReference => ({ ...r, source: 'admin' })), + ...userItems.map((r): EffectiveResourceReference => ({ ...r, source: 'user' })), + ], + }), + [adminItems, userItems], + ); + + // Resource picker wiring: showResourcePicker opens the real dialog and resolves on Use/cancel. + const pickerResolveRef = useRef<((r: DblResourceData | undefined) => void) | undefined>( + undefined, + ); + const [pickerOpen, setPickerOpen] = useState(false); + const [pickerSelectedIds, setPickerSelectedIds] = useState([]); + + const showResourcePicker = useCallback((selectedResourceIds: string[]) => { + setPickerSelectedIds(selectedResourceIds); + setPickerOpen(true); + return new Promise((resolve) => { + pickerResolveRef.current = resolve; + }); + }, []); + + const resolvePicker = useCallback((resource?: DblResourceData) => { + setPickerOpen(false); + pickerResolveRef.current?.(resource); + pickerResolveRef.current = undefined; + }, []); + + return ( + <> + { + if (config.disableInstall) return; + setResources((rs) => + rs.map((r) => (r.dblEntryUid === uid ? { ...r, installed: true } : r)), + ); + }} + setAdminModelTexts={(list) => { + // Settings write — log it, then reflect it so the panel updates (thin CRUD). + // eslint-disable-next-line no-console + console.log('setAdminModelTexts', list); + setAdminItems( + list.items.filter((i): i is DblResourceReference => i.type === 'dblResource'), + ); + }} + setUserModelTexts={(list) => { + // Settings write — log it, then reflect it so the panel updates (thin CRUD). + // eslint-disable-next-line no-console + console.log('setUserModelTexts', list); + setUserItems( + list.items.filter((i): i is DblResourceReference => i.type === 'dblResource'), + ); + }} + showResourcePicker={showResourcePicker} + getResourceChapter={async () => ({ usj: sampleUsj, textDirection: 'ltr' })} + /> + { + if (!open) resolvePicker(undefined); + }} + > + + resolvePicker(resource)} + /> + + + + ); +} + const meta: Meta = { title: 'Bundled Extensions/platform-scripture-editor/ModelTextPanel', component: ModelTextPanel, tags: ['autodocs'], - args: { - localizedStrings, - onPickModelText: () => alertCommand('platform.resourcePicker'), - scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, - onScrRefChange: () => {}, - }, }; export default meta; type Story = StoryObj; -/** The panel was opened without a project id. */ -export const NoProject: Story = { - args: { status: 'noProject' }, -}; +function createDecorator(config: DecoratorConfig) { + return function ModelTextPanelDecorator() { + return ; + }; +} -/** Still resolving which model text is configured. */ -export const LoadingModelTexts: Story = { - args: { status: 'loadingModelTexts' }, +/** A model text is configured and installed — the read-only editor shows it. */ +export const Active: Story = { + decorators: [createDecorator({ initialAdmin: [dblRef(seedResources[0])] })], }; -/** No model text configured yet — prompt the user to pick one. */ +/** + * No model text configured. Click "pick model text" to open the REAL resource picker; choosing the + * uninstalled ASV installs it and then renders it — fully interactive. + */ export const NoModelText: Story = { - args: { status: 'noModelText' }, + decorators: [createDecorator({})], }; -/** The configured resource id isn't present in the DBL list. */ -export const UnknownResource: Story = { - args: { status: 'unknownResource' }, +/** A configured resource that is still installing (install disabled so the state is observable). */ +export const Installing: Story = { + decorators: [createDecorator({ initialAdmin: [dblRef(seedResources[1])], disableInstall: true })], }; -/** The resource was found but is still installing. */ -export const Installing: Story = { - args: { status: 'installing' }, +/** The configured model text id isn't present in the DBL list. */ +export const UnknownResource: Story = { + decorators: [ + createDecorator({ + initialAdmin: [{ type: 'dblResource', id: 'uid-missing', name: 'Missing' }], + }), + ], }; -/** The resource is installed but its text hasn't loaded yet. */ -export const LoadingText: Story = { - args: { status: 'loadingText' }, +/** The panel was opened without a project id. */ +export const NoProject: Story = { + decorators: [createDecorator({ hasProject: false })], }; -/** The active state: a read-only Scripture editor showing the model text. */ -export const Active: Story = { - args: { status: 'active', usj: sampleUsj, textDirection: 'ltr' }, +/** A non-admin user picks a model text — it persists at the user level (logged to console). */ +export const NonAdminPick: Story = { + decorators: [createDecorator({ canWriteProjectSettings: false })], }; diff --git a/extensions/src/platform-scripture-editor/src/model-text-panel.web-view.tsx b/extensions/src/platform-scripture-editor/src/model-text-panel.web-view.tsx index 0763250c3a4..55b7079ac15 100644 --- a/extensions/src/platform-scripture-editor/src/model-text-panel.web-view.tsx +++ b/extensions/src/platform-scripture-editor/src/model-text-panel.web-view.tsx @@ -2,31 +2,30 @@ import { Usj, USJ_TYPE, USJ_VERSION } from '@eten-tech-foundation/scripture-util import type { WebViewProps } from '@papi/core'; import papi, { logger } from '@papi/frontend'; import { + useData, useDataProvider, - useDialogCallback, useLocalizedStrings, - useProjectData, useProjectDataProvider, useProjectSetting, } from '@papi/frontend/react'; +import { SerializedVerseRef } from '@sillsdev/scripture'; import { usePromise } from 'platform-bible-react'; import { - DblResourceData, formatReplacementString, getErrorMessage, isPlatformError, LocalizeKey, } from 'platform-bible-utils'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import type { DblResourceReference, EffectiveResourceReference } from 'platform-scripture'; +import type { + DblResourceReference, + EffectiveResourceReference, + ResourceReferenceList, +} from 'platform-scripture'; +import { useCallback, useEffect, useMemo } from 'react'; import { useEffectiveResourceReferenceList } from './use-effective-resource-reference-list.hook'; import { isDblResourceReference } from './resource-reference.utils'; -import { DEFAULT_RESOURCE_REFERENCE_LIST, selectTextConnection } from './select-dbl-resource'; -import { - ModelTextPanel, - ModelTextPanelStatus, - MODEL_TEXT_PANEL_STRING_KEYS, -} from './model-text-panel.component'; +import { DEFAULT_RESOURCE_REFERENCE_LIST } from './select-dbl-resource'; +import { ModelTextPanel, MODEL_TEXT_PANEL_STRING_KEYS } from './model-text-panel.component'; const DEFAULT_TEXT_DIRECTION = 'ltr'; @@ -44,6 +43,11 @@ const ALL_STRING_KEYS: LocalizeKey[] = [ '%webView_modelTextPanel_title_withResource%', ]; +/** + * Thin data-loader for the model-text panel. It wires PAPI to the props of `ModelTextPanel`, which + * owns the orchestration. Raw data is passed as props; writes and resource-dependent reads (the + * resolved resource's USJ + text direction) are passed as callbacks. + */ globalThis.webViewComponent = function ModelTextPanelWebView({ projectId, updateWebViewDefinition, @@ -53,70 +57,55 @@ globalThis.webViewComponent = function ModelTextPanelWebView({ const [scrRef, setScrRef] = useWebViewScrollGroupScrRef(); - // --- Data sources --- + // --- Raw data sources --- const [effectiveModelTexts, isEffectiveModelTextsLoading] = useEffectiveResourceReferenceList( projectId, 'platformScripture.modelTexts', ); - const [adminModelTexts, setAdminModelTexts] = useProjectSetting( + const [adminModelTextsSetting, setAdminModelTextsSetting] = useProjectSetting( projectId, 'platformScripture.modelTexts', DEFAULT_RESOURCE_REFERENCE_LIST, ); + const adminModelTexts = isPlatformError(adminModelTextsSetting) + ? undefined + : adminModelTextsSetting; const textConnectionsProvider = useProjectDataProvider( 'platformScripture.textConnectionSettings', projectId, ); - // --- DBL resource resolution --- - - const [fetchResources, setFetchResources] = useState(true); const dblResourcesProvider = useDataProvider('platformGetResources.dblResourcesProvider'); - const [resourcesPossiblyUndefined, isLoadingResources] = usePromise( - useCallback(async () => { - if (fetchResources) { - // Sets the `fetchResources` flag to false which will trigger the promise again next render - // to fetch the resources - setFetchResources(false); - return Promise.resolve(undefined); - } + const [resourcesPossiblyError] = useData( + 'platformGetResources.dblResourcesProvider', + ).DblResources(undefined, []); + const dblResources = isPlatformError(resourcesPossiblyError) ? [] : resourcesPossiblyError; - return papi.commands.sendCommand('platformGetResources.getCachedResources'); - }, [fetchResources]), - undefined, + const [canWriteProjectSettings] = usePromise( + useCallback( + async () => + (await textConnectionsProvider?.canUserWriteProjectTextConnectionSettings()) ?? false, + [textConnectionsProvider], + ), + false, ); - const dblResources = resourcesPossiblyUndefined ?? []; + + // --- Dynamic title: "Model text: {displayName}" when a resource is loaded --- + // Computed inline (rather than in the presentational component) because updateWebViewDefinition + // is a webview-only API. const effectiveModelText = effectiveModelTexts?.items[0]; - // EffectiveResourceReference is a discriminated union; isDblResourceReference narrows it let dblRef: (EffectiveResourceReference & DblResourceReference) | undefined; if (isDblResourceReference(effectiveModelText)) { dblRef = effectiveModelText; } - const match = dblRef ? dblResources.find((r) => r.dblEntryUid === dblRef.id) : undefined; - - // Auto-install when the resource exists but isn't installed yet - const isInstalling = dblRef !== undefined && match !== undefined && !match.installed; - const matchDblEntryUid = match?.dblEntryUid; - useEffect(() => { - if (!fetchResources && isInstalling && dblResourcesProvider && matchDblEntryUid !== undefined) { - setFetchResources(true); - dblResourcesProvider - .installDblResource(matchDblEntryUid) - .catch((e: unknown) => - logger.error(`Model text auto-install failed: ${getErrorMessage(e)}`), - ); - } - }, [isInstalling, fetchResources, dblResourcesProvider, matchDblEntryUid]); - - const resourceProjectId = match?.installed ? match.projectId : undefined; - - // --- Dynamic title: "Model text: {displayName}" when a resource is loaded --- - - const modelTextSmallName = match?.installed ? match.displayName : undefined; + const matchedInstalledResource = dblRef + ? dblResources.find((r) => r.dblEntryUid === dblRef.id && r.installed) + : undefined; + const modelTextSmallName = matchedInstalledResource?.displayName; useEffect(() => { const baseTitle = localizedStrings['%webView_modelTextPanel_title%']; if (!baseTitle) return; @@ -130,119 +119,86 @@ globalThis.webViewComponent = function ModelTextPanelWebView({ } }, [modelTextSmallName, localizedStrings, updateWebViewDefinition]); - // --- USJ from the resolved resource project --- - - const [usjPossiblyError] = useProjectData( - 'platformScripture.USJ_Chapter', - resourceProjectId, - ).ChapterUSJ( - useMemo( - () => ({ - book: scrRef.book, - chapterNum: scrRef.chapterNum, - verseNum: 1, - versificationStr: scrRef.versificationStr, - }), - [scrRef.book, scrRef.chapterNum, scrRef.versificationStr], - ), - defaultUsj, - ); - - const usjFromPdp = !isPlatformError(usjPossiblyError) ? usjPossiblyError : undefined; + // --- Operation callbacks --- - // --- Text direction from the resource project --- + const installResource = useCallback( + (dblEntryUid: string) => { + dblResourcesProvider + ?.installDblResource(dblEntryUid) + .catch((e: unknown) => + logger.error(`Model text auto-install failed: ${getErrorMessage(e)}`), + ); + }, + [dblResourcesProvider], + ); - const [textDirectionPossiblyError] = useProjectSetting( - resourceProjectId, - 'platform.textDirection', - DEFAULT_TEXT_DIRECTION, + const setAdminModelTexts = useCallback( + (list: ResourceReferenceList) => { + setAdminModelTextsSetting?.(list); + }, + [setAdminModelTextsSetting], ); - const textDirection = useMemo(() => { - if (isPlatformError(textDirectionPossiblyError)) return DEFAULT_TEXT_DIRECTION; - return textDirectionPossiblyError || DEFAULT_TEXT_DIRECTION; - }, [textDirectionPossiblyError]); - - // --- Resource picker --- - - const currentModelTextIds = useMemo(() => { - const items = effectiveModelTexts?.items ?? []; - const dblItems = items.filter((r) => isDblResourceReference(r)); - const adminDblItems = dblItems.filter((r) => r.source === 'admin'); - const relevantItems = - adminDblItems.length > 0 ? adminDblItems : dblItems.filter((r) => r.source === 'user'); - return relevantItems.map((r) => r.id); - }, [effectiveModelTexts]); - - const handleResourceSelect = useCallback( - (resource: DblResourceData) => - selectTextConnection( - resource, - adminModelTexts, - setAdminModelTexts, - textConnectionsProvider - ? () => textConnectionsProvider.canUserWriteProjectTextConnectionSettings() - : undefined, - textConnectionsProvider ? () => textConnectionsProvider.getUserModelTexts() : undefined, - textConnectionsProvider - ? (list) => textConnectionsProvider.setUserModelTexts(list) - : undefined, - ), - [adminModelTexts, setAdminModelTexts, textConnectionsProvider], + + const setUserModelTexts = useCallback( + async (list: ResourceReferenceList) => { + await textConnectionsProvider?.setUserModelTexts(list); + }, + [textConnectionsProvider], ); - const showResourcePicker = useDialogCallback( - 'platform.resourcePicker', - useMemo( - () => ({ resourceType: 'ScriptureResource', selectedResourceIds: currentModelTextIds }), - [currentModelTextIds], - ), - useCallback( - (resource: DblResourceData | undefined) => { - if (!resource) return; - handleResourceSelect(resource).catch((e) => - logger.error(`Model text selection failed: ${getErrorMessage(e)}`), - ); - }, - [handleResourceSelect], - ), + const showResourcePicker = useCallback( + (selectedResourceIds: string[]) => + papi.dialogs.showDialog('platform.resourcePicker', { + resourceType: 'ScriptureResource', + selectedResourceIds, + }), + [], ); - // --- Resolve which mutually-exclusive state to render --- - // Mirrors original priority order: no project → loading/empty → unknown → installing → - // loading text → active. - - let status: ModelTextPanelStatus; - if (!projectId) { - // It's expected this isn't shown long; the `platform-scripture-editor` extension will show - // the most recent project (or the picked project). - status = 'noProject'; - } else if (!effectiveModelTexts || effectiveModelTexts.items.length === 0) { - status = isEffectiveModelTextsLoading ? 'loadingModelTexts' : 'noModelText'; - } else if (isLoadingResources) { - // PT-3991: a model text is configured but the DBL resource list is still loading — show a - // spinner instead of falling through to 'unknownResource' (which would happen because the - // empty dblResources array yields match === undefined). - status = 'loadingModelTexts'; - } else if (dblRef && match === undefined) { - status = 'unknownResource'; - } else if (isInstalling) { - status = 'installing'; - } else if (!resourceProjectId || usjPossiblyError === undefined) { - // usjPossiblyError is undefined while the subscription is initializing - status = 'loadingText'; - } else { - status = 'active'; - } + const getResourceChapter = useCallback( + async (resourceProjectId: string, ref: SerializedVerseRef) => { + const usjPdp = await papi.projectDataProviders.get( + 'platformScripture.USJ_Chapter', + resourceProjectId, + ); + const usj = + (await usjPdp.getChapterUSJ({ + book: ref.book, + chapterNum: ref.chapterNum, + verseNum: 1, + versificationStr: ref.versificationStr, + })) ?? defaultUsj; + + let textDirection: string = DEFAULT_TEXT_DIRECTION; + try { + const basePdp = await papi.projectDataProviders.get('platform.base', resourceProjectId); + const td = await basePdp.getSetting('platform.textDirection'); + if (typeof td === 'string' && td) textDirection = td; + } catch (e) { + logger.warn(`Failed to read model text direction: ${getErrorMessage(e)}`); + } + + return { usj, textDirection }; + }, + [], + ); return ( showResourcePicker()} - usj={usjFromPdp} - textDirection={textDirection} + hasProject={projectId !== undefined} + effectiveModelTexts={effectiveModelTexts} + isEffectiveModelTextsLoading={isEffectiveModelTextsLoading} + dblResources={dblResources} + adminModelTexts={adminModelTexts} + canWriteProjectSettings={canWriteProjectSettings} scrRef={scrRef} onScrRefChange={setScrRef} + installResource={installResource} + setAdminModelTexts={setAdminModelTexts} + setUserModelTexts={setUserModelTexts} + showResourcePicker={showResourcePicker} + getResourceChapter={getResourceChapter} logger={logger} /> ); From 22ddce9a431f1bdae17b689b62e10c51def9b51a Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 14:52:36 +0200 Subject: [PATCH 11/23] split dictionary webview into presentational component + interactive story Apply the webview-split pattern to platform-lexical-tools' dictionary: a new Dictionary presentational component owns the orchestration (scope/search/selection state, text filtering, single-entry-vs-list derivation) and the webview becomes a thin data-loader. - Dictionary component (pure, no @papi): renders the scope/search header, loading/ error/no-results states, and DictionaryEntryDisplay or DictionaryList. Dependent reads are callbacks (getEntries, getFullEntry) run in effects. - Lift the internal useLocalizedStrings out of dictionary-entry-display and dictionary-list into a localizedStrings prop; make dictionary-list-item's @papi/core import a type-only import so the render tree is @papi-free. - webview: thin loader; getEntries/getFullEntry via the imperative papi.dataProviders.get('platformLexicalTools.lexicalReferenceService') API; scrRef from the scroll group. - story: thin in-memory service with seed entries; getEntries filters by scope; occurrence-nav announced via alertCommand. EntryList leads; SingleEntry, Loading, NoResults, DataError follow. Verified: typecheck, extensions lint, and storybook:build all clean. Co-Authored-By: Claude Code --- .../dictionary-entry-display.component.tsx | 8 +- .../dictionary-list-item.component.tsx | 2 +- .../dictionary/dictionary-list.component.tsx | 10 +- .../src/web-views/dictionary.component.tsx | 283 ++++++++++++++++ .../src/web-views/dictionary.stories.tsx | 238 ++++++++++++++ .../src/web-views/dictionary.web-view.tsx | 301 ++++-------------- 6 files changed, 592 insertions(+), 250 deletions(-) create mode 100644 extensions/src/platform-lexical-tools/src/web-views/dictionary.component.tsx create mode 100644 extensions/src/platform-lexical-tools/src/web-views/dictionary.stories.tsx diff --git a/extensions/src/platform-lexical-tools/src/components/dictionary/dictionary-entry-display.component.tsx b/extensions/src/platform-lexical-tools/src/components/dictionary/dictionary-entry-display.component.tsx index 956fa430cd1..af7d83e78e1 100644 --- a/extensions/src/platform-lexical-tools/src/components/dictionary/dictionary-entry-display.component.tsx +++ b/extensions/src/platform-lexical-tools/src/components/dictionary/dictionary-entry-display.component.tsx @@ -11,10 +11,8 @@ import { import { ChevronUpIcon } from 'lucide-react'; import { Entry, Sense } from 'platform-lexical-tools'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useLocalizedStrings } from '@papi/frontend/react'; import { SerializedVerseRef } from '@sillsdev/scripture'; -import { formatReplacementString, formatScrRef } from 'platform-bible-utils'; -import { DICTIONARY_LOCALIZED_STRING_KEYS } from '../../utils/dictionary-ui.utils'; +import { formatReplacementString, formatScrRef, LanguageStrings } from 'platform-bible-utils'; import { getFormatGlossesStringFromDictionaryEntrySenses, getDeduplicatedOccurrencesFromSenses, @@ -25,6 +23,8 @@ import { BackToListButton } from './back-to-list-button.component'; /** Props for the DictionaryEntryDisplay component */ export type DictionaryEntryDisplayProps = { + /** Localized strings for the dictionary; resolve via `DICTIONARY_LOCALIZED_STRING_KEYS`. */ + localizedStrings: LanguageStrings; /** Dictionary entry object to display */ dictionaryEntry: Entry; /** Whether the display is in a drawer or just next to the list */ @@ -45,6 +45,7 @@ export type DictionaryEntryDisplayProps = { * back button to navigate back to the list view. */ export function DictionaryEntryDisplay({ + localizedStrings, dictionaryEntry, isDrawer, handleBackToListButton, @@ -52,7 +53,6 @@ export function DictionaryEntryDisplay({ onSelectOccurrence, onClickScrollToTop, }: DictionaryEntryDisplayProps) { - const [localizedStrings] = useLocalizedStrings(DICTIONARY_LOCALIZED_STRING_KEYS); const [selectedSense, setSelectedSense] = useState(); const [selectedSenseIndex, setSelectedSenseIndex] = useState(); const [occurrenceView, setOccurrenceView] = useState('chapter'); diff --git a/extensions/src/platform-lexical-tools/src/components/dictionary/dictionary-list-item.component.tsx b/extensions/src/platform-lexical-tools/src/components/dictionary/dictionary-list-item.component.tsx index c887b3f50d9..9f4d91caff2 100644 --- a/extensions/src/platform-lexical-tools/src/components/dictionary/dictionary-list-item.component.tsx +++ b/extensions/src/platform-lexical-tools/src/components/dictionary/dictionary-list-item.component.tsx @@ -1,4 +1,4 @@ -import { LocalizationData } from '@papi/core'; +import type { LocalizationData } from '@papi/core'; import { SerializedVerseRef } from '@sillsdev/scripture'; import { cn, diff --git a/extensions/src/platform-lexical-tools/src/components/dictionary/dictionary-list.component.tsx b/extensions/src/platform-lexical-tools/src/components/dictionary/dictionary-list.component.tsx index fb4f0c0e2ba..49b253fc4e6 100644 --- a/extensions/src/platform-lexical-tools/src/components/dictionary/dictionary-list.component.tsx +++ b/extensions/src/platform-lexical-tools/src/components/dictionary/dictionary-list.component.tsx @@ -7,11 +7,11 @@ import { useListbox, type ListboxOption, } from 'platform-bible-react'; -import { useLocalizedStrings } from '@papi/frontend/react'; import { RefObject, useRef } from 'react'; import { SerializedVerseRef } from '@sillsdev/scripture'; +import { LanguageStrings } from 'platform-bible-utils'; import { DictionaryEntryDisplay } from './dictionary-entry-display.component'; -import { DICTIONARY_LOCALIZED_STRING_KEYS, useIsWideScreen } from '../../utils/dictionary-ui.utils'; +import { useIsWideScreen } from '../../utils/dictionary-ui.utils'; import { DictionaryScope } from '../../utils/dictionary.utils'; import { DictionaryListItem } from './dictionary-list-item.component'; @@ -21,6 +21,8 @@ function getEntryId(entry: Entry): string { /** Props for the DictionaryList component */ type DictionaryListProps = { + /** Localized strings for the dictionary; resolve via `DICTIONARY_LOCALIZED_STRING_KEYS`. */ + localizedStrings: LanguageStrings; /** Array of dictionary entries */ dictionaryData: Entry[]; /** Scripture reference to filter the dictionary entries by */ @@ -57,6 +59,7 @@ type DictionaryListProps = { * handle keyboard navigation of the list. */ export function DictionaryList({ + localizedStrings, dictionaryData, scriptureReferenceToFilterBy, scope, @@ -66,7 +69,6 @@ export function DictionaryList({ onEntrySelected, fullSelectedEntry, }: DictionaryListProps) { - const [localizedStrings] = useLocalizedStrings(DICTIONARY_LOCALIZED_STRING_KEYS); const isWideScreen = useIsWideScreen(); const selectedEntryId = selectedEntry ? getEntryId(selectedEntry) : undefined; @@ -135,6 +137,7 @@ export function DictionaryList({ (isWideScreen ? (
void; + /** + * Retrieve the entries for the current scope and Scripture reference. This is a callback (not a + * prop) because the read depends on values (`scope`, `scrRef`) resolved inside this component. + * Implementations should resolve a {@link PlatformError} into the `error` field. + */ + getEntries: ( + scope: DictionaryScope, + scrRef: SerializedVerseRef, + ) => Promise; + /** + * Retrieve the full (unfiltered) data for a displayed entry, used to show all occurrences across + * Scripture. This is a callback because the entry to fetch is resolved inside this component. + */ + getFullEntry: (entry: Entry) => Promise; +}; + +/** + * Presentational dictionary panel. It owns the orchestration (scope/search/selection state, the + * search-text filtering, and the "single entry vs. list" derivation) so the app webview and + * Storybook share the same logic; only the data (props) and the PAPI-backed reads (callbacks) + * differ between them. + */ +export function Dictionary({ + localizedStrings, + scrRef = DEFAULT_SCR_REF, + onSelectOccurrence, + getEntries, + getFullEntry, +}: DictionaryProps) { + const [scope, setScope] = useState('chapter'); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedEntry, setSelectedEntry] = useState(undefined); + + // ref.current expects null and not undefined when we pass it to the search input + // eslint-disable-next-line no-null/no-null + const searchInputRef = useRef(null); + // ref.current expects null and not undefined when we pass it to the div + // eslint-disable-next-line no-null/no-null + const dictionaryEntryRef = useRef(null); + + const scrollToTop = () => { + dictionaryEntryRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + // --- Load entries for the current scope/reference (verse reference filtering is done by the + // data source; only search-text filtering happens here) --- + + const [entriesById, setEntriesById] = useState(undefined); + const [entriesError, setEntriesError] = useState(undefined); + + useEffect(() => { + let isActive = true; + setEntriesById(undefined); + setEntriesError(undefined); + const load = async () => { + const result = await getEntries(scope, scrRef); + if (!isActive) return; + if (result.error) { + setEntriesError(result.error); + setEntriesById({}); + } else { + setEntriesById(result.entriesById); + } + }; + load().catch(() => { + if (!isActive) return; + setEntriesById({}); + }); + return () => { + isActive = false; + }; + }, [getEntries, scope, scrRef]); + + // `undefined` means "not yet fetched" so we can show the loading state, matching the original. + const isLoadingEntriesById = !entriesError && entriesById === undefined; + + // Return all defined entries filtered by searchQuery + const entriesFiltered = useMemo(() => { + if (!entriesById) return []; + + // Filter entries by searchQuery (verse reference filtering is now done on backend) + const search = searchQuery.toLowerCase(); + return Object.values(entriesById ?? {}) + .flat() + .filter((entry): entry is Entry => { + if (!entry) return false; + const matchesSearch = + entry.lemma.toLowerCase().includes(search) || + entry.strongsCodes.some((code) => code.toLowerCase().includes(search)) || + getFormatGlossesStringFromDictionaryEntrySenses(entry, scrRef) + .toLowerCase() + .includes(search); + return matchesSearch; + }); + }, [entriesById, searchQuery, scrRef]); + + // Clear the selected entry if it is no longer in the filtered list (e.g. after a chapter change) + useEffect(() => { + if ( + selectedEntry && + !entriesFiltered.some( + (e) => + e.id === selectedEntry.id && + e.lexicalReferenceTextId === selectedEntry.lexicalReferenceTextId, + ) + ) { + setSelectedEntry(undefined); + } + }, [entriesFiltered, selectedEntry]); + + // Entry displayed in DictionaryEntryDisplay: always the single entry, or whatever is selected in the list + const displayedEntry = entriesFiltered.length === 1 ? entriesFiltered[0] : selectedEntry; + + // Fetch the full (unfiltered) data for the displayed entry + const [fullDisplayedEntry, setFullDisplayedEntry] = useState(undefined); + + useEffect(() => { + if (!displayedEntry) { + setFullDisplayedEntry(undefined); + return undefined; + } + let isActive = true; + const load = async () => { + const entry = await getFullEntry(displayedEntry); + if (isActive) setFullDisplayedEntry(entry); + }; + load().catch(() => { + if (isActive) setFullDisplayedEntry(undefined); + }); + return () => { + isActive = false; + }; + }, [getFullEntry, displayedEntry]); + + const onCharacterPress = useCallback((character: string) => { + searchInputRef.current?.focus(); + setSearchQuery(character); + }, []); + + // TODO: Implement project selection when lexical data from scripture projects available + // const handleSelectProject = useCallback( + // (newProjectId: string) => { + // updateWebViewDefinition({ + // projectId: newProjectId, + // }); + // }, + // [updateWebViewDefinition], + // ); + + return ( +
+
+
+
+ +
+
+ +
+
+
+ {isLoadingEntriesById && ( +
+ {[...Array(10)].map((_, index) => ( + + ))} +
+ )} + {entriesError && ( +
+ + + + +
+ )} + {entriesFiltered.length === 0 && !isLoadingEntriesById && !entriesError && ( +
+ +
+ )} + {entriesFiltered.length === 1 && ( +
+ +
+ )} + {entriesFiltered.length > 1 && ( + + )} +
+ ); +} + +export default Dictionary; diff --git a/extensions/src/platform-lexical-tools/src/web-views/dictionary.stories.tsx b/extensions/src/platform-lexical-tools/src/web-views/dictionary.stories.tsx new file mode 100644 index 00000000000..582ddd5be58 --- /dev/null +++ b/extensions/src/platform-lexical-tools/src/web-views/dictionary.stories.tsx @@ -0,0 +1,238 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { SerializedVerseRef } from '@sillsdev/scripture'; +import type { Entry, LexicalEntriesById } from 'platform-lexical-tools'; +import { useCallback, useState } from 'react'; +import { getLocalizedStrings } from '../../../../../.storybook/localization.utils'; +import { alertCommand, rejectingMock } from '../../../../../.storybook/story.utils'; +import { Dictionary, DictionaryEntriesResult } from './dictionary.component'; +import { DICTIONARY_LOCALIZED_STRING_KEYS } from '../utils/dictionary-ui.utils'; +import { DictionaryScope } from '../utils/dictionary.utils'; + +/** + * `Dictionary` shows lexical reference entries for the current Scripture reference. It owns the + * orchestration (scope/search/selection and search-text filtering); the webview feeds it PAPI in + * the app. These stories feed it from a thin in-memory service so the flow is fully interactive: + * change scope, search, select an entry, and follow an occurrence (which announces the navigation + * command). + */ + +const localizedStrings = getLocalizedStrings([...DICTIONARY_LOCALIZED_STRING_KEYS]); + +const DEFAULT_SCR_REF: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + +const VERSION = '1.0.0'; + +/** Seed entries that all occur in GEN 1 (verse 1 unless noted), so the chapter scope shows them. */ +const seedEntries: Entry[] = [ + { + id: 'H7225', + lexicalReferenceTextId: 'SDBH', + lexicalReferenceTextVersion: VERSION, + lemma: 'רֵאשִׁית', + strongsCodes: ['H7225'], + domains: [{ taxonomy: 'Lexical Semantic Domains', code: '001', label: 'Time' }], + occurrences: { + WLC: [{ verseRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, wordNum: 1 }], + }, + senses: { + s1: { + id: 's1', + entryId: 'H7225', + lexicalReferenceTextId: 'SDBH', + lexicalReferenceTextVersion: VERSION, + bcp47Code: 'en', + definition: 'The first or beginning part of something.', + glosses: ['beginning', 'first'], + strongsCodes: ['H7225'], + domains: [{ taxonomy: 'Lexical Semantic Domains', code: '001', label: 'Time' }], + occurrences: { + WLC: [{ verseRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, wordNum: 1 }], + }, + }, + }, + }, + { + id: 'H430', + lexicalReferenceTextId: 'SDBH', + lexicalReferenceTextVersion: VERSION, + lemma: 'אֱלֹהִים', + strongsCodes: ['H430'], + domains: [{ taxonomy: 'Lexical Semantic Domains', code: '002', label: 'Deity' }], + occurrences: { + WLC: [{ verseRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, wordNum: 3 }], + }, + senses: { + s1: { + id: 's1', + entryId: 'H430', + lexicalReferenceTextId: 'SDBH', + lexicalReferenceTextVersion: VERSION, + bcp47Code: 'en', + definition: 'The supreme deity; God.', + glosses: ['God', 'gods'], + strongsCodes: ['H430'], + domains: [{ taxonomy: 'Lexical Semantic Domains', code: '002', label: 'Deity' }], + occurrences: { + WLC: [{ verseRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, wordNum: 3 }], + }, + }, + s2: { + id: 's2', + entryId: 'H430', + lexicalReferenceTextId: 'SDBH', + lexicalReferenceTextVersion: VERSION, + bcp47Code: 'en', + definition: 'Members of a divine class; (other) gods.', + glosses: ['gods (plural)'], + strongsCodes: ['H430'], + domains: [{ taxonomy: 'Lexical Semantic Domains', code: '002', label: 'Deity' }], + occurrences: { + WLC: [{ verseRef: { book: 'GEN', chapterNum: 1, verseNum: 26 }, wordNum: 5 }], + }, + }, + }, + }, + { + id: 'H1254', + lexicalReferenceTextId: 'SDBH', + lexicalReferenceTextVersion: VERSION, + lemma: 'בָּרָא', + strongsCodes: ['H1254'], + domains: [{ taxonomy: 'Lexical Semantic Domains', code: '003', label: 'Creation' }], + occurrences: { + WLC: [{ verseRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, wordNum: 2 }], + }, + senses: { + s1: { + id: 's1', + entryId: 'H1254', + lexicalReferenceTextId: 'SDBH', + lexicalReferenceTextVersion: VERSION, + bcp47Code: 'en', + definition: 'To bring something into existence.', + glosses: ['create', 'created'], + strongsCodes: ['H1254'], + domains: [{ taxonomy: 'Lexical Semantic Domains', code: '003', label: 'Creation' }], + occurrences: { + WLC: [{ verseRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, wordNum: 2 }], + }, + }, + }, + }, +]; + +/** Whether an entry has any occurrence matching the scope-filtered selector. */ +function entryMatchesScope( + entry: Entry, + scope: DictionaryScope, + scrRef: SerializedVerseRef, +): boolean { + return Object.values(entry.senses).some((sense) => + Object.values(sense?.occurrences ?? {}).some((occurrences) => + occurrences?.some( + (occurrence) => + occurrence.verseRef.book === scrRef.book && + occurrence.verseRef.chapterNum === scrRef.chapterNum && + (scope !== 'verse' || occurrence.verseRef.verseNum === scrRef.verseNum), + ), + ), + ); +} + +type HarnessConfig = { + /** Seed entries the in-memory service serves. Defaults to {@link seedEntries}. */ + entries?: Entry[]; + /** Force the loading state by never resolving the entries read. */ + neverResolve?: boolean; + /** Force a data-load error by rejecting the entries read. */ + failLoad?: boolean; +}; + +/** + * Thin in-memory service container: serves the seed entries scoped to the current reference, + * returns the full entry on demand, and announces occurrence navigation via `alertCommand`. + */ +function DictionaryHarness({ config }: { config: HarnessConfig }) { + const entries = config.entries ?? seedEntries; + const [scrRef, setScrRef] = useState(DEFAULT_SCR_REF); + + const getEntries = useCallback( + async (scope: DictionaryScope, ref: SerializedVerseRef): Promise => { + if (config.neverResolve) { + return new Promise(() => {}); + } + if (config.failLoad) { + await rejectingMock('The lexical reference text could not be read.')(); + } + const entriesById: LexicalEntriesById = {}; + entries + .filter((entry) => entryMatchesScope(entry, scope, ref)) + .forEach((entry) => { + entriesById[entry.id] = [entry]; + }); + return { entriesById }; + }, + [config.failLoad, config.neverResolve, entries], + ); + + const getFullEntry = useCallback( + async (entry: Entry) => entries.find((e) => e.id === entry.id), + [entries], + ); + + const onSelectOccurrence = useCallback((scrRefOfOccurrence: SerializedVerseRef) => { + // In the app the webview navigates the scroll group; here we announce it and update local state. + alertCommand('platformScriptureEditor.selectRange', { verseRef: scrRefOfOccurrence }); + setScrRef(scrRefOfOccurrence); + }, []); + + return ( + + ); +} + +const meta: Meta = { + title: 'Bundled Extensions/platform-lexical-tools/Dictionary', + component: Dictionary, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +function createDecorator(config: HarnessConfig) { + return function DictionaryDecorator() { + return ; + }; +} + +/** Multiple entries occur in GEN 1 — the list renders; select one to see its detail. */ +export const EntryList: Story = { + decorators: [createDecorator({})], +}; + +/** Exactly one entry matches — the detail view renders directly without the list. */ +export const SingleEntry: Story = { + decorators: [createDecorator({ entries: [seedEntries[0]] })], +}; + +/** Entries are still loading — the skeleton placeholders render. */ +export const Loading: Story = { + decorators: [createDecorator({ neverResolve: true })], +}; + +/** No entries match the current reference — the no-results message renders. */ +export const NoResults: Story = { + decorators: [createDecorator({ entries: [] })], +}; + +/** The entries read failed — the error popover surfaces the data-load error. */ +export const DataError: Story = { + decorators: [createDecorator({ failLoad: true })], +}; diff --git a/extensions/src/platform-lexical-tools/src/web-views/dictionary.web-view.tsx b/extensions/src/platform-lexical-tools/src/web-views/dictionary.web-view.tsx index 7ad2ac80528..796dd66efbe 100644 --- a/extensions/src/platform-lexical-tools/src/web-views/dictionary.web-view.tsx +++ b/extensions/src/platform-lexical-tools/src/web-views/dictionary.web-view.tsx @@ -1,146 +1,26 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - Button, - ErrorPopover, - Label, - SearchBar, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Skeleton, -} from 'platform-bible-react'; -import { useData, useLocalizedStrings } from '@papi/frontend/react'; +import { useCallback, useMemo } from 'react'; +import { useLocalizedStrings } from '@papi/frontend/react'; +import papi, { logger } from '@papi/frontend'; import { WebViewProps } from '@papi/core'; -import { Entry, LexicalEntriesById, LexicalReferenceSelector } from 'platform-lexical-tools'; +import { Entry, LexicalReferenceSelector } from 'platform-lexical-tools'; import { SerializedVerseRef } from '@sillsdev/scripture'; -import { logger } from '@papi/frontend'; import { getErrorMessage, isPlatformError } from 'platform-bible-utils'; -import { DictionaryEntryDisplay } from '../components/dictionary/dictionary-entry-display.component'; +import { Dictionary } from './dictionary.component'; import { DICTIONARY_LOCALIZED_STRING_KEYS } from '../utils/dictionary-ui.utils'; -import { - DictionaryScope, - getFormatGlossesStringFromDictionaryEntrySenses, -} from '../utils/dictionary.utils'; -import { DictionaryList } from '../components/dictionary/dictionary-list.component'; - -const ENTRIES_DEFAULT: LexicalEntriesById = {}; - -globalThis.webViewComponent = function Dictionary({ +import { DictionaryScope } from '../utils/dictionary.utils'; + +/** + * Thin data-loader for the dictionary. It wires PAPI to the props of `Dictionary`, which owns the + * orchestration (scope/search/selection state and the search-text filtering). Scope/reference- and + * entry-dependent reads are passed as imperative callbacks. + */ +globalThis.webViewComponent = function DictionaryWebView({ useWebViewScrollGroupScrRef, - useWebViewState, }: WebViewProps) { - const [scope, setScope] = useWebViewState('scope', 'chapter'); - const [searchQuery, setSearchQuery] = useWebViewState('searchQuery', ''); - const [localizedStrings] = useLocalizedStrings(DICTIONARY_LOCALIZED_STRING_KEYS); - const [scrRef, setScrRef] = useWebViewScrollGroupScrRef(); - const [selectedEntry, setSelectedEntry] = useState(undefined); - - // ref.current expects null and not undefined when we pass it to the search input - // eslint-disable-next-line no-null/no-null - const searchInputRef = useRef(null); - // ref.current expects null and not undefined when we pass it to the div - // eslint-disable-next-line no-null/no-null - const dictionaryEntryRef = useRef(null); - - const scrollToTop = () => { - dictionaryEntryRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); - }; - - // Create selector for filtering entries by verse reference - const selector: LexicalReferenceSelector = useMemo( - () => ({ - book: scrRef.book, - chapterNum: scrRef.chapterNum, - ...(scope === 'verse' && { verseNum: scrRef.verseNum }), - }), - [scrRef.book, scrRef.chapterNum, scrRef.verseNum, scope], + const [localizedStrings] = useLocalizedStrings( + useMemo(() => DICTIONARY_LOCALIZED_STRING_KEYS, []), ); - - const [entriesByIdPossiblyError] = useData( - 'platformLexicalTools.lexicalReferenceService', - ).EntriesById(selector, ENTRIES_DEFAULT); - - const entriesError = useMemo(() => { - if (!isPlatformError(entriesByIdPossiblyError)) return undefined; - return entriesByIdPossiblyError; - }, [entriesByIdPossiblyError]); - - useEffect(() => { - if (entriesError) { - logger.error(`Error getting entries by ID: ${getErrorMessage(entriesError)}`); - } - }, [entriesError]); - - const entriesById: LexicalEntriesById = useMemo(() => { - if (isPlatformError(entriesByIdPossiblyError)) return ENTRIES_DEFAULT; - return entriesByIdPossiblyError; - }, [entriesByIdPossiblyError]); - - const isLoadingEntriesById = !entriesError && entriesById === ENTRIES_DEFAULT; - - // Return all defined entries filtered by searchQuery - const entriesFiltered = useMemo(() => { - if (entriesById === ENTRIES_DEFAULT) return []; - - // Filter entries by searchQuery (verse reference filtering is now done on backend) - const search = searchQuery.toLowerCase(); - return Object.values(entriesById ?? {}) - .flat() - .filter((entry): entry is Entry => { - if (!entry) return false; - const matchesSearch = - entry.lemma.toLowerCase().includes(search) || - entry.strongsCodes.some((code) => code.toLowerCase().includes(search)) || - getFormatGlossesStringFromDictionaryEntrySenses(entry, scrRef) - .toLowerCase() - .includes(search); - return matchesSearch; - }); - }, [entriesById, searchQuery, scrRef]); - - // Clear the selected entry if it is no longer in the filtered list (e.g. after a chapter change) - useEffect(() => { - if ( - selectedEntry && - !entriesFiltered.some( - (e) => - e.id === selectedEntry.id && - e.lexicalReferenceTextId === selectedEntry.lexicalReferenceTextId, - ) - ) { - setSelectedEntry(undefined); - } - }, [entriesFiltered, selectedEntry]); - - // Entry displayed in DictionaryEntryDisplay: always the single entry, or whatever is selected in the list - const displayedEntry = entriesFiltered.length === 1 ? entriesFiltered[0] : selectedEntry; - - // Selector to fetch the full (unfiltered) data for the displayed entry - const displayedItemSelector: LexicalReferenceSelector = useMemo( - () => - displayedEntry - ? { - itemId: displayedEntry.id, - lexicalReferenceTextId: displayedEntry.lexicalReferenceTextId, - } - : {}, - [displayedEntry], - ); - - const [fullEntriesByIdPossiblyError] = useData( - displayedEntry !== undefined ? 'platformLexicalTools.lexicalReferenceService' : undefined, - ).EntriesById(displayedItemSelector, ENTRIES_DEFAULT); - - // Extract the full entry for the displayed entry; the selector already filters by - // lexicalReferenceTextId so there will only be one matching entry array - const fullDisplayedEntry = useMemo(() => { - if (!displayedEntry) return undefined; - if (isPlatformError(fullEntriesByIdPossiblyError)) return undefined; - const entries = fullEntriesByIdPossiblyError[displayedEntry.id]; - return entries?.[0]; - }, [fullEntriesByIdPossiblyError, displayedEntry]); + const [scrRef, setScrRef] = useWebViewScrollGroupScrRef(); const onSelectOccurrence = useCallback( (scrRefOfOccurrence: SerializedVerseRef) => { @@ -149,113 +29,50 @@ globalThis.webViewComponent = function Dictionary({ [setScrRef], ); - const onCharacterPress = useCallback( - (character: string) => { - searchInputRef.current?.focus(); - setSearchQuery(character); - }, - [setSearchQuery], - ); - - // TODO: Implement project selection when lexical data from scripture projects available - // const handleSelectProject = useCallback( - // (newProjectId: string) => { - // updateWebViewDefinition({ - // projectId: newProjectId, - // }); - // }, - // [updateWebViewDefinition], - // ); + // Read the scope-filtered entries for the current reference. Verse reference filtering is done by + // the data source; the component handles search-text filtering. + const getEntries = useCallback(async (scope: DictionaryScope, ref: SerializedVerseRef) => { + const selector: LexicalReferenceSelector = { + book: ref.book, + chapterNum: ref.chapterNum, + ...(scope === 'verse' && { verseNum: ref.verseNum }), + }; + try { + const dp = await papi.dataProviders.get('platformLexicalTools.lexicalReferenceService'); + const entriesById = await dp.getEntriesById(selector); + return { entriesById: entriesById ?? {} }; + } catch (e) { + const error = getErrorMessage(e); + logger.error(`Error getting entries by ID: ${error}`); + return { entriesById: {}, error }; + } + }, []); + + // Fetch the full (unfiltered) data for the displayed entry. The selector filters by + // lexicalReferenceTextId so there will only be one matching entry array. + const getFullEntry = useCallback(async (entry: Entry) => { + const selector: LexicalReferenceSelector = { + itemId: entry.id, + lexicalReferenceTextId: entry.lexicalReferenceTextId, + }; + try { + const dp = await papi.dataProviders.get('platformLexicalTools.lexicalReferenceService'); + const fullEntriesById = await dp.getEntriesById(selector); + if (!fullEntriesById || isPlatformError(fullEntriesById)) return undefined; + return fullEntriesById[entry.id]?.[0]; + } catch (e) { + logger.error(`Error getting full entry by ID: ${getErrorMessage(e)}`); + return undefined; + } + }, []); return ( -
-
-
-
- -
-
- -
-
-
- {isLoadingEntriesById && ( -
- {[...Array(10)].map((_, index) => ( - - ))} -
- )} - {entriesError && ( -
- - - - -
- )} - {entriesFiltered.length === 0 && !isLoadingEntriesById && !entriesError && ( -
- -
- )} - {entriesFiltered.length === 1 && ( -
- -
- )} - {entriesFiltered.length > 1 && ( - - )} -
+ ); }; From 2b246ad0ca03fe747856a3a85ddb676055d77c9d Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 15:05:18 +0200 Subject: [PATCH 12/23] split inventory components from PAPI + add interactive stories Make the four inventory components (character, marker, punctuation, repeated-words) pure so they render in Storybook, and add interactive stories driven by a thin in-memory service. - Lift each component's internal useLocalizedStrings into a localized-strings prop. Marker also used useProjectData + logger for marker names; lift that to a markerNames prop so the component is fully @papi-free (the webview now loads marker names and passes them down). - inventory.web-view.tsx: resolve each type's table-header strings (and marker names) and pass them into the rendered component; everything else (useInventory, settings reads/writes, occurrence loading) unchanged. - Add inventory.stories.tsx: a thin in-memory CRUD service seeds items + approved/unapproved lists; approve/unapprove move items between lists so the UI reflects the change; occurrences load on selection; valid/invalid persistence is console-logged. Stories for Character (default), RepeatedWords, Markers, plus Loading and Empty. Verified: typecheck, extensions lint, and storybook:build all clean. Co-Authored-By: Claude Code --- .../character-inventory.component.tsx | 14 +- .../checks/inventories/inventory.stories.tsx | 320 ++++++++++++++++++ .../marker-inventory.component.tsx | 50 ++- .../punctuation-inventory.component.tsx | 14 +- .../repeated-words-inventory.component.tsx | 14 +- .../src/inventory.web-view.tsx | 123 ++++--- 6 files changed, 454 insertions(+), 81 deletions(-) create mode 100644 extensions/src/platform-scripture/src/checks/inventories/inventory.stories.tsx diff --git a/extensions/src/platform-scripture/src/checks/inventories/character-inventory.component.tsx b/extensions/src/platform-scripture/src/checks/inventories/character-inventory.component.tsx index e8953f178ea..91657c7a2be 100644 --- a/extensions/src/platform-scripture/src/checks/inventories/character-inventory.component.tsx +++ b/extensions/src/platform-scripture/src/checks/inventories/character-inventory.component.tsx @@ -1,4 +1,3 @@ -import { useLocalizedStrings } from '@papi/frontend/react'; import { SerializedVerseRef } from '@sillsdev/scripture'; import { ColumnDef, @@ -15,7 +14,11 @@ import { LanguageStrings, LocalizeKey } from 'platform-bible-utils'; import { useMemo } from 'react'; import { getUnicodeValue } from './inventory-utils'; -const CHARACTER_INVENTORY_STRING_KEYS: LocalizeKey[] = [ +/** + * Localization keys this inventory needs for its table headers. Resolve these via the Platform's + * localization hook and pass the result into the `characterInventoryStrings` prop. + */ +export const CHARACTER_INVENTORY_STRING_KEYS: LocalizeKey[] = [ '%webView_inventory_table_header_character%', '%webView_inventory_table_header_unicode_value%', '%webView_inventory_table_header_count%', @@ -69,6 +72,11 @@ type CharacterInventoryProps = { inventoryItems: InventorySummaryItem[] | undefined; setVerseRef: (scriptureReference: SerializedVerseRef) => void; localizedStrings: LanguageStrings; + /** + * Localized strings for this inventory's table headers; resolve via + * {@link CHARACTER_INVENTORY_STRING_KEYS}. + */ + characterInventoryStrings: LanguageStrings; approvedItems: string[]; onApprovedItemsChange: (items: string[]) => void; unapprovedItems: string[]; @@ -83,6 +91,7 @@ export function CharacterInventory({ inventoryItems, setVerseRef, localizedStrings, + characterInventoryStrings, approvedItems, onApprovedItemsChange, unapprovedItems, @@ -92,7 +101,6 @@ export function CharacterInventory({ areInventoryItemsLoading, onItemSelected, }: CharacterInventoryProps) { - const [characterInventoryStrings] = useLocalizedStrings(CHARACTER_INVENTORY_STRING_KEYS); const itemLabel = useMemo( () => characterInventoryStrings['%webView_inventory_table_header_character%'], [characterInventoryStrings], diff --git a/extensions/src/platform-scripture/src/checks/inventories/inventory.stories.tsx b/extensions/src/platform-scripture/src/checks/inventories/inventory.stories.tsx new file mode 100644 index 00000000000..51135cc8b61 --- /dev/null +++ b/extensions/src/platform-scripture/src/checks/inventories/inventory.stories.tsx @@ -0,0 +1,320 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { SerializedVerseRef } from '@sillsdev/scripture'; +import { INVENTORY_STRING_KEYS, InventorySummaryItem, Scope } from 'platform-bible-react'; +import { useCallback, useMemo, useState } from 'react'; +import { getLocalizedStrings } from '../../../../../../.storybook/localization.utils'; +import { + CharacterInventory, + CHARACTER_INVENTORY_STRING_KEYS, +} from './character-inventory.component'; +import { + RepeatedWordsInventory, + REPEATED_WORDS_INVENTORY_STRING_KEYS, +} from './repeated-words-inventory.component'; +import { MarkerInventory, MARKER_INVENTORY_STRING_KEYS } from './marker-inventory.component'; + +/** + * The inventory components (`CharacterInventory`, `RepeatedWordsInventory`, `MarkerInventory`, …) + * are presentational wrappers around the shared `Inventory`: they build the type-specific columns + * and forward the rest. In the app the inventory webview feeds them PAPI (items + occurrences via + * the data provider, approved/unapproved lists via project settings). These stories feed them from + * a thin in-memory CRUD service so the flow is fully interactive: filter, change scope, approve / + * unapprove items (which moves them between the lists and re-renders), and select an item to load + * its occurrences on demand. + */ + +const sharedStrings = getLocalizedStrings([...INVENTORY_STRING_KEYS]); +const characterStrings = getLocalizedStrings([...CHARACTER_INVENTORY_STRING_KEYS]); +const repeatedWordsStrings = getLocalizedStrings([...REPEATED_WORDS_INVENTORY_STRING_KEYS]); +const markerStrings = getLocalizedStrings([...MARKER_INVENTORY_STRING_KEYS]); + +const DEFAULT_SCR_REF: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + +/** A seed inventory item: its key plus the occurrences the data provider would return for it. */ +type SeedItem = { + key: string | string[]; + count: number; + occurrences: { reference: SerializedVerseRef; text: string }[]; +}; + +const characterSeed: SeedItem[] = [ + { + key: 'a', + count: 3, + occurrences: [ + { reference: { book: 'GEN', chapterNum: 1, verseNum: 1 }, text: 'In the beginning' }, + { reference: { book: 'GEN', chapterNum: 1, verseNum: 2 }, text: 'And the earth was' }, + ], + }, + { + key: 'ḥ', + count: 1, + occurrences: [ + { reference: { book: 'GEN', chapterNum: 1, verseNum: 4 }, text: 'God saw the light' }, + ], + }, + { + key: '”', + count: 2, + occurrences: [ + { reference: { book: 'GEN', chapterNum: 1, verseNum: 3 }, text: 'Let there be light”' }, + ], + }, + { + key: '​', + count: 1, + occurrences: [ + { reference: { book: 'GEN', chapterNum: 1, verseNum: 5 }, text: 'the first day' }, + ], + }, +]; + +const repeatedWordsSeed: SeedItem[] = [ + { + key: 'the the', + count: 2, + occurrences: [ + { + reference: { book: 'GEN', chapterNum: 1, verseNum: 1 }, + text: 'In the the beginning was the Word', + }, + ], + }, + { + key: 'and and', + count: 1, + occurrences: [ + { + reference: { book: 'GEN', chapterNum: 1, verseNum: 3 }, + text: 'And and God said, Let there be light', + }, + ], + }, + { + key: 'is is', + count: 1, + occurrences: [ + { reference: { book: 'PSA', chapterNum: 25, verseNum: 8 }, text: 'God is is good' }, + ], + }, +]; + +const markerSeed: SeedItem[] = [ + { + key: 'xt', + count: 3, + occurrences: [ + { + reference: { book: 'GEN', chapterNum: 1, verseNum: 1 }, + text: 'In the beginning God created the heavens and the earth.', + }, + ], + }, + { + key: 'f', + count: 5, + occurrences: [ + { + reference: { book: 'GEN', chapterNum: 1, verseNum: 3 }, + text: 'And God said, "Let there be light," and there was light.', + }, + ], + }, + { + key: 'toc2', + count: 1, + occurrences: [ + { + reference: { book: 'GEN', chapterNum: 1, verseNum: 5 }, + text: 'God called the light Day, and the darkness he called Night.', + }, + ], + }, +]; + +const seedMarkerNames: string[] = [ + 'xt - Cross Reference - Target References', + 'toc2 - File - Short Table of Contents Text', + 'fig - Auxiliary - Figure/Illustration/Map', + 'f - Footnote', +]; + +/** Which presentational inventory the harness should render. */ +type InventoryKind = 'character' | 'repeatedWords' | 'marker'; + +type HarnessConfig = { + kind: InventoryKind; + /** Seed items the in-memory service serves. */ + items: SeedItem[]; + /** Items approved up front. */ + initialApproved?: string[]; + /** Items unapproved up front. */ + initialUnapproved?: string[]; + /** Force the loading state by reporting items as still loading and serving none. */ + loading?: boolean; +}; + +/** + * Thin in-memory service container shared by every inventory story: it owns the approved / + * unapproved lists and the per-item occurrence cache, derives each item's status from those lists + * (exactly as the webview does), loads occurrences on demand, and renders the requested + * presentational inventory. + */ +function InventoryHarness({ config }: { config: HarnessConfig }) { + // The app navigates the scroll group on setVerseRef; in the story we just track it locally. + const [, setScrRef] = useState(DEFAULT_SCR_REF); + const [scope, setScope] = useState('chapter'); + const [approvedItems, setApprovedItems] = useState(config.initialApproved ?? []); + const [unapprovedItems, setUnapprovedItems] = useState(config.initialUnapproved ?? []); + // Occurrences loaded on demand, keyed by item key (mirrors the webview's occurrence cache). + const [occurrencesByKey, setOccurrencesByKey] = useState<{ + [key: string]: SeedItem['occurrences']; + }>({}); + + // Derive each item's status + currently-loaded occurrences from the in-memory state, just like + // the webview's `enhancedInventoryItems`, so approve/unapprove and item-selection reflect. + const inventoryItems = useMemo(() => { + if (config.loading) return []; + return config.items.map((item) => { + const itemKey = String(item.key); + let status: 'approved' | 'unapproved' | 'unknown' = 'unknown'; + if (approvedItems.includes(itemKey)) status = 'approved'; + else if (unapprovedItems.includes(itemKey)) status = 'unapproved'; + return { + key: item.key, + count: item.count, + status, + occurrences: occurrencesByKey[itemKey] ?? [], + }; + }); + }, [config.items, config.loading, approvedItems, unapprovedItems, occurrencesByKey]); + + // Approve/unapprove move the key between the two lists so the row's status updates immediately. + const onApprovedItemsChange = useCallback((items: string[]) => { + // Settings write in the app; here we reflect it in-memory so the list re-renders. + // eslint-disable-next-line no-console + console.log('setValidItems', items); + setApprovedItems(items); + }, []); + + const onUnapprovedItemsChange = useCallback((items: string[]) => { + // Settings write in the app; here we reflect it in-memory so the list re-renders. + // eslint-disable-next-line no-console + console.log('setInvalidItems', items); + setUnapprovedItems(items); + }, []); + + // Selecting an item loads its occurrences from the seed (the app reads them from the provider). + const onItemSelected = useCallback( + (itemKey: string) => { + setOccurrencesByKey((prev) => { + if (prev[itemKey]) return prev; + const seed = config.items.find((item) => String(item.key) === itemKey); + return { ...prev, [itemKey]: seed?.occurrences ?? [] }; + }); + }, + [config.items], + ); + + const sharedProps = { + inventoryItems, + setVerseRef: setScrRef, + localizedStrings: sharedStrings, + approvedItems, + onApprovedItemsChange, + unapprovedItems, + onUnapprovedItemsChange, + scope, + onScopeChange: setScope, + areInventoryItemsLoading: config.loading ?? false, + onItemSelected, + }; + + switch (config.kind) { + case 'repeatedWords': + return ( + + ); + case 'marker': + return ( + + ); + case 'character': + default: + return ; + } +} + +const meta: Meta = { + title: 'Bundled Extensions/platform-scripture/Inventory', + component: InventoryHarness, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +function createDecorator(config: HarnessConfig) { + return function InventoryDecorator() { + return ; + }; +} + +/** + * The character inventory, populated. Approve / unapprove items to move them between the lists, use + * the status filter, and select a row to load its occurrences. + */ +export const Character: Story = { + decorators: [ + createDecorator({ + kind: 'character', + items: characterSeed, + initialApproved: ['a'], + initialUnapproved: ['”'], + }), + ], +}; + +/** The repeated-words inventory, populated. */ +export const RepeatedWords: Story = { + decorators: [ + createDecorator({ + kind: 'repeatedWords', + items: repeatedWordsSeed, + initialApproved: ['and and'], + initialUnapproved: ['the the'], + }), + ], +}; + +/** + * The marker inventory, populated. Marker names (the style-name column + the "show preceding + * marker" option) come from the seed the webview would load from the project. + */ +export const Markers: Story = { + decorators: [ + createDecorator({ + kind: 'marker', + items: markerSeed, + initialApproved: ['xt'], + initialUnapproved: ['f'], + }), + ], +}; + +/** Items are still loading — the inventory's loading state renders. */ +export const Loading: Story = { + decorators: [createDecorator({ kind: 'character', items: characterSeed, loading: true })], +}; + +/** No items to review — the empty inventory renders. */ +export const Empty: Story = { + decorators: [createDecorator({ kind: 'character', items: [] })], +}; diff --git a/extensions/src/platform-scripture/src/checks/inventories/marker-inventory.component.tsx b/extensions/src/platform-scripture/src/checks/inventories/marker-inventory.component.tsx index c0110b061c0..18219b701ae 100644 --- a/extensions/src/platform-scripture/src/checks/inventories/marker-inventory.component.tsx +++ b/extensions/src/platform-scripture/src/checks/inventories/marker-inventory.component.tsx @@ -1,6 +1,4 @@ -import { logger } from '@papi/frontend'; -import { useLocalizedStrings, useProjectData } from '@papi/frontend/react'; -import { Canon, SerializedVerseRef } from '@sillsdev/scripture'; +import { SerializedVerseRef } from '@sillsdev/scripture'; import { ColumnDef, getInventoryHeader, @@ -12,15 +10,14 @@ import { InventoryTableData, Scope, } from 'platform-bible-react'; -import { - getErrorMessage, - isPlatformError, - LanguageStrings, - LocalizeKey, -} from 'platform-bible-utils'; +import { LanguageStrings, LocalizeKey } from 'platform-bible-utils'; import { useMemo } from 'react'; -const MARKER_INVENTORY_STRING_KEYS: LocalizeKey[] = [ +/** + * Localization keys this inventory needs for its table headers. Resolve these via the Platform's + * localization hook and pass the result into the `markerInventoryStrings` prop. + */ +export const MARKER_INVENTORY_STRING_KEYS: LocalizeKey[] = [ '%webView_inventory_table_header_marker%', '%webView_inventory_table_header_preceding_marker%', '%webView_inventory_table_header_style_name%', @@ -30,8 +27,6 @@ const MARKER_INVENTORY_STRING_KEYS: LocalizeKey[] = [ '%webView_inventory_unknown_marker%', ]; -const MARKER_NAMES_DEFAULT: string[] = []; - function getDescription(markerDescriptions: string[], marker: string): string | undefined { // Search for whole marker surrounded by whitespace or periods or at string boundaries (^ and $) const findMarker = new RegExp(`(^|[\\s.])${marker}([\\s.]|$)`); @@ -87,49 +82,44 @@ const createColumns = ( type MarkerInventoryProps = { inventoryItems: InventorySummaryItem[] | undefined; - verseRef: SerializedVerseRef; setVerseRef: (scriptureReference: SerializedVerseRef) => void; localizedStrings: LanguageStrings; + /** + * Localized strings for this inventory's table headers; resolve via + * {@link MARKER_INVENTORY_STRING_KEYS}. + */ + markerInventoryStrings: LanguageStrings; + /** + * Full marker descriptions (as defined in the project's USFM stylesheet) for the current book, + * used to resolve each marker's style-name column. Loaded by the container (webview/story) + * because reading them depends on the project and book reference. + */ + markerNames: string[]; approvedItems: string[]; onApprovedItemsChange: (items: string[]) => void; unapprovedItems: string[]; onUnapprovedItemsChange: (items: string[]) => void; scope: Scope; onScopeChange: (scope: Scope) => void; - projectId?: string; areInventoryItemsLoading: boolean; onItemSelected?: (itemKey: string) => void; }; export function MarkerInventory({ inventoryItems, - verseRef, setVerseRef, localizedStrings, + markerInventoryStrings, + markerNames, approvedItems, onApprovedItemsChange, unapprovedItems, onUnapprovedItemsChange, scope, onScopeChange, - projectId, areInventoryItemsLoading, onItemSelected, }: MarkerInventoryProps) { - const [markerNamesPossiblyError] = useProjectData( - 'platformScripture.MarkerNames', - projectId ?? undefined, - ).MarkerNames(Canon.bookIdToNumber(verseRef.book), []); - - const markerNames = useMemo(() => { - if (isPlatformError(markerNamesPossiblyError)) { - logger.warn(`Error getting marker names: ${getErrorMessage(markerNamesPossiblyError)}`); - return MARKER_NAMES_DEFAULT; - } - return markerNamesPossiblyError; - }, [markerNamesPossiblyError]); - - const [markerInventoryStrings] = useLocalizedStrings(MARKER_INVENTORY_STRING_KEYS); const itemLabel = useMemo( () => markerInventoryStrings['%webView_inventory_table_header_marker%'], [markerInventoryStrings], diff --git a/extensions/src/platform-scripture/src/checks/inventories/punctuation-inventory.component.tsx b/extensions/src/platform-scripture/src/checks/inventories/punctuation-inventory.component.tsx index 09aa1b977c5..0c17ee43d08 100644 --- a/extensions/src/platform-scripture/src/checks/inventories/punctuation-inventory.component.tsx +++ b/extensions/src/platform-scripture/src/checks/inventories/punctuation-inventory.component.tsx @@ -1,4 +1,3 @@ -import { useLocalizedStrings } from '@papi/frontend/react'; import { SerializedVerseRef } from '@sillsdev/scripture'; import { ColumnDef, @@ -15,7 +14,11 @@ import { LanguageStrings, LocalizeKey } from 'platform-bible-utils'; import { useMemo } from 'react'; import { getUnicodeValue } from './inventory-utils'; -const PUNCTUATION_INVENTORY_STRING_KEYS: LocalizeKey[] = [ +/** + * Localization keys this inventory needs for its table headers. Resolve these via the Platform's + * localization hook and pass the result into the `punctuationInventoryStrings` prop. + */ +export const PUNCTUATION_INVENTORY_STRING_KEYS: LocalizeKey[] = [ '%webView_inventory_table_header_count%', '%webView_inventory_table_header_punctuation%', '%webView_inventory_table_header_status%', @@ -80,6 +83,11 @@ type PunctuationInventoryProps = { inventoryItems: InventorySummaryItem[] | undefined; setVerseRef: (scriptureReference: SerializedVerseRef) => void; localizedStrings: LanguageStrings; + /** + * Localized strings for this inventory's table headers; resolve via + * {@link PUNCTUATION_INVENTORY_STRING_KEYS}. + */ + punctuationInventoryStrings: LanguageStrings; approvedItems: string[]; onApprovedItemsChange: (items: string[]) => void; unapprovedItems: string[]; @@ -94,6 +102,7 @@ export function PunctuationInventory({ inventoryItems, setVerseRef, localizedStrings, + punctuationInventoryStrings, approvedItems, onApprovedItemsChange, unapprovedItems, @@ -103,7 +112,6 @@ export function PunctuationInventory({ areInventoryItemsLoading, onItemSelected, }: PunctuationInventoryProps) { - const [punctuationInventoryStrings] = useLocalizedStrings(PUNCTUATION_INVENTORY_STRING_KEYS); const itemLabel = useMemo( () => punctuationInventoryStrings['%webView_inventory_table_header_punctuation%'], [punctuationInventoryStrings], diff --git a/extensions/src/platform-scripture/src/checks/inventories/repeated-words-inventory.component.tsx b/extensions/src/platform-scripture/src/checks/inventories/repeated-words-inventory.component.tsx index b2898f5ea74..77233c4dab8 100644 --- a/extensions/src/platform-scripture/src/checks/inventories/repeated-words-inventory.component.tsx +++ b/extensions/src/platform-scripture/src/checks/inventories/repeated-words-inventory.component.tsx @@ -1,4 +1,3 @@ -import { useLocalizedStrings } from '@papi/frontend/react'; import { SerializedVerseRef } from '@sillsdev/scripture'; import { ColumnDef, @@ -13,7 +12,11 @@ import { import { LanguageStrings, LocalizeKey } from 'platform-bible-utils'; import { useMemo } from 'react'; -const REPEATED_WORDS_INVENTORY_STRING_KEYS: LocalizeKey[] = [ +/** + * Localization keys this inventory needs for its table headers. Resolve these via the Platform's + * localization hook and pass the result into the `repeatedWordsInventoryStrings` prop. + */ +export const REPEATED_WORDS_INVENTORY_STRING_KEYS: LocalizeKey[] = [ '%webView_inventory_table_header_repeated_words%', '%webView_inventory_table_header_count%', '%webView_inventory_table_header_status%', @@ -55,6 +58,11 @@ interface RepeatedWordsInventoryProps { inventoryItems: InventorySummaryItem[] | undefined; setVerseRef: (scriptureReference: SerializedVerseRef) => void; localizedStrings: LanguageStrings; + /** + * Localized strings for this inventory's table headers; resolve via + * {@link REPEATED_WORDS_INVENTORY_STRING_KEYS}. + */ + repeatedWordsInventoryStrings: LanguageStrings; approvedItems: string[]; onApprovedItemsChange: (items: string[]) => void; unapprovedItems: string[]; @@ -69,6 +77,7 @@ export function RepeatedWordsInventory({ inventoryItems, setVerseRef, localizedStrings, + repeatedWordsInventoryStrings, approvedItems, onApprovedItemsChange, unapprovedItems, @@ -78,7 +87,6 @@ export function RepeatedWordsInventory({ areInventoryItemsLoading, onItemSelected, }: RepeatedWordsInventoryProps) { - const [repeatedWordsInventoryStrings] = useLocalizedStrings(REPEATED_WORDS_INVENTORY_STRING_KEYS); const itemLabel = useMemo( () => repeatedWordsInventoryStrings['%webView_inventory_table_header_repeated_words%'], [repeatedWordsInventoryStrings], diff --git a/extensions/src/platform-scripture/src/inventory.web-view.tsx b/extensions/src/platform-scripture/src/inventory.web-view.tsx index 477ee047a11..750e6f5ada7 100644 --- a/extensions/src/platform-scripture/src/inventory.web-view.tsx +++ b/extensions/src/platform-scripture/src/inventory.web-view.tsx @@ -1,19 +1,32 @@ import { WebViewProps } from '@papi/core'; import { logger } from '@papi/frontend'; -import { useLocalizedStrings, useProjectSetting } from '@papi/frontend/react'; -import { SerializedVerseRef } from '@sillsdev/scripture'; +import { useLocalizedStrings, useProjectData, useProjectSetting } from '@papi/frontend/react'; +import { Canon, SerializedVerseRef } from '@sillsdev/scripture'; import { INVENTORY_STRING_KEYS, Scope } from 'platform-bible-react'; import { getErrorMessage, isPlatformError } from 'platform-bible-utils'; import type { InventoryInputRange, ItemizedInventoryItem } from 'platform-scripture'; import { useCallback, useMemo, useState, useEffect, useRef } from 'react'; -import { CharacterInventory } from './checks/inventories/character-inventory.component'; -import { MarkerInventory } from './checks/inventories/marker-inventory.component'; -import { PunctuationInventory } from './checks/inventories/punctuation-inventory.component'; -import { RepeatedWordsInventory } from './checks/inventories/repeated-words-inventory.component'; +import { + CharacterInventory, + CHARACTER_INVENTORY_STRING_KEYS, +} from './checks/inventories/character-inventory.component'; +import { + MarkerInventory, + MARKER_INVENTORY_STRING_KEYS, +} from './checks/inventories/marker-inventory.component'; +import { + PunctuationInventory, + PUNCTUATION_INVENTORY_STRING_KEYS, +} from './checks/inventories/punctuation-inventory.component'; +import { + RepeatedWordsInventory, + REPEATED_WORDS_INVENTORY_STRING_KEYS, +} from './checks/inventories/repeated-words-inventory.component'; import { useInventory } from './hooks/use-inventory'; const VALID_ITEMS_DEFAULT = ''; const INVALID_ITEMS_DEFAULT = ''; +const MARKER_NAMES_DEFAULT: string[] = []; /** Subset of CheckType enum from Paratext.Data.Checking */ type CheckType = 'Character' | 'RepeatedWord' | 'Marker' | 'Punctuation'; @@ -29,28 +42,28 @@ type InventoryItemOccurrence = { /** Configuration mapping web view types to their corresponding components and check IDs */ const INVENTORY_TYPE_CONFIG = { 'platformScripture.characterInventory': { - component: CharacterInventory, checkId: 'Character' satisfies CheckType, validItemsSetting: 'platformScripture.validCharacters', invalidItemsSetting: 'platformScripture.invalidCharacters', + typeStringKeys: CHARACTER_INVENTORY_STRING_KEYS, }, 'platformScripture.repeatedWordsInventory': { - component: RepeatedWordsInventory, checkId: 'RepeatedWord' satisfies CheckType, validItemsSetting: 'platformScripture.repeatableWords', invalidItemsSetting: 'platformScripture.nonRepeatableWords', + typeStringKeys: REPEATED_WORDS_INVENTORY_STRING_KEYS, }, 'platformScripture.markersInventory': { - component: MarkerInventory, checkId: 'Marker' satisfies CheckType, validItemsSetting: 'platformScripture.validMarkers', invalidItemsSetting: 'platformScripture.invalidMarkers', + typeStringKeys: MARKER_INVENTORY_STRING_KEYS, }, 'platformScripture.punctuationInventory': { - component: PunctuationInventory, checkId: 'Punctuation' satisfies CheckType, validItemsSetting: 'platformScripture.validPunctuation', invalidItemsSetting: 'platformScripture.invalidPunctuation', + typeStringKeys: PUNCTUATION_INVENTORY_STRING_KEYS, }, } as const; @@ -202,21 +215,11 @@ global.webViewComponent = function InventoryWebView({ }; }, []); - const [localizedStrings] = useLocalizedStrings( - useMemo(() => { - return Array.from(INVENTORY_STRING_KEYS); - }, []), - ); const [webViewType] = useWebViewState('webViewType', ''); const [verseRef, setVerseRef] = useWebViewScrollGroupScrRef(); const [scope, setScope] = useState('chapter'); - const { - component: InventoryComponent, - checkId, - validItemsSetting, - invalidItemsSetting, - } = useMemo(() => { + const { checkId, validItemsSetting, invalidItemsSetting, typeStringKeys } = useMemo(() => { // Validate and get inventory configuration if (!webViewType || !isValidInventoryWebViewType(webViewType)) { throw new Error(`"${webViewType}" is not a valid inventory type`); @@ -224,11 +227,14 @@ global.webViewComponent = function InventoryWebView({ return INVENTORY_TYPE_CONFIG[webViewType]; }, [webViewType]); - const { inventoryInputRange, stableVerseRefForScope } = useInventoryInputRange( - verseRef, - scope, - projectId, + // Resolve the shared inventory strings plus the rendered type's table-header strings (lifted out + // of the per-type components so they no longer import `@papi`). + const [localizedStrings] = useLocalizedStrings( + useMemo(() => Array.from(INVENTORY_STRING_KEYS), []), ); + const [typeStrings] = useLocalizedStrings(useMemo(() => [...typeStringKeys], [typeStringKeys])); + + const { inventoryInputRange } = useInventoryInputRange(verseRef, scope, projectId); const { inventoryItems, @@ -248,6 +254,21 @@ global.webViewComponent = function InventoryWebView({ }; }, [cleanup]); + // Marker names for the current book (lifted out of MarkerInventory so it stays presentational). + // Only the marker inventory consumes these; the hook runs harmlessly for the other types. + const [markerNamesPossiblyError] = useProjectData( + 'platformScripture.MarkerNames', + projectId ?? undefined, + ).MarkerNames(Canon.bookIdToNumber(verseRef.book), []); + + const markerNames = useMemo(() => { + if (isPlatformError(markerNamesPossiblyError)) { + logger.warn(`Error getting marker names: ${getErrorMessage(markerNamesPossiblyError)}`); + return MARKER_NAMES_DEFAULT; + } + return markerNamesPossiblyError; + }, [markerNamesPossiblyError]); + const [inventoryItemsWithOccurrences, setInventoryItemsWithOccurrences] = useState<{ [itemKey: string]: InventoryItemOccurrence[]; }>({}); @@ -367,21 +388,39 @@ global.webViewComponent = function InventoryWebView({ [inventoryItems, inventoryItemsWithOccurrences, approvedItems, unapprovedItems], ); - return ( - - ); + // Props shared by every inventory type. The per-type table-header strings (and, for the marker + // inventory, the resolved marker names) are supplied alongside these per `webViewType`. + const sharedProps = { + inventoryItems: enhancedInventoryItems, + setVerseRef, + localizedStrings, + approvedItems, + onApprovedItemsChange: handleApprovedItemsChange, + unapprovedItems, + onUnapprovedItemsChange: handleUnapprovedItemsChange, + scope, + onScopeChange: setScope, + areInventoryItemsLoading, + onItemSelected: handleItemSelected, + }; + + switch (webViewType) { + case 'platformScripture.repeatedWordsInventory': + return ( + + ); + case 'platformScripture.markersInventory': + return ( + + ); + case 'platformScripture.punctuationInventory': + return ; + case 'platformScripture.characterInventory': + default: + return ; + } }; From d0887366e3047133ccefd1d016dbb38c4679d4d4 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 15:18:21 +0200 Subject: [PATCH 13/23] split checks side panel into presentational component + interactive story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract a presentational ChecksSidePanel component from the checks side panel webview and add an interactive story. The async check-job lifecycle (begin/poll/ stop/abandon, network invalidation, PDP writes, editor navigation) stays in the webview; the component owns the presentational logic and is fed via props. - ChecksSidePanel component (pure, no @papi): config bar (project/scope/check-type filters), the results list of CheckCards, progress/status bar, and empty/loading states. Receives checkResults, checksInfo, projects, filters, and job status as props and allow/deny/navigate/settings as callbacks. - check-card: lift its internal useLocalizedStrings into a localizedStrings prop. - checks-side-panel.utils: move getProjectNames (the only @papi user) out to the webview so the util is @papi-free and importable by the component + story. - webview: thin loader — keeps all PAPI/job/event orchestration; maps results + progress to props and wires callbacks to PDP writes/navigation. - story: thin in-memory service seeds check results; allow/deny mutate them so the cards update; navigation/settings announced via alertCommand; Running shows progress; WriteFailure uses rejectingMock. Populated leads. Verified: typecheck, extensions lint, and storybook:build all clean. Co-Authored-By: Claude Code --- .../src/checks-side-panel.utils.ts | 16 - .../src/checks-side-panel.web-view.tsx | 341 ++++------------ .../check-card.component.tsx | 47 ++- .../checks-side-panel.component.tsx | 376 ++++++++++++++++++ .../checks-side-panel.stories.tsx | 276 +++++++++++++ 5 files changed, 745 insertions(+), 311 deletions(-) create mode 100644 extensions/src/platform-scripture/src/checks/checks-side-panel/checks-side-panel.component.tsx create mode 100644 extensions/src/platform-scripture/src/checks/checks-side-panel/checks-side-panel.stories.tsx diff --git a/extensions/src/platform-scripture/src/checks-side-panel.utils.ts b/extensions/src/platform-scripture/src/checks-side-panel.utils.ts index fc72944688a..3608e1f7557 100644 --- a/extensions/src/platform-scripture/src/checks-side-panel.utils.ts +++ b/extensions/src/platform-scripture/src/checks-side-panel.utils.ts @@ -1,4 +1,3 @@ -import { projectDataProviders } from '@papi/frontend'; import { LocalizeKey } from 'platform-bible-utils'; export const CHECK_SCOPE_FILTER_STRINGS: { [key in CheckScopes]: LocalizeKey } = { @@ -70,18 +69,3 @@ export type ProjectOption = { fullName: string; shortName: string; }; - -/** - * Gets the short and full names of a project from its ID. - * - * @param projectId The ID of the project to get the names of. - * @returns An object with the short and full names of the project. - */ -export async function getProjectNames(projectId: string): Promise { - const pdp = await projectDataProviders.get('platform.base', projectId); - - const projectShortName = await pdp.getSetting('platform.name'); - const projectFullName = await pdp.getSetting('platform.fullName'); - - return { shortName: projectShortName, fullName: projectFullName }; -} diff --git a/extensions/src/platform-scripture/src/checks-side-panel.web-view.tsx b/extensions/src/platform-scripture/src/checks-side-panel.web-view.tsx index 733cb215c63..5c228ef982b 100644 --- a/extensions/src/platform-scripture/src/checks-side-panel.web-view.tsx +++ b/extensions/src/platform-scripture/src/checks-side-panel.web-view.tsx @@ -7,25 +7,9 @@ import { useWebViewController, } from '@papi/frontend/react'; import { Canon, SerializedVerseRef } from '@sillsdev/scripture'; -import { - Button, - ComboBox, - ComboBoxGroup, - MultiSelectComboBox, - MultiSelectComboBoxEntry, - Progress, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Spinner, - useEvent, - usePromise, -} from 'platform-bible-react'; +import { useEvent, usePromise } from 'platform-bible-react'; import { deepEqual, - formatReplacementString, getChaptersForBook, getErrorMessage, isPlatformError, @@ -41,17 +25,24 @@ import { CheckRunResult, } from 'platform-scripture'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - CHECK_SCOPE_FILTER_STRINGS, - CheckInfo, - CheckScopes, - getProjectNames, - isValidCheckScope, - LOCALIZED_STRINGS, - ProjectOption, -} from './checks-side-panel.utils'; +import { CheckInfo, CheckScopes, ProjectOption } from './checks-side-panel.utils'; import { CHECK_RESULTS_INVALIDATED_EVENT } from './checks/check.model'; -import { CheckCard, CheckStates } from './checks/checks-side-panel/check-card.component'; +import { + ChecksSidePanel, + ChecksSidePanelProject, + CHECKS_SIDE_PANEL_STRING_KEYS, +} from './checks/checks-side-panel/checks-side-panel.component'; + +/** + * Gets the short and full names of a project from its ID. Kept in the webview (not the shared, + * `@papi`-free utils) so the utils stay importable by the presentational component and its story. + */ +async function getProjectNames(projectId: string): Promise { + const pdp = await papi.projectDataProviders.get('platform.base', projectId); + const projectShortName = await pdp.getSetting('platform.name'); + const projectFullName = await pdp.getSetting('platform.fullName'); + return { shortName: projectShortName, fullName: projectFullName }; +} const defaultCheckRunnerCheckDetails: CheckRunnerCheckDetails = { checkDescription: '', @@ -83,12 +74,10 @@ global.webViewComponent = function ChecksSidePanelWebView({ useWebViewState, }: WebViewProps) { const [scrRef, setScrRef, ,] = useWebViewScrollGroupScrRef(); - const [selectedCheckId, setSelectedCheckId] = useState(''); const [selectedCheckTypeIds, setSelectedCheckTypeIds] = useWebViewState( 'selectedCheckTypes', [], ); - const [isCheckTypesOpen, setIsCheckTypesOpen] = useState(false); const [scope, setScope] = useWebViewState('checkScope', CheckScopes.Chapter); const [activeRanges, setActiveRanges] = useState(() => []); const [activeJobStatusReport, setActiveJobStatusReport] = @@ -98,7 +87,9 @@ global.webViewComponent = function ChecksSidePanelWebView({ const [checkResults, setCheckResults] = useState(() => defaultCheckResults); const checkResultsRef = useRef(checkResults); const [isResultLoadingCancelled, setIsResultLoadingCancelled] = useState(false); - const [localizedStrings] = useLocalizedStrings(useMemo(() => LOCALIZED_STRINGS, [])); + const [localizedStrings] = useLocalizedStrings( + useMemo(() => [...CHECKS_SIDE_PANEL_STRING_KEYS], []), + ); const [availableChecks, , isLoadingAvailableChecks] = useData( 'platformScripture.checkAggregator', ).AvailableChecks( @@ -588,14 +579,6 @@ global.webViewComponent = function ChecksSidePanelWebView({ [setScrRef, writeCheckId, editorWebViewId, editorWebViewController], ); - const handleSelectCheck = useCallback( - async (id: string) => { - setSelectedCheckId(id); - selectCheckReferenceInEditor(id); - }, - [selectCheckReferenceInEditor], - ); - const setDeniedStatusForResult = useCallback( (result: CheckRunResult, isDenied: boolean) => { if (!result || !projectId || !checkAggregator) return false; @@ -665,88 +648,17 @@ global.webViewComponent = function ChecksSidePanelWebView({ ); const handleSelectScope = useCallback( - (newScope: string) => { - if (isValidCheckScope(newScope)) { - setScope(newScope); - } + (newScope: CheckScopes) => { + setScope(newScope); }, [setScope], ); - const handleSelectCheckType = (updatedCheckIds: string[]) => { - setSelectedCheckTypeIds(updatedCheckIds); - }; - - type ProjectEntry = { - id: string; - fullName: string; - shortName: string; - label: string; - secondaryLabel?: string; - }; - - const projectOptionsGrouped = useMemo[]>(() => { - const allProjects = Object.entries(projectIdsAndNames) - .sort(([, a], [, b]) => - a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }), - ) - .map(([id, project]) => ({ - id, - fullName: project.fullName, - shortName: project.shortName, - label: project.shortName, - secondaryLabel: project.fullName, - })); - return [ - { - groupHeading: - localizedStrings['%webView_checksSidePanel_projectFilter_projectsAndResources%'], - options: allProjects, - }, - ]; - }, [projectIdsAndNames, localizedStrings]); - - const selectedProjectOption = useMemo( - () => - projectOptionsGrouped - .flatMap((group) => group.options) - .find((option) => option.id === projectId), - [projectOptionsGrouped, projectId], - ); - - const getScopeLabel = useCallback( - (scopeValue: string) => { - if (isValidCheckScope(scopeValue)) { - return localizedStrings[CHECK_SCOPE_FILTER_STRINGS[scopeValue]]; - } - return scopeValue; // Fallback for invalid scope values + const handleSelectCheckType = useCallback( + (updatedCheckIds: string[]) => { + setSelectedCheckTypeIds(updatedCheckIds); }, - [localizedStrings], - ); - - // Helper functions for check type filter - const checkTypeEntries: MultiSelectComboBoxEntry[] = useMemo( - () => - checksInfo.map((check) => ({ - value: check.checkId, - label: check.checkName, - secondaryLabel: check.isSetup - ? undefined - : localizedStrings['%webView_checksSidePanel_checkRequiresSetup%'], - starred: false, - })), - [checksInfo, localizedStrings], - ); - - const selectedChecksCountLabel = useMemo( - () => - formatReplacementString( - localizedStrings['%webView_checksSidePanel_checkTypeFilter_countLabel%'], - { - resultsCount: selectedCheckTypeIds.length, - }, - ), - [localizedStrings, selectedCheckTypeIds], + [setSelectedCheckTypeIds], ); const handleCancelOperation = useCallback(async () => { @@ -755,167 +667,48 @@ global.webViewComponent = function ChecksSidePanelWebView({ setIsResultLoadingCancelled(true); }, [stopActiveJob]); - // #endregion + // The presentational panel renders results as a plain array; surface an empty list on the + // PlatformError sentinel so the panel shows its empty state (matching the original behavior). + const safeCheckResults = useMemo( + () => (isPlatformError(checkResults) ? [] : checkResults), + [checkResults], + ); + + // Shape the loaded project metadata into the list the panel renders in the project filter. + const projects = useMemo( + () => + Object.entries(projectIdsAndNames).map(([id, project]) => ({ + id, + fullName: project.fullName, + shortName: project.shortName, + })), + [projectIdsAndNames], + ); - if (isLoadingAvailableChecks || !checkAggregator) { - return ( -
- -
- ); - } + // #endregion return ( -
- {/* Check configuration */} -
- {/* Project Filter */} - - options={projectOptionsGrouped} - value={selectedProjectOption} - onChange={(newProject) => handleSelectProject(newProject.id)} - getButtonLabel={(project) => project.shortName} - buttonPlaceholder={ - localizedStrings['%webView_checksSidePanel_projectFilter_noProjectSelected%'] - } - commandEmptyMessage={ - localizedStrings['%webView_checksSidePanel_projectFilter_noProjectsFound%'] - } - ariaLabel={ - localizedStrings['%webView_checksSidePanel_projectFilter_projectsAndResources%'] - } - buttonVariant="outline" - buttonClassName="tw:flex-1 tw:min-w-32 tw:font-normal" - popoverContentClassName="tw:w-[300px]" - alignDropDown="start" - /> - - {/* Scope Filter */} - - {/* Check Type Filter */} - -
- {/* Check results */} - { - // TODO: Display something else if there is an error getting check results - !checkResults || isPlatformError(checkResults) || checkResults.length === 0 ? ( -
-
- {selectedCheckTypeIds.length === 0 - ? localizedStrings['%webView_checksSidePanel_noChecksSelected%'] - : localizedStrings['%webView_checksSidePanel_noCheckResults%']} -
- -
- ) : ( -
- {checkResults.map((result, index) => ( - check.checkId === result.checkId)?.isSetup ?? true - } - checkCardDescription={result.messageFormatString} - /> - ))} -
- ) - } - {/* Status bar */} - {activeJobStatusReport && - activeJobStatusReport !== defaultJobStatusReport && - checkResults && ( -
- {/* The job is active */} - {activeJobStatusReport.status === 'queued' || - (activeJobStatusReport.status === 'running' && - // While starting up, % complete stays stuck at 0 and looks strange with the Cancel button - activeJobStatusReport.percentComplete > 0 && ( -
- - -
- ))} - {/* The job has finished but not all results are loaded into the UI yet */} - {(activeJobStatusReport.status === 'completed' || - activeJobStatusReport.status === 'stopped') && - checkResults && - checkResults.length < activeJobStatusReport.totalResultsCount && ( -
- - {checkResults.length.toString()} /{' '} - {activeJobStatusReport.totalResultsCount.toString()} - -
- )} - {/* The job has finished and all results are loaded into the UI */} - {(activeJobStatusReport.status === 'completed' || - activeJobStatusReport.status === 'stopped') && - checkResults && - checkResults.length === activeJobStatusReport.totalResultsCount && ( -

- {checkResults.length > 0 - ? checkResults.length.toString() - : localizedStrings['%webView_find_noResultsFound%']} -

- )} - {/* The job encountered an error while running */} - {activeJobStatusReport.status === 'errored' && activeJobStatusReport.error && ( -

{activeJobStatusReport.error}

- )} -
- )} -
+ ); }; diff --git a/extensions/src/platform-scripture/src/checks/checks-side-panel/check-card.component.tsx b/extensions/src/platform-scripture/src/checks/checks-side-panel/check-card.component.tsx index 6001bab21d6..5662590b8fd 100644 --- a/extensions/src/platform-scripture/src/checks/checks-side-panel/check-card.component.tsx +++ b/extensions/src/platform-scripture/src/checks/checks-side-panel/check-card.component.tsx @@ -11,8 +11,23 @@ import { import { Check, Settings, X } from 'lucide-react'; import { useMemo } from 'react'; import { CheckRunResult } from 'platform-scripture'; -import { useLocalizedStrings } from '@papi/frontend/react'; -import { formatScrRef } from 'platform-bible-utils'; +import { formatScrRef, LanguageStrings } from 'platform-bible-utils'; + +/** + * Object containing all keys used for localization in this component. Pass these keys into the + * Platform's localization hook and pass the resulting localized strings into the `localizedStrings` + * prop. + */ +export const CHECK_CARD_STRING_KEYS = Object.freeze([ + '%webView_checksSidePanel_fixedBadge_title%', + '%webView_checksSidePanel_deniedBadge_title%', + '%webView_checksSidePanel_checkingBadge_title%', + '%webView_checksSidePanel_checkRequiresSetup%', + '%webView_checksSidePanel_checkRequiresSetup_tooltip%', + '%webView_checksSidePanel_focusedCheckDropdown_allowItem%', + '%webView_checksSidePanel_focusedCheckDropdown_denyItem%', + '%webView_checksSidePanel_focusedCheckDropdown_settingsItem%', +] as const); /** Enum representing the possible states of a check */ export enum CheckStates { @@ -30,10 +45,12 @@ export enum CheckStates { type CheckStateBadgeProps = { /** The check state to display */ state: CheckStates.Fixed | CheckStates.Denied | CheckStates.Checking; + /** Localized strings; resolve via `CHECK_CARD_STRING_KEYS`. */ + localizedStrings: LanguageStrings; }; /** Generic badge component for displaying check states */ -function CheckStateBadge({ state }: CheckStateBadgeProps) { +function CheckStateBadge({ state, localizedStrings }: CheckStateBadgeProps) { const localizationKeys = useMemo( () => ({ [CheckStates.Fixed]: '%webView_checksSidePanel_fixedBadge_title%' as const, @@ -43,10 +60,6 @@ function CheckStateBadge({ state }: CheckStateBadgeProps) { [], ); - const [localizedStrings] = useLocalizedStrings( - useMemo(() => [localizationKeys[state]], [localizationKeys, state]), - ); - const isOutlineVariant = state === CheckStates.Fixed || state === CheckStates.Denied; return ( @@ -62,6 +75,8 @@ function CheckStateBadge({ state }: CheckStateBadgeProps) { /** Props for the CheckCard component */ export type CheckCardProps = { + /** Localized strings; resolve via `CHECK_CARD_STRING_KEYS`. */ + localizedStrings: LanguageStrings; /** Object containing the check result details */ checkResult: CheckRunResult; /** Unique identifier of the check */ @@ -95,6 +110,7 @@ export type CheckCardProps = { * The card styling changes based on the current check and selection status. */ export function CheckCard({ + localizedStrings, checkResult, checkId, isSelected, @@ -109,19 +125,6 @@ export function CheckCard({ isCheckSetup = true, className, }: CheckCardProps) { - const [localizedStrings] = useLocalizedStrings( - useMemo( - () => [ - '%webView_checksSidePanel_checkRequiresSetup%', - '%webView_checksSidePanel_checkRequiresSetup_tooltip%', - '%webView_checksSidePanel_focusedCheckDropdown_allowItem%', - '%webView_checksSidePanel_focusedCheckDropdown_denyItem%', - '%webView_checksSidePanel_focusedCheckDropdown_settingsItem%', - ], - [], - ), - ); - const isFixedOrDenied = useMemo( () => checkState === CheckStates.Fixed || checkState === CheckStates.Denied, [checkState], @@ -190,7 +193,9 @@ export function CheckCard({ {showBadge && (checkState === CheckStates.Fixed || checkState === CheckStates.Denied || - checkState === CheckStates.Checking) && } + checkState === CheckStates.Checking) && ( + + )} {!isCheckSetup && ( diff --git a/extensions/src/platform-scripture/src/checks/checks-side-panel/checks-side-panel.component.tsx b/extensions/src/platform-scripture/src/checks/checks-side-panel/checks-side-panel.component.tsx new file mode 100644 index 00000000000..2e8cd21f717 --- /dev/null +++ b/extensions/src/platform-scripture/src/checks/checks-side-panel/checks-side-panel.component.tsx @@ -0,0 +1,376 @@ +import { + Button, + ComboBox, + ComboBoxGroup, + MultiSelectComboBox, + MultiSelectComboBoxEntry, + Progress, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Spinner, +} from 'platform-bible-react'; +import { formatReplacementString, LanguageStrings } from 'platform-bible-utils'; +import { CheckJobStatusReport, CheckRunResult } from 'platform-scripture'; +import { useCallback, useMemo, useState } from 'react'; +import { + CHECK_SCOPE_FILTER_STRINGS, + CheckInfo, + CheckScopes, + isValidCheckScope, + LOCALIZED_STRINGS, + ProjectOption, +} from '../../checks-side-panel.utils'; +import { CHECK_CARD_STRING_KEYS, CheckCard, CheckStates } from './check-card.component'; + +/** + * All localization keys used by the panel and the cards it renders. Pass these into the Platform's + * localization hook and pass the resulting strings into the `localizedStrings` prop. + */ +export const CHECKS_SIDE_PANEL_STRING_KEYS = Object.freeze([ + ...LOCALIZED_STRINGS, + ...CHECK_CARD_STRING_KEYS, +] as const); + +/** A project (or resource) the user can select to run checks against. */ +export type ChecksSidePanelProject = ProjectOption & { + /** Unique id of the project. */ + id: string; +}; + +/** Props for the {@link ChecksSidePanel} presentational component. */ +export type ChecksSidePanelProps = { + /** Localized strings for the panel and its cards; resolve via `CHECKS_SIDE_PANEL_STRING_KEYS`. */ + localizedStrings: LanguageStrings; + /** Whether the available-checks list (and therefore the panel) is still loading. */ + isLoading: boolean; + /** Scripture projects/resources the user can run checks against. */ + projects: ChecksSidePanelProject[]; + /** Id of the currently-selected project, or `undefined` if none is selected. */ + selectedProjectId: string | undefined; + /** The currently-selected check scope (chapter/book/all). */ + scope: CheckScopes; + /** Ids of the check types the user has selected to run. */ + selectedCheckTypeIds: string[]; + /** Per-check setup info (display name + whether the check is fully configured). */ + checksInfo: CheckInfo[]; + /** The check results to render. */ + checkResults: CheckRunResult[]; + /** The active check job's status report (drives the progress/status bar). */ + jobStatusReport: CheckJobStatusReport; + /** Whether the report is the "no active job" sentinel (status bar stays hidden). */ + hasActiveJob: boolean; + /** Whether the user has cancelled loading results (disables the Cancel button). */ + isResultLoadingCancelled: boolean; + /** + * Resolve a check id to its localized description for display on a result card. This is a + * callback (not derivable from `checksInfo` alone) because results may reference a check by its + * result type when no check id is present. + */ + getLocalizedCheckDescription: (checkId: string) => string; + /** Called when the user selects a different project. Navigates the panel to that project. */ + onSelectProject: (projectId: string) => void; + /** Called when the user selects a different scope. */ + onSelectScope: (scope: CheckScopes) => void; + /** Called when the user changes which check types are selected. */ + onSelectCheckTypes: (checkTypeIds: string[]) => void; + /** Called when the user allows (un-denies) a check result. */ + onAllowCheck: (result: CheckRunResult) => Promise; + /** Called when the user denies a check result. */ + onDenyCheck: (result: CheckRunResult) => Promise; + /** Called to open the check settings/inventories UI (a different UI surface). */ + onOpenSettings: () => void; + /** + * Called when the user selects a check result. The container focuses/selects that result in the + * editor (a different UI surface). + */ + onNavigateToResult: (resultId: string) => void; + /** Called when the user cancels the in-progress check job. */ + onCancelOperation: () => void; +}; + +/** A project entry shaped for the project ComboBox. */ +type ProjectEntry = { + id: string; + fullName: string; + shortName: string; + label: string; + secondaryLabel?: string; +}; + +/** + * Presentational checks side panel. It owns the panel's rendering and presentational state + * (selection, the check-type filter open state, the result-id derivation) and the derivation of the + * config-bar option lists from its props, so the app webview and Storybook share the same logic. + * The container (webview or story) owns the async check-job lifecycle, PAPI reads/writes, and + * editor navigation, passing the derived data in as props and the operations in as callbacks. + */ +export function ChecksSidePanel({ + localizedStrings, + isLoading, + projects, + selectedProjectId, + scope, + selectedCheckTypeIds, + checksInfo, + checkResults, + jobStatusReport, + hasActiveJob, + isResultLoadingCancelled, + getLocalizedCheckDescription, + onSelectProject, + onSelectScope, + onSelectCheckTypes, + onAllowCheck, + onDenyCheck, + onOpenSettings, + onNavigateToResult, + onCancelOperation, +}: ChecksSidePanelProps) { + const [selectedCheckId, setSelectedCheckId] = useState(''); + const [isCheckTypesOpen, setIsCheckTypesOpen] = useState(false); + + /** + * Creates a unique identifier for a CheckRunResult, used to provide a unique key to the UI and to + * track which check result is selected. + */ + const writeCheckId = useMemo( + () => (result: CheckRunResult, index: number) => + `${result.checkResultUniqueId || ''}__${result.verseRef.book}_${result.verseRef.chapterNum}_${result.verseRef.verseNum}__${result.checkResultType}__${index}`, + [], + ); + + const handleSelectCheck = useCallback( + (id: string) => { + setSelectedCheckId(id); + onNavigateToResult(id); + }, + [onNavigateToResult], + ); + + const handleSelectScope = useCallback( + (newScope: string) => { + if (isValidCheckScope(newScope)) onSelectScope(newScope); + }, + [onSelectScope], + ); + + const projectOptionsGrouped = useMemo[]>(() => { + const allProjects = [...projects] + .sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' })) + .map((project) => ({ + id: project.id, + fullName: project.fullName, + shortName: project.shortName, + label: project.shortName, + secondaryLabel: project.fullName, + })); + return [ + { + groupHeading: + localizedStrings['%webView_checksSidePanel_projectFilter_projectsAndResources%'], + options: allProjects, + }, + ]; + }, [projects, localizedStrings]); + + const selectedProjectOption = useMemo( + () => + projectOptionsGrouped + .flatMap((group) => group.options) + .find((option) => option.id === selectedProjectId), + [projectOptionsGrouped, selectedProjectId], + ); + + const getScopeLabel = useCallback( + (scopeValue: string) => { + if (isValidCheckScope(scopeValue)) { + return localizedStrings[CHECK_SCOPE_FILTER_STRINGS[scopeValue]]; + } + return scopeValue; // Fallback for invalid scope values + }, + [localizedStrings], + ); + + // Helper functions for check type filter + const checkTypeEntries: MultiSelectComboBoxEntry[] = useMemo( + () => + checksInfo.map((check) => ({ + value: check.checkId, + label: check.checkName, + secondaryLabel: check.isSetup + ? undefined + : localizedStrings['%webView_checksSidePanel_checkRequiresSetup%'], + starred: false, + })), + [checksInfo, localizedStrings], + ); + + const selectedChecksCountLabel = useMemo( + () => + formatReplacementString( + localizedStrings['%webView_checksSidePanel_checkTypeFilter_countLabel%'], + { + resultsCount: selectedCheckTypeIds.length, + }, + ), + [localizedStrings, selectedCheckTypeIds], + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Check configuration */} +
+ {/* Project Filter */} + + options={projectOptionsGrouped} + value={selectedProjectOption} + onChange={(newProject) => onSelectProject(newProject.id)} + getButtonLabel={(project) => project.shortName} + buttonPlaceholder={ + localizedStrings['%webView_checksSidePanel_projectFilter_noProjectSelected%'] + } + commandEmptyMessage={ + localizedStrings['%webView_checksSidePanel_projectFilter_noProjectsFound%'] + } + ariaLabel={ + localizedStrings['%webView_checksSidePanel_projectFilter_projectsAndResources%'] + } + buttonVariant="outline" + buttonClassName="tw:flex-1 tw:min-w-32 tw:font-normal" + popoverContentClassName="tw:w-[300px]" + alignDropDown="start" + /> + + {/* Scope Filter */} + + {/* Check Type Filter */} + +
+ {/* Check results */} + {checkResults.length === 0 ? ( +
+
+ {selectedCheckTypeIds.length === 0 + ? localizedStrings['%webView_checksSidePanel_noChecksSelected%'] + : localizedStrings['%webView_checksSidePanel_noCheckResults%']} +
+ +
+ ) : ( +
+ {checkResults.map((result, index) => ( + check.checkId === result.checkId)?.isSetup ?? true + } + checkCardDescription={result.messageFormatString} + /> + ))} +
+ )} + {/* Status bar */} + {hasActiveJob && ( +
+ {/* The job is active */} + {jobStatusReport.status === 'queued' || + (jobStatusReport.status === 'running' && + // While starting up, % complete stays stuck at 0 and looks strange with the Cancel button + jobStatusReport.percentComplete > 0 && ( +
+ + +
+ ))} + {/* The job has finished but not all results are loaded into the UI yet */} + {(jobStatusReport.status === 'completed' || jobStatusReport.status === 'stopped') && + checkResults.length < jobStatusReport.totalResultsCount && ( +
+ + {checkResults.length.toString()} / {jobStatusReport.totalResultsCount.toString()} + +
+ )} + {/* The job has finished and all results are loaded into the UI */} + {(jobStatusReport.status === 'completed' || jobStatusReport.status === 'stopped') && + checkResults.length === jobStatusReport.totalResultsCount && ( +

+ {checkResults.length > 0 + ? checkResults.length.toString() + : localizedStrings['%webView_find_noResultsFound%']} +

+ )} + {/* The job encountered an error while running */} + {jobStatusReport.status === 'errored' && jobStatusReport.error && ( +

{jobStatusReport.error}

+ )} +
+ )} +
+ ); +} + +export default ChecksSidePanel; diff --git a/extensions/src/platform-scripture/src/checks/checks-side-panel/checks-side-panel.stories.tsx b/extensions/src/platform-scripture/src/checks/checks-side-panel/checks-side-panel.stories.tsx new file mode 100644 index 00000000000..a3d360b8e2b --- /dev/null +++ b/extensions/src/platform-scripture/src/checks/checks-side-panel/checks-side-panel.stories.tsx @@ -0,0 +1,276 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { SerializedVerseRef } from '@sillsdev/scripture'; +import { CheckJobStatusReport, CheckRunResult } from 'platform-scripture'; +import { useCallback, useMemo, useState } from 'react'; +import { getLocalizedStrings } from '../../../../../../.storybook/localization.utils'; +import { alertCommand, rejectingMock } from '../../../../../../.storybook/story.utils'; +import { CheckInfo, CheckScopes } from '../../checks-side-panel.utils'; +import { + ChecksSidePanel, + ChecksSidePanelProject, + CHECKS_SIDE_PANEL_STRING_KEYS, +} from './checks-side-panel.component'; + +/** + * `ChecksSidePanel` shows the results of running Scripture checks against a project, with filters + * for project / scope / check type and a status bar for the running job. In the app the webview + * owns the async check-job lifecycle (begin/poll/stop + invalidation re-runs) and feeds the panel + * the derived results, progress, and check info. These stories feed it from a thin in-memory + * service so the flow is interactive: deny/allow a result toggles its state in place, and + * navigation / settings actions announce the command the webview would run. + */ + +const localizedStrings = getLocalizedStrings([...CHECKS_SIDE_PANEL_STRING_KEYS]); + +const PROJECT_ID = 'project-web'; + +const seedProjects: ChecksSidePanelProject[] = [ + { id: 'project-web', fullName: 'World English Bible', shortName: 'WEB' }, + { id: 'project-asv', fullName: 'American Standard Version', shortName: 'ASV' }, +]; + +const seedChecksInfo: CheckInfo[] = [ + { checkId: 'capitalization', checkName: 'Capitalization', isSetup: true }, + { checkId: 'punctuation', checkName: 'Punctuation', isSetup: true }, + { checkId: 'characters', checkName: 'Characters', isSetup: false }, +]; + +/** Build a check result with sensible defaults so seed data stays terse. */ +function makeResult( + overrides: Partial & { verseRef: SerializedVerseRef }, +): CheckRunResult { + return { + checkId: 'capitalization', + checkResultType: 'capitalization', + projectId: PROJECT_ID, + verseText: '', + itemText: '', + messageFormatString: '', + isDenied: false, + start: overrides.verseRef, + end: overrides.verseRef, + ...overrides, + }; +} + +const seedResults: CheckRunResult[] = [ + makeResult({ + checkResultUniqueId: 'r1', + checkId: 'capitalization', + checkResultType: 'capitalization', + verseRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, + itemText: 'in', + messageFormatString: 'Sentence should start with a capital letter', + }), + makeResult({ + checkResultUniqueId: 'r2', + checkId: 'punctuation', + checkResultType: 'punctuation', + verseRef: { book: 'GEN', chapterNum: 1, verseNum: 3 }, + itemText: '"', + messageFormatString: 'Unmatched quotation mark', + }), + makeResult({ + checkResultUniqueId: 'r3', + checkId: 'punctuation', + checkResultType: 'punctuation', + verseRef: { book: 'GEN', chapterNum: 1, verseNum: 5 }, + itemText: ';', + messageFormatString: 'Unexpected semicolon', + isDenied: true, + }), + makeResult({ + checkResultUniqueId: 'r4', + checkId: 'characters', + checkResultType: 'characters', + verseRef: { book: 'GEN', chapterNum: 2, verseNum: 4 }, + itemText: 'ḥ', + messageFormatString: 'Character is not in the approved inventory', + }), +]; + +const completedJobReport: CheckJobStatusReport = { + jobId: 'job-1', + status: 'completed', + percentComplete: 100, + totalResultsCount: seedResults.length, + nextResults: [], + totalExecutionTimeMs: 1200, +}; + +const runningJobReport: CheckJobStatusReport = { + jobId: 'job-2', + status: 'running', + percentComplete: 45, + totalResultsCount: 8, + nextResults: [], + totalExecutionTimeMs: 400, +}; + +const NO_ACTIVE_JOB_REPORT: CheckJobStatusReport = { + jobId: '', + status: 'completed', + percentComplete: 0, + totalResultsCount: 0, + nextResults: [], + totalExecutionTimeMs: 0, +}; + +type HarnessConfig = { + /** Seed check results the in-memory service serves. */ + results?: CheckRunResult[]; + /** Check types selected up front (drives the empty-state messaging). */ + initialSelectedCheckTypeIds?: string[]; + /** The job status report the status bar reflects. */ + jobStatusReport?: CheckJobStatusReport; + /** Whether there is an active job (controls whether the status bar shows). */ + hasActiveJob?: boolean; + /** Force the loading state. */ + loading?: boolean; + /** Make allow/deny fail with a business error (for the failure story). */ + failWrites?: boolean; +}; + +/** + * Thin in-memory service container: holds the results + filter selections, mutates `isDenied` on + * allow/deny so the card updates in place, and routes editor navigation / settings to + * `alertCommand` (the different-UI actions the webview would perform). + */ +function ChecksSidePanelHarness({ config }: { config: HarnessConfig }) { + const [results, setResults] = useState(config.results ?? seedResults); + const [selectedProjectId, setSelectedProjectId] = useState(PROJECT_ID); + const [scope, setScope] = useState(CheckScopes.Chapter); + const [selectedCheckTypeIds, setSelectedCheckTypeIds] = useState( + config.initialSelectedCheckTypeIds ?? seedChecksInfo.map((check) => check.checkId), + ); + + const getLocalizedCheckDescription = useCallback( + (checkId: string) => + seedChecksInfo.find((check) => check.checkId === checkId)?.checkName ?? checkId, + [], + ); + + const setDenied = useCallback((target: CheckRunResult, isDenied: boolean) => { + setResults((prev) => + prev.map((result) => + result.checkResultUniqueId === target.checkResultUniqueId + ? { ...result, isDenied } + : result, + ), + ); + }, []); + + const failingWrite = useMemo( + () => rejectingMock('Cannot update this check result right now'), + [], + ); + + const onAllowCheck = useCallback( + async (result: CheckRunResult) => { + // The rejecting mock throws (Promise), so awaiting it surfaces the business error. + if (config.failWrites) return failingWrite(); + setDenied(result, false); + return true; + }, + [config.failWrites, failingWrite, setDenied], + ); + + const onDenyCheck = useCallback( + async (result: CheckRunResult) => { + // The rejecting mock throws (Promise), so awaiting it surfaces the business error. + if (config.failWrites) return failingWrite(); + setDenied(result, true); + return true; + }, + [config.failWrites, failingWrite, setDenied], + ); + + return ( + alertCommand('platformScripture.openCheckSettingsAndInventories')} + onNavigateToResult={(resultId) => + alertCommand('platformScriptureEditor.selectCheckResultInEditor', { resultId }) + } + onCancelOperation={() => alertCommand('platformScripture.cancelCheckJob')} + /> + ); +} + +const meta: Meta = { + title: 'Bundled Extensions/platform-scripture/ChecksSidePanel', + component: ChecksSidePanelHarness, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +function createDecorator(config: HarnessConfig) { + return function ChecksSidePanelDecorator() { + return ; + }; +} + +/** + * Populated with a mix of failed and denied results (one check requires setup). Use a card's + * dropdown to deny / allow a result and watch it toggle in place; selecting a card or opening + * settings announces the command the webview would run. + */ +export const Populated: Story = { + decorators: [createDecorator({})], +}; + +/** A check job is still running — the progress bar and Cancel button show. */ +export const Running: Story = { + decorators: [createDecorator({ jobStatusReport: runningJobReport, hasActiveJob: true })], +}; + +/** No results found yet for the selected checks — the empty state renders. */ +export const NoResults: Story = { + decorators: [ + createDecorator({ + results: [], + jobStatusReport: NO_ACTIVE_JOB_REPORT, + hasActiveJob: false, + }), + ], +}; + +/** No check types selected — the panel prompts the user to select checks. */ +export const NoChecksSelected: Story = { + decorators: [ + createDecorator({ + results: [], + initialSelectedCheckTypeIds: [], + jobStatusReport: NO_ACTIVE_JOB_REPORT, + hasActiveJob: false, + }), + ], +}; + +/** Still loading the available checks — the spinner renders. */ +export const Loading: Story = { + decorators: [createDecorator({ loading: true })], +}; + +/** Allow / deny fails with a business error so the failure path is observable. */ +export const WriteFailure: Story = { + decorators: [createDecorator({ failWrites: true })], +}; From 9272ed534315afc120abdd77be615bd8d861228c Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 15:33:34 +0200 Subject: [PATCH 14/23] split find webview into presentational component + interactive story Extract a presentational Find component from the find/replace webview and add an interactive story. The find-job lifecycle, replace/revert, version-history commits, and external-change detection stay in the webview; the component is fed via props. - Find component (pure, no @papi): search input + recent searches + scope selector + FindFilters + find/replace mode toggle + replace row + grouped results list + progress/empty states. Owns presentational derivations + keyboard result navigation, calling back for every action. - Make the result children @papi-free: search-result drops its logger (keeps the graceful catch); search-results-in-book takes a getBookUsj callback instead of calling useProjectData, building the UsjReaderWriter from the result. - webview: keeps all orchestration; renders with the state/callbacks; getBookUsj via the imperative papi.projectDataProviders.get(...).getBookUSJ API. - story: thin in-memory service seeds results across two books with verse context; replace/replace-all/cancel/hide mutate the seed so the UI reflects it; commit/editor-nav announced via alertCommand. Populated leads; ReplaceMode, InProgress, NoResults, Replaced follow. Verified: typecheck, extensions lint, and storybook:build all clean. Co-Authored-By: Claude Code --- .../platform-scripture/src/find.web-view.tsx | 626 ++------------- .../src/find/find.component.tsx | 746 ++++++++++++++++++ .../src/find/find.stories.tsx | 335 ++++++++ .../src/find/search-result.component.tsx | 13 +- .../find/search-results-in-book.component.tsx | 64 +- 5 files changed, 1192 insertions(+), 592 deletions(-) create mode 100644 extensions/src/platform-scripture/src/find/find.component.tsx create mode 100644 extensions/src/platform-scripture/src/find/find.stories.tsx diff --git a/extensions/src/platform-scripture/src/find.web-view.tsx b/extensions/src/platform-scripture/src/find.web-view.tsx index 66f4af16d10..8bf58e108ab 100644 --- a/extensions/src/platform-scripture/src/find.web-view.tsx +++ b/extensions/src/platform-scripture/src/find.web-view.tsx @@ -6,43 +6,9 @@ import { useProjectSetting, useWebViewController, } from '@papi/frontend/react'; +import { Usj } from '@eten-tech-foundation/scripture-utilities'; import { Canon, SerializedVerseRef } from '@sillsdev/scripture'; -import { - ArrowRight, - ChevronDown, - ChevronUp, - Info, - Replace, - ReplaceAll, - TextSearch, - X, -} from 'lucide-react'; -import { - Button, - Card, - CardContent, - Checkbox, - Input, - Label, - Popover, - PopoverContent, - PopoverTrigger, - Progress, - RecentSearches, - Scope, - SCOPE_SELECTOR_STRING_KEYS, - ScopeSelector, - Skeleton, - Sonner, - sonner, - ToggleGroup, - ToggleGroupItem, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, - useRecentSearches, -} from 'platform-bible-react'; +import { Scope, SCOPE_SELECTOR_STRING_KEYS, sonner, useRecentSearches } from 'platform-bible-react'; import { debounce, formatReplacementString, @@ -61,56 +27,28 @@ import { WordRestriction, } from 'platform-scripture'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { FindFilters } from './find/find-filters.component'; +import { Find, FIND_LOCALIZED_STRING_KEYS } from './find/find.component'; import { LocalizedBookData, SearchTextType } from './find/find-types'; import { HidableFindResult, SEARCH_RESULT_LOCALIZED_STRING_KEYS, } from './find/search-result.component'; -import { SearchResultsInBook } from './find/search-results-in-book.component'; -const LOCALIZED_STRINGS: LocalizeKey[] = [ - '%general_countOfTotal%', +// Strings used by the webview's own replace / version-history-commit / toast logic, in addition to +// the strings the presentational Find component needs (FIND_LOCALIZED_STRING_KEYS). +const WEB_VIEW_LOCALIZED_STRINGS: LocalizeKey[] = [ '%versionHistoryCommit_beforeReplace%', '%versionHistoryCommit_beforeReplace_failureMessage%', '%versionHistoryCommit_afterReplace%', - '%webView_find_allText%', - '%webView_find_allText_tooltip%', - '%webView_find_allowRegex%', - '%webView_find_cancelSearch%', - '%webView_find_capitalization%', - '%webView_find_errorOccurred%', '%webView_find_findInProject%', - '%webView_find_findTab%', - '%webView_find_matchCase%', - '%webView_find_matchContentIn%', - '%webView_find_nextResult%', - '%webView_find_noResultsFound%', - '%webView_find_pattern%', - '%webView_find_preserveCase%', - '%webView_find_preserveCase_tooltip%', - '%webView_find_previousResult%', - '%webView_find_recent%', - '%webView_find_replace%', - '%webView_find_replaceAll%', '%webView_find_replacedOneOccurrence%', '%webView_find_replacedNOccurrences%', '%webView_find_replacementReverted%', - '%webView_find_replaceTab%', - '%webView_find_replaceTerm_placeholder%', - '%webView_find_restrictions%', - '%webView_find_restrictions_endOfWord%', - '%webView_find_restrictions_none%', - '%webView_find_restrictions_startOfWord%', - '%webView_find_restrictions_wholeWord%', - '%webView_find_result%', - '%webView_find_searchPlaceholder%', - '%webView_find_showing%', - '%webView_find_showingResults%', - '%webView_find_showingResultsOfMore%', - '%webView_find_showRecentSearches%', - '%webView_find_toggleFilters%', - '%webView_find_verseTextOnly%', +]; + +const LOCALIZED_STRINGS: LocalizeKey[] = [ + ...FIND_LOCALIZED_STRING_KEYS, + ...WEB_VIEW_LOCALIZED_STRINGS, ]; const defaultBooksPresent: string = ''; @@ -245,9 +183,6 @@ global.webViewComponent = function FindWebView({ const resultsRef = useRef([]); resultsRef.current = results; const loadedResultsLengthRef = useRef(0); - // useRef requires null as the initial value when used with a DOM element ref - // eslint-disable-next-line no-null/no-null - const resultsContainerRef = useRef(null); const [numberOfHiddenResults, setNumberOfHiddenResults] = useState(0); const [focusedResultIndex, setFocusedResultIndex] = useState(undefined); @@ -1241,483 +1176,76 @@ global.webViewComponent = function FindWebView({ pendingReplaceRevertRef.current?.cancel(); }, []); - const visibleResults = useMemo( - () => - results - .map((result, index) => ({ result, originalIndex: index })) - .filter(({ result }) => !result.isHidden), - [results], - ); - - const focusedVisibleIndex = useMemo( - () => - focusedResultIndex === undefined - ? -1 - : visibleResults.findIndex((vr) => vr.originalIndex === focusedResultIndex), - [visibleResults, focusedResultIndex], - ); - - const handlePreviousResult = useCallback(() => { - if (visibleResults.length === 0) return; - if (focusedVisibleIndex <= 0) { - // No result focused (index -1) or already at first → wrap to last - // eslint-disable-next-line no-type-assertion/no-type-assertion - const last = visibleResults.at(-1)!; - handleFocusedResultChange(last.result, last.originalIndex); - return; - } - const prev = visibleResults[focusedVisibleIndex - 1]; - handleFocusedResultChange(prev.result, prev.originalIndex); - }, [focusedVisibleIndex, visibleResults, handleFocusedResultChange]); - - const handleNextResult = useCallback(() => { - if (visibleResults.length === 0) return; - if (focusedVisibleIndex >= visibleResults.length - 1) { - // Already at last result → wrap to first - handleFocusedResultChange(visibleResults[0].result, visibleResults[0].originalIndex); - return; - } - const next = visibleResults[focusedVisibleIndex + 1]; - handleFocusedResultChange(next.result, next.originalIndex); - }, [focusedVisibleIndex, visibleResults, handleFocusedResultChange]); - - const handleFirstResult = useCallback(() => { - if (visibleResults.length === 0) return; - handleFocusedResultChange(visibleResults[0].result, visibleResults[0].originalIndex); - }, [visibleResults, handleFocusedResultChange]); - - const handleLastResult = useCallback(() => { - if (visibleResults.length === 0) return; - // `at(-1)` returns `undefined` only on an empty array; the early return above guarantees - // that the array is non-empty - // eslint-disable-next-line no-type-assertion/no-type-assertion - const last = visibleResults.at(-1)!; - handleFocusedResultChange(last.result, last.originalIndex); - }, [visibleResults, handleFocusedResultChange]); - - const getPageSize = useCallback(() => { - const container = resultsContainerRef.current; - if (!container) return 1; - const containerRect = container.getBoundingClientRect(); - const cards = container.querySelectorAll('[role="button"]:not([hidden])'); - const count = Array.from(cards).filter((card) => { - const rect = card.getBoundingClientRect(); - return rect.bottom > containerRect.top && rect.top < containerRect.bottom; - }).length; - return Math.max(1, count); - }, []); - - const handlePageUpResult = useCallback(() => { - if (visibleResults.length === 0) return; - const pageSize = getPageSize(); - const currentIndex = Math.max(0, focusedVisibleIndex); - const newIndex = Math.max(0, currentIndex - pageSize); - const target = visibleResults[newIndex]; - handleFocusedResultChange(target.result, target.originalIndex); - }, [focusedVisibleIndex, visibleResults, handleFocusedResultChange, getPageSize]); - - const handlePageDownResult = useCallback(() => { - if (visibleResults.length === 0) return; - const pageSize = getPageSize(); - const currentIndex = Math.max(0, focusedVisibleIndex); - const newIndex = Math.min(visibleResults.length - 1, currentIndex + pageSize); - const target = visibleResults[newIndex]; - handleFocusedResultChange(target.result, target.originalIndex); - }, [focusedVisibleIndex, visibleResults, handleFocusedResultChange, getPageSize]); - - const handleResultsKeyDown = useCallback( - (e: React.KeyboardEvent) => { - switch (e.key) { - case 'ArrowUp': - e.preventDefault(); - handlePreviousResult(); - break; - case 'ArrowDown': - e.preventDefault(); - handleNextResult(); - break; - case 'Home': - e.preventDefault(); - handleFirstResult(); - break; - case 'End': - e.preventDefault(); - handleLastResult(); - break; - case 'PageUp': - e.preventDefault(); - handlePageUpResult(); - break; - case 'PageDown': - e.preventDefault(); - handlePageDownResult(); - break; - default: - break; + // Retrieves the USJ for a book so the search-result cards can compute verse context. Reads the + // USJ_Book project data provider imperatively (rather than via a hook) so the presentational Find + // component stays free of `@papi`. + const getBookUsj = useCallback( + async (bookId: string): Promise => { + if (!projectId) return undefined; + try { + const usjBookPdp = await papi.projectDataProviders.get( + 'platformScripture.USJ_Book', + projectId, + ); + return await usjBookPdp.getBookUSJ({ book: bookId, chapterNum: 1, verseNum: 0 }); + } catch (error) { + logger.warn( + `Error retrieving USJ Book ${bookId} for search results: ${getErrorMessage(error)}`, + ); + return undefined; } }, - [ - handlePreviousResult, - handleNextResult, - handleFirstResult, - handleLastResult, - handlePageUpResult, - handlePageDownResult, - ], + [projectId], ); - const areFiltersActive = - shouldMatchCase || wordRestriction !== 'none' || searchTextType !== 'all' || isRegexAllowed; - - const resultsMessage = useMemo(() => { - if (!results) return ''; - if (results.length === 0) { - return localizedStrings['%webView_find_noResultsFound%']; - } - const l10nKey = - searchStatus === 'exceeded' - ? '%webView_find_showingResultsOfMore%' - : (numberOfHiddenResults > 0 && '%webView_find_showingResults%') || '%webView_find_result%'; - - return formatReplacementString(localizedStrings[l10nKey], { - visibleNumber: (results.length - numberOfHiddenResults).toString(), - totalNumber: totalNumberOfResults.toString(), - }); - }, [results, numberOfHiddenResults, totalNumberOfResults, searchStatus, localizedStrings]); - - /** Text shown in the scope popover trigger, e.g. "Genesis 1" or "Genesis, Exodus, John" */ - const scopeDisplayText = useMemo(() => { - switch (scope) { - case 'chapter': { - const bookName = - localizedBookData.get(verseRefSetting.book)?.localizedId ?? verseRefSetting.book; - return `${bookName} ${verseRefSetting.chapterNum}`; - } - case 'book': - return localizedBookData.get(verseRefSetting.book)?.localizedId ?? verseRefSetting.book; - case 'selectedBooks': - if (selectedBookIds.length === 0) return '…'; - return selectedBookIds.map((id) => localizedBookData.get(id)?.localizedId ?? id).join(', '); - default: - return ''; - } - }, [scope, selectedBookIds, verseRefSetting, localizedBookData]); - return ( -
- {/* Header with searchbar and filters */} -
- {/* Find/Replace mode toggle */} - { - if (value === 'find' || value === 'replace') setActiveMode(value); - }} - className="tw:w-fit tw:rounded-lg tw:bg-muted tw:p-1" - > - - {localizedStrings['%webView_find_findTab%']} - - - {localizedStrings['%webView_find_replaceTab%']} - - - - {/* Find input row */} -
-
- - setSearchTerm(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleStartSearch(true); - } - }} - placeholder={localizedStrings['%webView_find_searchPlaceholder%']} - className={`tw:w-full tw:min-w-16 tw:text-ellipsis tw:!pl-8 scripture-font ${searchTerm ? 'tw:!pe-8' : 'tw:!pr-4'}`} - /> - {searchTerm && ( - - )} -
- - - -
- - {/* Replace input row — shown in Replace mode */} - {activeMode === 'replace' && ( - <> -
- - setReplaceTerm(e.target.value)} - placeholder={localizedStrings['%webView_find_replaceTerm_placeholder%']} - className="tw:w-full tw:min-w-16 tw:!pl-8 tw:!pr-4 scripture-font" - /> -
-
-
- setPreserveCase(checked === true)} - /> - - - - - - - -

- {localizedStrings['%webView_find_preserveCase_tooltip%']} -

-
-
-
-
-
- - -
-
- - )} - - {/* Scope selector row */} -
- - - - - - - - - {visibleResults.length > 0 && ( -
- - {formatReplacementString(localizedStrings['%general_countOfTotal%'], { - count: focusedVisibleIndex >= 0 ? String(focusedVisibleIndex + 1) : '–', - total: String(visibleResults.length), - })} - - - -
- )} -
-
- - {/* Search Results Placeholder */} - {results && results.length === 0 && searchStatus === 'running' && ( -
- {Array.from({ length: 5 }).map((_value, index) => ( - // As this is a placeholder, it is safe to use the index as a key - // eslint-disable-next-line react/no-array-index-key - - -
- - -
-
-
- ))} -
- )} - - {/* Search Results */} - {/* This div is a scroll container that handles keyboard navigation (arrow keys) between search - results. It needs onKeyDown for result navigation and onScroll for progressive loading, but - it has no single semantic ARIA role (it's not a listbox, grid, etc.) that would satisfy the - rule without being misleading. The child result rows are the interactive elements. */} - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} -
- {(() => { - // Only the first book that has a replaced result gets the cancel handler. - // All replaced rows share one pending operation, so only one Cancel button - // should appear to avoid implying per-row granularity. - let cancelHandlerAssigned = false; - return [...resultsByBook.entries()].map(([bookId, bookResults]) => { - const bookHasReplaced = bookResults.some(({ result }) => result.isReplaced); - const cancelReplace = - !cancelHandlerAssigned && bookHasReplaced ? handleCancelReplace : undefined; - if (cancelReplace) cancelHandlerAssigned = true; - return ( - result)} - localizedBookData={localizedBookData} - focusedResultIndex={bookResults.findIndex( - ({ originalIndex }) => originalIndex === focusedResultIndex, - )} - onResultClick={(result, indexInBookResults) => - handleFocusedResultChange(result, bookResults[indexInBookResults].originalIndex) - } - onHideResult={(indexInBookResults) => - handleHideResult(bookResults[indexInBookResults].originalIndex) - } - onReplace={(indexInBookResults) => - handleReplace(bookResults[indexInBookResults].originalIndex) - } - onCancelReplace={cancelReplace} - localizedStrings={searchResultLocalizedStrings} - isReplaceMode={activeMode === 'replace'} - isReplacing={isReplacing} - /> - ); - }); - })()} -
- - {/* Status bar */} - {searchStatus && ( -
- {searchStatus === 'running' && (activeMode !== 'replace' || !isPostReplaceSearch) && ( -
- - -
- )} - {(searchStatus === 'completed' || - searchStatus === 'stopped' || - searchStatus === 'exceeded') && - results &&

{resultsMessage}

} - {searchStatus === 'errored' && searchError && ( -

- {formatReplacementString(localizedStrings['%webView_find_errorOccurred%'], { - error: searchError, - })} -

- )} -
- )} - -
+ ); }; diff --git a/extensions/src/platform-scripture/src/find/find.component.tsx b/extensions/src/platform-scripture/src/find/find.component.tsx new file mode 100644 index 00000000000..a8f03ba37b0 --- /dev/null +++ b/extensions/src/platform-scripture/src/find/find.component.tsx @@ -0,0 +1,746 @@ +import { Usj } from '@eten-tech-foundation/scripture-utilities'; +import { SerializedVerseRef } from '@sillsdev/scripture'; +import { + ArrowRight, + ChevronDown, + ChevronUp, + Info, + Replace, + ReplaceAll, + TextSearch, + X, +} from 'lucide-react'; +import { + Button, + Card, + CardContent, + Checkbox, + Input, + Label, + Popover, + PopoverContent, + PopoverTrigger, + Progress, + RecentSearches, + Scope, + SCOPE_SELECTOR_STRING_KEYS, + ScopeSelector, + Skeleton, + Sonner, + ToggleGroup, + ToggleGroupItem, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from 'platform-bible-react'; +import { + formatReplacementString, + LanguageStrings, + LocalizedStringValue, +} from 'platform-bible-utils'; +import { FindJobStatus, WordRestriction } from 'platform-scripture'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { FindFilters } from './find-filters.component'; +import { LocalizedBookData, SearchTextType } from './find-types'; +import { HidableFindResult, SEARCH_RESULT_LOCALIZED_STRING_KEYS } from './search-result.component'; +import { SearchResultsInBook } from './search-results-in-book.component'; + +/** Localization keys used by the {@link Find} component itself (excludes child component keys). */ +export const FIND_LOCALIZED_STRING_KEYS = [ + '%general_countOfTotal%', + '%webView_find_allText%', + '%webView_find_allText_tooltip%', + '%webView_find_allowRegex%', + '%webView_find_cancelSearch%', + '%webView_find_capitalization%', + '%webView_find_errorOccurred%', + '%webView_find_findTab%', + '%webView_find_matchCase%', + '%webView_find_matchContentIn%', + '%webView_find_nextResult%', + '%webView_find_noResultsFound%', + '%webView_find_pattern%', + '%webView_find_preserveCase%', + '%webView_find_preserveCase_tooltip%', + '%webView_find_previousResult%', + '%webView_find_recent%', + '%webView_find_replace%', + '%webView_find_replaceAll%', + '%webView_find_replaceTab%', + '%webView_find_replaceTerm_placeholder%', + '%webView_find_restrictions%', + '%webView_find_restrictions_endOfWord%', + '%webView_find_restrictions_none%', + '%webView_find_restrictions_startOfWord%', + '%webView_find_restrictions_wholeWord%', + '%webView_find_result%', + '%webView_find_searchPlaceholder%', + '%webView_find_showing%', + '%webView_find_showingResults%', + '%webView_find_showingResultsOfMore%', + '%webView_find_showRecentSearches%', + '%webView_find_toggleFilters%', + '%webView_find_verseTextOnly%', +] as const; + +/** + * A search result paired with its index in the complete (ungrouped) results array, as produced by + * grouping the results by book. + */ +export type BookResultEntry = { result: HidableFindResult; originalIndex: number }; + +/** Props for the {@link Find} presentational component. */ +export type FindProps = { + /** Localized strings for the find/replace UI; resolve via {@link FIND_LOCALIZED_STRING_KEYS}. */ + localizedStrings: LanguageStrings; + /** Localized strings for the {@link ScopeSelector}; resolve via `SCOPE_SELECTOR_STRING_KEYS`. */ + scopeSelectorLocalizedStrings: LanguageStrings; + /** + * Localized strings for the search-result cards; resolve via + * `SEARCH_RESULT_LOCALIZED_STRING_KEYS`. + */ + searchResultLocalizedStrings: { + [localizedKey in (typeof SEARCH_RESULT_LOCALIZED_STRING_KEYS)[number]]?: LocalizedStringValue; + }; + + // Search/replace input + filter state + /** The current search term. */ + searchTerm: string; + /** Recent search terms shown in the recent-searches dropdown. */ + recentSearches: string[]; + /** The currently selected scope (chapter/book/selectedBooks). */ + scope: Scope; + /** The current scroll-group verse ref, used to label the chapter/book scope (e.g. "Genesis 1"). */ + verseRef: SerializedVerseRef; + /** The string of present books (from the `booksPresent` project setting) for the scope selector. */ + booksPresent: string; + /** Ids of the books selected for the `selectedBooks` scope. */ + selectedBookIds: string[]; + /** Map of available book ids to their localized display names. */ + localizedBookData: Map; + /** Whether to match case in the search. */ + shouldMatchCase: boolean; + /** Which text to match (all text / verse text only). */ + searchTextType: SearchTextType; + /** The word-boundary restriction for matches. */ + wordRestriction: WordRestriction; + /** Whether the search string is treated as a regular expression. */ + isRegexAllowed: boolean; + + // Mode + replace state + /** Whether the UI is in find or replace mode. */ + activeMode: 'find' | 'replace'; + /** The replacement term entered in replace mode. */ + replaceTerm: string; + /** Whether to preserve the case of the matched text when replacing. */ + preserveCase: boolean; + /** True while a replace operation (and its mandatory re-find) is executing. */ + isReplacing: boolean; + + // Results state + /** All current search results (including hidden/replaced ones). */ + results: HidableFindResult[]; + /** Search results grouped by book id, each paired with its original index. */ + resultsByBook: Map; + /** The index (into `results`) of the focused result, or `undefined`. */ + focusedResultIndex: number | undefined; + /** The current find-job status, or `undefined` when no search has run. */ + searchStatus: FindJobStatus | undefined; + /** The find-job error message, if the status is `errored`. */ + searchError: string | undefined; + /** Percent complete of the running search (0-100). */ + searchProgress: number; + /** Total number of results the job reports (may exceed loaded results). */ + totalNumberOfResults: number; + /** Number of results the user has hidden/dismissed. */ + numberOfHiddenResults: number; + /** + * Whether the current search was auto-triggered after a replace. Used to suppress the progress + * bar for that housekeeping search. + */ + isPostReplaceSearch: boolean; + + // Action callbacks + /** Called when the search term changes. */ + onSearchTermChange: (term: string) => void; + /** Called to start a search. `isExplicitSearch` is true for Enter/Find-button-initiated searches. */ + onStartSearch: (isExplicitSearch?: boolean) => void; + /** Called to stop the current search. `shouldClearResults` clears results and resets state. */ + onStopSearch: (shouldClearResults?: boolean) => void; + /** Called when the user changes the scope. */ + setScope: (scope: Scope) => void; + /** Called when the selected books for the `selectedBooks` scope change. */ + onSelectedBookIdsChange: (bookIds: string[]) => void; + /** Called when the match-content-in (text type) filter changes. */ + setSearchTextType: (value: SearchTextType) => void; + /** Called when the word-restriction filter changes. */ + setWordRestriction: (value: WordRestriction) => void; + /** Called when the match-case filter changes. */ + setShouldMatchCase: (value: boolean) => void; + /** Called when the allow-regex filter changes. */ + setIsRegexAllowed: (value: boolean) => void; + /** Called when the user toggles find/replace mode. */ + onToggleMode: (mode: 'find' | 'replace') => void; + /** Called when the replacement term changes. */ + onReplaceTermChange: (term: string) => void; + /** Called when the preserve-case checkbox changes. */ + onPreserveCaseChange: (value: boolean) => void; + /** Called when the user focuses a result (by clicking or keyboard navigation). */ + onFocusedResultChange: (searchResult: HidableFindResult, index: number) => void; + /** Called when the user hides/dismisses a result, by its original index. */ + onHideResult: (index: number) => void; + /** Called when the user replaces a single result, by its original index (defaults to focused). */ + onReplace: (resultIndex?: number) => void; + /** Called when the user replaces all visible results. */ + onReplaceAll: () => void; + /** Called to cancel/revert the pending replace operation. */ + onCancelReplace: () => void; + /** Called when the results container scrolls (drives progressive loading). */ + onResultsScroll: (event: React.UIEvent) => void; + /** Retrieves the USJ for a book so each result's verse context can be computed. */ + getBookUsj: (bookId: string) => Promise; +}; + +/** + * Presentational find/replace UI. It owns the rendering and the presentational derivations (visible + * results, focused-result navigation, scope display text, results message) but no async logic. The + * container (webview or story) owns the find-job lifecycle, replace/revert, version-history + * commits, and editor navigation, passing data in as props and operations in as callbacks. + */ +export function Find({ + localizedStrings, + scopeSelectorLocalizedStrings, + searchResultLocalizedStrings, + searchTerm, + recentSearches, + scope, + verseRef, + booksPresent, + selectedBookIds, + localizedBookData, + shouldMatchCase, + searchTextType, + wordRestriction, + isRegexAllowed, + activeMode, + replaceTerm, + preserveCase, + isReplacing, + results, + resultsByBook, + focusedResultIndex, + searchStatus, + searchError, + searchProgress, + totalNumberOfResults, + numberOfHiddenResults, + isPostReplaceSearch, + onSearchTermChange, + onStartSearch, + onStopSearch, + setScope, + onSelectedBookIdsChange, + setSearchTextType, + setWordRestriction, + setShouldMatchCase, + setIsRegexAllowed, + onToggleMode, + onReplaceTermChange, + onPreserveCaseChange, + onFocusedResultChange, + onHideResult, + onReplace, + onReplaceAll, + onCancelReplace, + onResultsScroll, + getBookUsj, +}: FindProps) { + // useRef requires null as the initial value when used with a DOM element ref + // eslint-disable-next-line no-null/no-null + const resultsContainerRef = useRef(null); + + const areFiltersActive = + shouldMatchCase || wordRestriction !== 'none' || searchTextType !== 'all' || isRegexAllowed; + + const visibleResults = useMemo( + () => + results + .map((result, index) => ({ result, originalIndex: index })) + .filter(({ result }) => !result.isHidden), + [results], + ); + + const focusedVisibleIndex = useMemo( + () => + focusedResultIndex === undefined + ? -1 + : visibleResults.findIndex((vr) => vr.originalIndex === focusedResultIndex), + [visibleResults, focusedResultIndex], + ); + + const handlePreviousResult = useCallback(() => { + if (visibleResults.length === 0) return; + if (focusedVisibleIndex <= 0) { + // No result focused (index -1) or already at first → wrap to last + // eslint-disable-next-line no-type-assertion/no-type-assertion + const last = visibleResults.at(-1)!; + onFocusedResultChange(last.result, last.originalIndex); + return; + } + const prev = visibleResults[focusedVisibleIndex - 1]; + onFocusedResultChange(prev.result, prev.originalIndex); + }, [focusedVisibleIndex, visibleResults, onFocusedResultChange]); + + const handleNextResult = useCallback(() => { + if (visibleResults.length === 0) return; + if (focusedVisibleIndex >= visibleResults.length - 1) { + // Already at last result → wrap to first + onFocusedResultChange(visibleResults[0].result, visibleResults[0].originalIndex); + return; + } + const next = visibleResults[focusedVisibleIndex + 1]; + onFocusedResultChange(next.result, next.originalIndex); + }, [focusedVisibleIndex, visibleResults, onFocusedResultChange]); + + const handleFirstResult = useCallback(() => { + if (visibleResults.length === 0) return; + onFocusedResultChange(visibleResults[0].result, visibleResults[0].originalIndex); + }, [visibleResults, onFocusedResultChange]); + + const handleLastResult = useCallback(() => { + if (visibleResults.length === 0) return; + // `at(-1)` returns `undefined` only on an empty array; the early return above guarantees + // that the array is non-empty + // eslint-disable-next-line no-type-assertion/no-type-assertion + const last = visibleResults.at(-1)!; + onFocusedResultChange(last.result, last.originalIndex); + }, [visibleResults, onFocusedResultChange]); + + const getPageSize = useCallback(() => { + const container = resultsContainerRef.current; + if (!container) return 1; + const containerRect = container.getBoundingClientRect(); + const cards = container.querySelectorAll('[role="button"]:not([hidden])'); + const count = Array.from(cards).filter((card) => { + const rect = card.getBoundingClientRect(); + return rect.bottom > containerRect.top && rect.top < containerRect.bottom; + }).length; + return Math.max(1, count); + }, []); + + const handlePageUpResult = useCallback(() => { + if (visibleResults.length === 0) return; + const pageSize = getPageSize(); + const currentIndex = Math.max(0, focusedVisibleIndex); + const newIndex = Math.max(0, currentIndex - pageSize); + const target = visibleResults[newIndex]; + onFocusedResultChange(target.result, target.originalIndex); + }, [focusedVisibleIndex, visibleResults, onFocusedResultChange, getPageSize]); + + const handlePageDownResult = useCallback(() => { + if (visibleResults.length === 0) return; + const pageSize = getPageSize(); + const currentIndex = Math.max(0, focusedVisibleIndex); + const newIndex = Math.min(visibleResults.length - 1, currentIndex + pageSize); + const target = visibleResults[newIndex]; + onFocusedResultChange(target.result, target.originalIndex); + }, [focusedVisibleIndex, visibleResults, onFocusedResultChange, getPageSize]); + + const handleResultsKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + handlePreviousResult(); + break; + case 'ArrowDown': + e.preventDefault(); + handleNextResult(); + break; + case 'Home': + e.preventDefault(); + handleFirstResult(); + break; + case 'End': + e.preventDefault(); + handleLastResult(); + break; + case 'PageUp': + e.preventDefault(); + handlePageUpResult(); + break; + case 'PageDown': + e.preventDefault(); + handlePageDownResult(); + break; + default: + break; + } + }, + [ + handlePreviousResult, + handleNextResult, + handleFirstResult, + handleLastResult, + handlePageUpResult, + handlePageDownResult, + ], + ); + + const resultsMessage = useMemo(() => { + if (!results) return ''; + if (results.length === 0) { + return localizedStrings['%webView_find_noResultsFound%']; + } + const l10nKey = + searchStatus === 'exceeded' + ? '%webView_find_showingResultsOfMore%' + : (numberOfHiddenResults > 0 && '%webView_find_showingResults%') || '%webView_find_result%'; + + return formatReplacementString(localizedStrings[l10nKey], { + visibleNumber: (results.length - numberOfHiddenResults).toString(), + totalNumber: totalNumberOfResults.toString(), + }); + }, [results, numberOfHiddenResults, totalNumberOfResults, searchStatus, localizedStrings]); + + /** Text shown in the scope popover trigger, e.g. "Genesis 1" or "Genesis, Exodus, John" */ + const scopeDisplayText = useMemo(() => { + switch (scope) { + case 'chapter': { + const bookName = localizedBookData.get(verseRef.book)?.localizedId ?? verseRef.book; + return `${bookName} ${verseRef.chapterNum}`; + } + case 'book': + return localizedBookData.get(verseRef.book)?.localizedId ?? verseRef.book; + case 'selectedBooks': + if (selectedBookIds.length === 0) return '…'; + return selectedBookIds.map((id) => localizedBookData.get(id)?.localizedId ?? id).join(', '); + default: + return ''; + } + }, [scope, selectedBookIds, verseRef, localizedBookData]); + + return ( +
+ {/* Header with searchbar and filters */} +
+ {/* Find/Replace mode toggle */} + { + if (value === 'find' || value === 'replace') onToggleMode(value); + }} + className="tw:w-fit tw:rounded-lg tw:bg-muted tw:p-1" + > + + {localizedStrings['%webView_find_findTab%']} + + + {localizedStrings['%webView_find_replaceTab%']} + + + + {/* Find input row */} +
+
+ + onSearchTermChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onStartSearch(true); + } + }} + placeholder={localizedStrings['%webView_find_searchPlaceholder%']} + className={`tw:w-full tw:min-w-16 tw:text-ellipsis tw:!pl-8 scripture-font ${searchTerm ? 'tw:!pe-8' : 'tw:!pr-4'}`} + /> + {searchTerm && ( + + )} +
+ + + +
+ + {/* Replace input row — shown in Replace mode */} + {activeMode === 'replace' && ( + <> +
+ + onReplaceTermChange(e.target.value)} + placeholder={localizedStrings['%webView_find_replaceTerm_placeholder%']} + className="tw:w-full tw:min-w-16 tw:!pl-8 tw:!pr-4 scripture-font" + /> +
+
+
+ onPreserveCaseChange(checked === true)} + /> + + + + + + + +

+ {localizedStrings['%webView_find_preserveCase_tooltip%']} +

+
+
+
+
+
+ + +
+
+ + )} + + {/* Scope selector row */} +
+ + + + + + + + + {visibleResults.length > 0 && ( +
+ + {formatReplacementString(localizedStrings['%general_countOfTotal%'], { + count: focusedVisibleIndex >= 0 ? String(focusedVisibleIndex + 1) : '–', + total: String(visibleResults.length), + })} + + + +
+ )} +
+
+ + {/* Search Results Placeholder */} + {results && results.length === 0 && searchStatus === 'running' && ( +
+ {Array.from({ length: 5 }).map((_value, index) => ( + // As this is a placeholder, it is safe to use the index as a key + // eslint-disable-next-line react/no-array-index-key + + +
+ + +
+
+
+ ))} +
+ )} + + {/* Search Results */} + {/* This div is a scroll container that handles keyboard navigation (arrow keys) between search + results. It needs onKeyDown for result navigation and onScroll for progressive loading, but + it has no single semantic ARIA role (it's not a listbox, grid, etc.) that would satisfy the + rule without being misleading. The child result rows are the interactive elements. */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ {(() => { + // Only the first book that has a replaced result gets the cancel handler. + // All replaced rows share one pending operation, so only one Cancel button + // should appear to avoid implying per-row granularity. + let cancelHandlerAssigned = false; + return [...resultsByBook.entries()].map(([bookId, bookResults]) => { + const bookHasReplaced = bookResults.some(({ result }) => result.isReplaced); + const cancelReplace = + !cancelHandlerAssigned && bookHasReplaced ? onCancelReplace : undefined; + if (cancelReplace) cancelHandlerAssigned = true; + return ( + result)} + localizedBookData={localizedBookData} + focusedResultIndex={bookResults.findIndex( + ({ originalIndex }) => originalIndex === focusedResultIndex, + )} + onResultClick={(result, indexInBookResults) => + onFocusedResultChange(result, bookResults[indexInBookResults].originalIndex) + } + onHideResult={(indexInBookResults) => + onHideResult(bookResults[indexInBookResults].originalIndex) + } + onReplace={(indexInBookResults) => + onReplace(bookResults[indexInBookResults].originalIndex) + } + onCancelReplace={cancelReplace} + localizedStrings={searchResultLocalizedStrings} + isReplaceMode={activeMode === 'replace'} + isReplacing={isReplacing} + /> + ); + }); + })()} +
+ + {/* Status bar */} + {searchStatus && ( +
+ {searchStatus === 'running' && (activeMode !== 'replace' || !isPostReplaceSearch) && ( +
+ + +
+ )} + {(searchStatus === 'completed' || + searchStatus === 'stopped' || + searchStatus === 'exceeded') && + results &&

{resultsMessage}

} + {searchStatus === 'errored' && searchError && ( +

+ {formatReplacementString(localizedStrings['%webView_find_errorOccurred%'], { + error: searchError, + })} +

+ )} +
+ )} + +
+ ); +} + +export default Find; + +// Re-export the scope-selector key constant so the webview/story can resolve those strings. +export { SCOPE_SELECTOR_STRING_KEYS }; diff --git a/extensions/src/platform-scripture/src/find/find.stories.tsx b/extensions/src/platform-scripture/src/find/find.stories.tsx new file mode 100644 index 00000000000..ba38b209581 --- /dev/null +++ b/extensions/src/platform-scripture/src/find/find.stories.tsx @@ -0,0 +1,335 @@ +import { Usj, usxStringToUsj } from '@eten-tech-foundation/scripture-utilities'; +import { SerializedVerseRef } from '@sillsdev/scripture'; +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { Scope, SCOPE_SELECTOR_STRING_KEYS } from 'platform-bible-react'; +import { + Canon, + USFM_MARKERS_MAP_PARATEXT_3_0, + UsjReaderWriter, + type UsfmVerseRefVerseLocation, +} from 'platform-bible-utils'; +import { FindJobStatus, WordRestriction } from 'platform-scripture'; +import { useCallback, useMemo, useState } from 'react'; +import { getLocalizedStrings } from '../../../../../.storybook/localization.utils'; +import { alertCommand } from '../../../../../.storybook/story.utils'; +import { Find, FIND_LOCALIZED_STRING_KEYS, type BookResultEntry } from './find.component'; +import { LocalizedBookData, SearchTextType } from './find-types'; +import { HidableFindResult, SEARCH_RESULT_LOCALIZED_STRING_KEYS } from './search-result.component'; + +/** + * `Find` is the find/replace UI for a Scripture project: a search input with recent searches, a + * scope selector, filters, a find/replace mode toggle, and a results list grouped by book. In the + * app the webview owns the find-job lifecycle (begin/poll/stop), replace-with-revert, + * version-history commits, and editor navigation, feeding the panel the derived results and + * progress. These stories feed it from a thin in-memory service so the flow is interactive: replace + * mutates the seed results in place, the scope/filters/mode are driven by local state, and editor + * navigation announces the command the webview would run. + */ + +const localizedStrings = getLocalizedStrings([...FIND_LOCALIZED_STRING_KEYS]); +const scopeSelectorLocalizedStrings = getLocalizedStrings([...SCOPE_SELECTOR_STRING_KEYS]); +const searchResultLocalizedStrings = getLocalizedStrings([...SEARCH_RESULT_LOCALIZED_STRING_KEYS]); + +const SEARCH_TERM = 'God'; + +// Seed USX for the two books we search across, so the selected result can render verse context. +const seedUsxByBook: Record = { + GEN: ` + + World English Bible (WEB) + + In the beginning, God created the heavens and the earth. + The earth was formless and empty. God's Spirit was hovering over the surface of the waters. + God said, "Let there be light," and there was light. + +`, + JHN: ` + + World English Bible (WEB) + + In the beginning was the Word, and the Word was with God, and the Word was God. + +`, +}; + +const seedUsjByBook: Record = Object.fromEntries( + Object.entries(seedUsxByBook).map(([bookId, usx]) => [bookId, usxStringToUsj(usx)]), +); + +/** + * Build a {@link HidableFindResult} for an occurrence of the search term in a verse, computing the + * USFM offsets from the seed USJ so the selected result renders the matched text in context. Uses + * the same `UsjReaderWriter` the component uses (no `@papi`). + */ +function makeResult( + bookId: string, + chapterNum: number, + verseNum: number, + occurrence = 1, +): HidableFindResult { + const verseRef: SerializedVerseRef = { book: bookId, chapterNum, verseNum }; + const readerWriter = new UsjReaderWriter(seedUsjByBook[bookId], { + markersMap: USFM_MARKERS_MAP_PARATEXT_3_0, + }); + const usfm = readerWriter.toUsfm(); + const verseStartIndex = readerWriter.usfmVerseLocationToIndexInUsfm(verseRef); + + // Find the requested occurrence of the term at or after the verse start. + let matchIndex = -1; + let searchFrom = verseStartIndex; + for (let i = 0; i < occurrence; i++) { + matchIndex = usfm.indexOf(SEARCH_TERM, searchFrom); + searchFrom = matchIndex + SEARCH_TERM.length; + } + + const start: UsfmVerseRefVerseLocation = { verseRef, offset: matchIndex - verseStartIndex }; + const end: UsfmVerseRefVerseLocation = { + verseRef, + offset: matchIndex - verseStartIndex + SEARCH_TERM.length, + }; + return { start, end, text: SEARCH_TERM }; +} + +const seedResults: HidableFindResult[] = [ + makeResult('GEN', 1, 1), + makeResult('GEN', 1, 2), + makeResult('GEN', 1, 3), + makeResult('JHN', 1, 1, 1), + makeResult('JHN', 1, 1, 2), +]; + +const availableBookIds = ['GEN', 'JHN']; + +const booksPresent = Canon.allBookIds + .map((bookId) => (availableBookIds.includes(bookId) ? '1' : '0')) + .join(''); + +const localizedBookData = new Map([ + ['GEN', { localizedId: 'Genesis', localizedName: 'Genesis' }], + ['JHN', { localizedId: 'John', localizedName: 'John' }], +]); + +const completedStatus: FindJobStatus = 'completed'; +const runningStatus: FindJobStatus = 'running'; + +type HarnessConfig = { + /** Seed results the in-memory service serves. */ + results?: HidableFindResult[]; + /** Initial search term. */ + searchTerm?: string; + /** Initial mode. */ + activeMode?: 'find' | 'replace'; + /** The find-job status the status bar reflects. */ + searchStatus?: FindJobStatus | undefined; + /** Percent complete for an in-progress search. */ + searchProgress?: number; + /** Total results the job reports. */ + totalNumberOfResults?: number; +}; + +/** + * Thin in-memory service container: holds the search/replace/filter state and the results, mutates + * the results on replace/replace-all (mark replaced) and on hide, returns seed USJ for verse + * context, and routes editor navigation to `alertCommand` (the different-UI action the webview + * would perform). + */ +function FindHarness({ config }: { config: HarnessConfig }) { + const [searchTerm, setSearchTerm] = useState(config.searchTerm ?? SEARCH_TERM); + const [recentSearches, setRecentSearches] = useState(['Lord', 'beginning']); + const [scope, setScope] = useState('book'); + const [selectedBookIds, setSelectedBookIds] = useState(['GEN']); + const [shouldMatchCase, setShouldMatchCase] = useState(false); + const [searchTextType, setSearchTextType] = useState('all'); + const [wordRestriction, setWordRestriction] = useState('none'); + const [isRegexAllowed, setIsRegexAllowed] = useState(false); + + const [activeMode, setActiveMode] = useState<'find' | 'replace'>(config.activeMode ?? 'find'); + const [replaceTerm, setReplaceTerm] = useState('Yahweh'); + const [preserveCase, setPreserveCase] = useState(false); + + const [results, setResults] = useState(config.results ?? seedResults); + const [focusedResultIndex, setFocusedResultIndex] = useState( + config.activeMode === 'replace' ? 0 : undefined, + ); + const [numberOfHiddenResults, setNumberOfHiddenResults] = useState(0); + + const verseRef = useMemo( + () => ({ book: 'GEN', chapterNum: 1, verseNum: 1 }), + [], + ); + + const resultsByBook = useMemo>(() => { + const map = new Map(); + results.forEach((result, originalIndex) => { + const bookId = result.start.verseRef.book; + const entries = map.get(bookId) ?? []; + entries.push({ result, originalIndex }); + map.set(bookId, entries); + }); + return map; + }, [results]); + + const addRecentSearchItem = useCallback((term: string) => { + if (term.trim() === '') return; + setRecentSearches((prev) => [term, ...prev.filter((t) => t !== term)].slice(0, 10)); + }, []); + + const getBookUsj = useCallback(async (bookId: string) => seedUsjByBook[bookId], []); + + const handleFocusedResultChange = useCallback( + (searchResult: HidableFindResult, index: number) => { + setFocusedResultIndex(index); + // Selecting a result navigates/selects it in the editor in the real app. + alertCommand('platformScriptureEditor.selectRange', { + start: searchResult.start.verseRef, + end: searchResult.end.verseRef, + }); + }, + [], + ); + + const handleHideResult = useCallback((index: number) => { + setResults((prev) => + prev.map((result, i) => (i === index ? { ...result, isHidden: true } : result)), + ); + setNumberOfHiddenResults((prev) => prev + 1); + setFocusedResultIndex(undefined); + }, []); + + const handleReplace = useCallback( + (resultIndex?: number) => { + const indexToReplace = resultIndex ?? focusedResultIndex; + if (indexToReplace === undefined) return; + // Mark the replaced result so the UI reflects it; in the app a re-find then refreshes. + setResults((prev) => + prev.map((result, i) => (i === indexToReplace ? { ...result, isReplaced: true } : result)), + ); + }, + [focusedResultIndex], + ); + + const handleReplaceAll = useCallback(() => { + setResults((prev) => + prev.map((result) => (result.isHidden ? result : { ...result, isReplaced: true })), + ); + }, []); + + const handleCancelReplace = useCallback(() => { + // Cancel/revert the pending replace: unmark the replaced results. + setResults((prev) => prev.map((result) => ({ ...result, isReplaced: false }))); + }, []); + + return ( + addRecentSearchItem(searchTerm)} + onStopSearch={() => {}} + setScope={setScope} + onSelectedBookIdsChange={setSelectedBookIds} + setSearchTextType={setSearchTextType} + setWordRestriction={setWordRestriction} + setShouldMatchCase={setShouldMatchCase} + setIsRegexAllowed={setIsRegexAllowed} + onToggleMode={setActiveMode} + onReplaceTermChange={setReplaceTerm} + onPreserveCaseChange={setPreserveCase} + onFocusedResultChange={handleFocusedResultChange} + onHideResult={handleHideResult} + onReplace={handleReplace} + onReplaceAll={handleReplaceAll} + onCancelReplace={handleCancelReplace} + onResultsScroll={() => {}} + getBookUsj={getBookUsj} + /> + ); +} + +const meta: Meta = { + title: 'Bundled Extensions/platform-scripture/Find', + component: FindHarness, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +function createDecorator(config: HarnessConfig) { + return function FindDecorator() { + return ; + }; +} + +/** + * A populated find result list across two books. Click a result to select it (announces the editor + * command the webview would run) and watch its verse context render. + */ +export const Populated: Story = { + decorators: [createDecorator({})], +}; + +/** + * Replace mode: the replace input and Replace / Replace All buttons show. Clicking Replace marks + * the focused result as replaced; Replace All marks all visible results; Cancel reverts them. + */ +export const ReplaceMode: Story = { + decorators: [createDecorator({ activeMode: 'replace' })], +}; + +/** An in-progress search — the progress bar and Cancel button show while results stream in. */ +export const InProgress: Story = { + decorators: [ + createDecorator({ + results: [], + searchStatus: runningStatus, + searchProgress: 40, + totalNumberOfResults: 12, + }), + ], +}; + +/** No results were found for the search term — the empty-state message renders. */ +export const NoResults: Story = { + decorators: [ + createDecorator({ results: [], searchStatus: completedStatus, totalNumberOfResults: 0 }), + ], +}; + +/** + * Replace mode with results already marked as replaced (the red "replaced" state + revert Cancel + * button). Use Cancel to revert them back. + */ +export const Replaced: Story = { + decorators: [ + createDecorator({ + activeMode: 'replace', + results: seedResults.map((result) => ({ ...result, isReplaced: true })), + }), + ], +}; diff --git a/extensions/src/platform-scripture/src/find/search-result.component.tsx b/extensions/src/platform-scripture/src/find/search-result.component.tsx index 09d7082e838..43047b3ab09 100644 --- a/extensions/src/platform-scripture/src/find/search-result.component.tsx +++ b/extensions/src/platform-scripture/src/find/search-result.component.tsx @@ -1,12 +1,6 @@ -import { logger } from '@papi/frontend'; import { Copy, X } from 'lucide-react'; import { Button, DropdownMenuItem, ResultsCard } from 'platform-bible-react'; -import { - getErrorMessage, - LocalizedStringValue, - LocalizeKey, - UsjReaderWriter, -} from 'platform-bible-utils'; +import { LocalizedStringValue, LocalizeKey, UsjReaderWriter } from 'platform-bible-utils'; import { FindResult } from 'platform-scripture'; import { useEffect, useMemo, useRef, useState } from 'react'; import { LocalizedBookData } from './find-types'; @@ -153,8 +147,9 @@ export default function SearchResult({ } return { beforeText, text, afterText }; - } catch (error) { - logger.warn(`Error determining text parts for search result: ${getErrorMessage(error)}`); + } catch { + // The verse context is best-effort; if locating the result within the USFM fails, fall back + // to showing the result without surrounding context rather than surfacing an error. return undefined; } }, [usjReaderWriter, searchResult, isVisible]); diff --git a/extensions/src/platform-scripture/src/find/search-results-in-book.component.tsx b/extensions/src/platform-scripture/src/find/search-results-in-book.component.tsx index 4bc6f2b8e2f..1da10e12ff9 100644 --- a/extensions/src/platform-scripture/src/find/search-results-in-book.component.tsx +++ b/extensions/src/platform-scripture/src/find/search-results-in-book.component.tsx @@ -1,13 +1,10 @@ +import { Usj } from '@eten-tech-foundation/scripture-utilities'; import { - getErrorMessage, - isPlatformError, LocalizedStringValue, USFM_MARKERS_MAP_PARATEXT_3_0, UsjReaderWriter, } from 'platform-bible-utils'; -import { useProjectData } from '@papi/frontend/react'; -import { useMemo } from 'react'; -import { logger } from '@papi/frontend'; +import { useEffect, useMemo, useState } from 'react'; import { LocalizedBookData } from './find-types'; import SearchResult, { HidableFindResult, @@ -15,8 +12,12 @@ import SearchResult, { } from './search-result.component'; type SearchResultsInBookProps = { - /** The ID of the project being searched */ - projectId: string | undefined; + /** + * Retrieves the USJ for the given book so the verse context for each result can be computed. + * Provided by the container (webview reads it from the USJ_Book project data provider; the story + * returns seed USJ) so this component stays free of `@papi`. + */ + getBookUsj: (bookId: string) => Promise; /** The book ID of the book these results are from */ bookId: string; /** The list of search results in this book */ @@ -44,7 +45,7 @@ type SearchResultsInBookProps = { /** Handles rendering the results within a single book of a search. */ export function SearchResultsInBook({ - projectId, + getBookUsj, bookId, results, localizedBookData, @@ -57,40 +58,35 @@ export function SearchResultsInBook({ isReplaceMode, isReplacing, }: SearchResultsInBookProps) { - const verseRefForBook = useMemo(() => { - return { - book: bookId, - chapterNum: 1, - verseNum: 0, - }; - }, [bookId]); - - const [usjBookPossiblyError] = useProjectData( - 'platformScripture.USJ_Book', - projectId ?? undefined, - ).BookUSJ(verseRefForBook, undefined); + const [usjBook, setUsjBook] = useState(undefined); - const usjBook = useMemo(() => { - if (isPlatformError(usjBookPossiblyError)) { - logger.warn( - `Error retrieving USJ Book ${bookId} for search results in book: ${getErrorMessage(usjBookPossiblyError)}`, - ); - return undefined; - } - return usjBookPossiblyError; - }, [usjBookPossiblyError, bookId]); + useEffect(() => { + let isActive = true; + getBookUsj(bookId) + .then((usj) => { + if (isActive) setUsjBook(usj); + return undefined; + }) + .catch(() => { + // The verse context is best-effort; if loading the book USJ fails, leave it undefined so + // results still render without surrounding context. + if (isActive) setUsjBook(undefined); + }); + return () => { + isActive = false; + }; + }, [getBookUsj, bookId]); const usjReaderWriter = useMemo(() => { if (!usjBook) return undefined; try { return new UsjReaderWriter(usjBook, { markersMap: USFM_MARKERS_MAP_PARATEXT_3_0 }); - } catch (error) { - logger.warn( - `Error creating UsjReaderWriter ${bookId} for search results in book: ${getErrorMessage(error)}`, - ); + } catch { + // If the USJ can't be parsed, fall back to rendering results without verse context rather + // than surfacing an error. return undefined; } - }, [usjBook, bookId]); + }, [usjBook]); const firstReplacedIndex = results.findIndex((r) => r.isReplaced); From adf36ca44820606623edd2d5db481dad010abdb0 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 15:36:38 +0200 Subject: [PATCH 15/23] make comment-list story writes mutate the in-memory threads Upgrade the comment-list story from announce-only callbacks to a thin in-memory CRUD store: add/edit/delete/read-status changes mutate the threads in state so the list reflects them, matching the real app (and the other split stories). Editor navigation stays announced via alertCommand. Verified: typecheck, extensions lint, and storybook:build all clean. Co-Authored-By: Claude Code --- .../src/comment-list.stories.tsx | 73 +++++++++++++++---- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx b/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx index abe163a8c7c..d3220c2d67c 100644 --- a/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx +++ b/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx @@ -108,8 +108,9 @@ type DecoratorConfig = { }; /** - * Wires the controlled filters and the selected-thread state to local state, and mocks the - * PAPI-backed callbacks so reviewers can exercise the panel in isolation. + * Wires the controlled filters and the selected-thread state to local state, and backs the comment + * writes with a thin in-memory store so add/edit/delete/read changes reflect in the UI (like the + * real app). Navigating to the editor is announced via `alertCommand` (different UI). */ function createDecorator(config: DecoratorConfig) { return function CommentListPanelDecorator( @@ -120,6 +121,7 @@ function createDecorator(config: DecoratorConfig) { ); const [scopeFilter, setScopeFilter] = useState(UNFILTERED); const [selectedThreadId, setSelectedThreadId] = useState(undefined); + const [threads, setThreads] = useState(config.threads ?? sampleThreads); return (
@@ -127,7 +129,7 @@ function createDecorator(config: DecoratorConfig) { args={{ localizedStrings, isLoading: config.isLoading ?? false, - threads: config.threads ?? sampleThreads, + threads, currentUser: CURRENT_USER, commentFilter, onCommentFilterChange: setCommentFilter, @@ -139,24 +141,67 @@ function createDecorator(config: DecoratorConfig) { canUserResolveThreadCallback: resolveTrue, canUserEditOrDeleteCommentCallback: resolveTrue, handleAddCommentToThread: (options) => { - alertCommand('legacyCommentManager.comments.addCommentToThread', { - threadId: options.threadId, - }); - return Promise.resolve(`${options.threadId}-new`); + const newCommentId = `${options.threadId}-${Date.now()}`; + setThreads((prev) => + prev.map((thread) => + thread.id === options.threadId + ? { + ...thread, + comments: [ + ...thread.comments, + makeComment({ + id: newCommentId, + thread: thread.id, + verseRef: thread.verseRef, + user: CURRENT_USER, + contents: options.contents ?? '', + isRead: false, + ...(options.status && { status: options.status }), + ...(options.assignedUser !== undefined && { + assignedUser: options.assignedUser, + }), + }), + ], + ...(options.status && { status: options.status }), + ...(options.assignedUser !== undefined && { + assignedUser: options.assignedUser, + }), + } + : thread, + ), + ); + return Promise.resolve(newCommentId); }, - handleUpdateComment: (commentId) => { - alertCommand('legacyCommentManager.comments.updateComment', { commentId }); + handleUpdateComment: (commentId, contents) => { + setThreads((prev) => + prev.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => + comment.id === commentId ? { ...comment, contents } : comment, + ), + })), + ); return Promise.resolve(true); }, handleDeleteComment: (commentId) => { - alertCommand('legacyCommentManager.comments.deleteComment', { commentId }); + setThreads((prev) => + prev + .map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => + comment.id === commentId ? { ...comment, deleted: true } : comment, + ), + })) + .filter((thread) => thread.comments.some((comment) => !comment.deleted)), + ); return Promise.resolve(true); }, handleReadStatusChange: (threadId, markAsRead) => { - alertCommand('legacyCommentManager.comments.setIsCommentThreadRead', { - threadId, - markAsRead, - }); + setThreads((prev) => + prev.map((thread) => + thread.id === threadId ? { ...thread, isRead: markAsRead } : thread, + ), + ); return Promise.resolve(true); }, selectedThreadId, From 737b990018aee71a91334049e73b3a1d8e34e7e1 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 15:38:57 +0200 Subject: [PATCH 16/23] fix find story: import Canon from @sillsdev/scripture (runtime value) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The find story imported `Canon` from platform-bible-utils, which only re-exports it as a type — so `Canon` was undefined at runtime and `Canon.allBookIds` threw when the story rendered (typecheck/build passed; only runtime caught it). Import Canon from @sillsdev/scripture, as the webview does. Co-Authored-By: Claude Code --- extensions/src/platform-scripture/src/find/find.stories.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/extensions/src/platform-scripture/src/find/find.stories.tsx b/extensions/src/platform-scripture/src/find/find.stories.tsx index ba38b209581..f5adefc3d5c 100644 --- a/extensions/src/platform-scripture/src/find/find.stories.tsx +++ b/extensions/src/platform-scripture/src/find/find.stories.tsx @@ -1,9 +1,8 @@ import { Usj, usxStringToUsj } from '@eten-tech-foundation/scripture-utilities'; -import { SerializedVerseRef } from '@sillsdev/scripture'; +import { Canon, SerializedVerseRef } from '@sillsdev/scripture'; import type { Meta, StoryObj } from '@storybook/react-webpack5'; import { Scope, SCOPE_SELECTOR_STRING_KEYS } from 'platform-bible-react'; import { - Canon, USFM_MARKERS_MAP_PARATEXT_3_0, UsjReaderWriter, type UsfmVerseRefVerseLocation, From 8650799640d953ab3ca3bf8c60590a4d5f75f84d Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 15:51:09 +0200 Subject: [PATCH 17/23] Reapply "Remove obsolete find-header-demo story" This reverts commit 63e0b05061f5e772d3d42519f26b3c7e6fcf7dba. --- .../find/find-header-demo.stories-helper.tsx | 393 ------------------ .../src/find/find-header-demo.stories.tsx | 21 - 2 files changed, 414 deletions(-) delete mode 100644 extensions/src/platform-scripture/src/find/find-header-demo.stories-helper.tsx delete mode 100644 extensions/src/platform-scripture/src/find/find-header-demo.stories.tsx diff --git a/extensions/src/platform-scripture/src/find/find-header-demo.stories-helper.tsx b/extensions/src/platform-scripture/src/find/find-header-demo.stories-helper.tsx deleted file mode 100644 index fd2dc8720d9..00000000000 --- a/extensions/src/platform-scripture/src/find/find-header-demo.stories-helper.tsx +++ /dev/null @@ -1,393 +0,0 @@ -import { SerializedVerseRef } from '@sillsdev/scripture'; -import { - ArrowRight, - ChevronDown, - ChevronUp, - Info, - Replace, - ReplaceAll, - TextSearch, - X, -} from 'lucide-react'; -import { - Button, - Checkbox, - Input, - Label, - Popover, - PopoverContent, - PopoverTrigger, - RecentSearches, - Scope, - ScopeSelector, - SCOPE_SELECTOR_STRING_KEYS, - Spinner, - ToggleGroup, - ToggleGroupItem, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, - useRecentSearches, -} from 'platform-bible-react'; -import { FindJobStatus, WordRestriction } from 'platform-scripture'; -import { formatReplacementString } from 'platform-bible-utils'; -import { SetStateAction, useEffect, useMemo, useRef, useState } from 'react'; -import { getLocalizedStrings } from '../../../../../.storybook/localization.utils'; -import { FindFilters } from './find-filters.component'; -import { SearchTextType } from './find-types'; - -const filterLocalizedStrings = getLocalizedStrings([ - '%webView_find_toggleFilters%', - '%webView_find_matchContentIn%', - '%webView_find_allText%', - '%webView_find_allText_tooltip%', - '%webView_find_verseTextOnly%', - '%webView_find_restrictions%', - '%webView_find_restrictions_none%', - '%webView_find_restrictions_wholeWord%', - '%webView_find_restrictions_startOfWord%', - '%webView_find_restrictions_endOfWord%', - '%webView_find_capitalization%', - '%webView_find_matchCase%', - '%webView_find_pattern%', - '%webView_find_allowRegex%', -]); - -const replaceLocalizedStrings = getLocalizedStrings([ - '%webView_find_replace%', - '%webView_find_replaceAll%', - '%webView_find_replaceTerm_placeholder%', - '%webView_find_preserveCase%', - '%webView_find_preserveCase_tooltip%', -]); - -const localizedStrings = getLocalizedStrings([ - '%webView_find_findTab%', - '%webView_find_replaceTab%', - '%webView_find_searchPlaceholder%', - '%webView_find_showRecentSearches%', - '%webView_find_recent%', - '%webView_find_findInProject%', - '%webView_find_showing%', - '%webView_find_previousResult%', - '%webView_find_nextResult%', -]); - -const scopeSelectorLocalizedStrings = getLocalizedStrings([...SCOPE_SELECTOR_STRING_KEYS]); -export function FindHeaderDemo() { - const [searchTerm, setSearchTerm] = useState(''); - - // custom for demo - const [verseRefSetting] = useState({ - book: 'GEN', - chapterNum: 1, - verseNum: 1, - }); - - const [scope, setScope] = useState('book'); - - const [recentSearches, setRecentSearches] = useState([]); - const addRecentSearchItem = useRecentSearches(recentSearches, setRecentSearches); - - const [selectedBookIds, setSelectedBookIds] = useState([]); - const [shouldMatchCase, setShouldMatchCase] = useState(false); - const [searchTextType, setSearchTextType] = useState('all'); - const [wordRestriction, setWordRestriction] = useState('none'); - const [isRegexAllowed, setIsRegexAllowed] = useState(false); - - const [activeMode, setActiveMode] = useState<'find' | 'replace'>('find'); - const [replaceTerm, setReplaceTerm] = useState(''); - const [preserveCase, setPreserveCase] = useState(false); - - const [searchStatus, setSearchStatus] = useState(undefined); - - // custom for demo - const [focusedResultIndex, setFocusedResultIndex] = useState(undefined); - const demoTotalResults = searchStatus === 'completed' ? 5 : 0; - - const areFiltersActive = - shouldMatchCase || wordRestriction !== 'none' || searchTextType !== 'all' || isRegexAllowed; - - const isSearchQueryValid = useMemo(() => { - if (searchTerm.trim() === '') return false; - if (scope === 'selectedBooks' && selectedBookIds.length === 0) return false; - return true; - }, [searchTerm, scope, selectedBookIds]); - - // custom for demo - const [findButtonText, setFindButtonText] = useState(''); - useEffect(() => { - const timeout = setTimeout( - () => setFindButtonText(localizedStrings['%webView_find_findTab%']), - 1000, - ); - return () => clearTimeout(timeout); - }, []); - - // custom for demo - const searchTimeoutRef = useRef | undefined>(undefined); - useEffect(() => { - return () => { - if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); - }; - }, []); - const handleStartSearch = () => { - setSearchStatus('running'); - if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); - searchTimeoutRef.current = setTimeout(() => { - setSearchStatus('completed'); - }, 1000); - - addRecentSearchItem(searchTerm); - }; - - // custom for demo - const availableBookIds = - '111111111111111111111111111111111111111111111111111111111111111111100001000000000000000000001100000000000000101000000000000'; - - // custom for demo: simplified scope display text - const scopeDisplayText = useMemo(() => { - switch (scope) { - case 'chapter': - return `${verseRefSetting.book} ${verseRefSetting.chapterNum}`; - case 'book': - return verseRefSetting.book; - case 'selectedBooks': - return selectedBookIds.length > 0 ? selectedBookIds.join(', ') : '…'; - default: - return ''; - } - }, [scope, selectedBookIds, verseRefSetting]); - - return ( -
- {/* Find/Replace mode toggle */} - { - if (value === 'find' || value === 'replace') setActiveMode(value); - }} - className="tw:w-fit tw:rounded-lg tw:bg-muted tw:p-1" - > - - {localizedStrings['%webView_find_findTab%']} - - - {localizedStrings['%webView_find_replaceTab%']} - - - - {/* Find input row */} -
-
- - } }) => - setSearchTerm(e.target.value) - } - onKeyDown={(e: { key: string }) => { - if (e.key === 'Enter') { - handleStartSearch(); - } - }} - placeholder={localizedStrings['%webView_find_searchPlaceholder%']} - className={`tw:w-full tw:min-w-16 tw:text-ellipsis tw:!pl-8 ${searchTerm ? 'tw:!pe-8' : 'tw:!pr-4'}`} - /> - {searchTerm && ( - - )} -
- - - - - - - - - - -

{localizedStrings['%webView_find_findInProject%']}

-
-
-
-
- - {/* Replace input row — shown in Replace mode */} - {activeMode === 'replace' && ( - <> -
- - } }) => - setReplaceTerm(e.target.value) - } - placeholder={replaceLocalizedStrings['%webView_find_replaceTerm_placeholder%']} - className="tw:w-full tw:min-w-16 tw:!pl-8 tw:!pr-4" - /> -
-
-
- setPreserveCase(checked === true)} - /> - - - - - - - -

- {replaceLocalizedStrings['%webView_find_preserveCase_tooltip%']} -

-
-
-
-
-
- - -
-
- - )} - - {/* Scope selector row */} -
- - - - - - - - - {demoTotalResults > 0 && ( -
- - {formatReplacementString('{current} of {total}', { - current: focusedResultIndex !== undefined ? String(focusedResultIndex + 1) : '–', - total: String(demoTotalResults), - })} - - - -
- )} -
-
- ); -} diff --git a/extensions/src/platform-scripture/src/find/find-header-demo.stories.tsx b/extensions/src/platform-scripture/src/find/find-header-demo.stories.tsx deleted file mode 100644 index a73e68b70e4..00000000000 --- a/extensions/src/platform-scripture/src/find/find-header-demo.stories.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5'; -import { FindHeaderDemo } from './find-header-demo.stories-helper'; - -const meta: Meta = { - title: 'Bundled Extensions/find/FindHeaderDemo', - component: FindHeaderDemo, - tags: ['autodocs'], - decorators: [ - (Story) => ( -
- -
- ), - ], -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; From 99119c92a7743aa179352407c71a0b769552bb44 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 17:08:32 +0200 Subject: [PATCH 18/23] make storybook stories fully interactive and render on the panel background Stories now behave like the running app instead of rendering a fixed snapshot: - preview.ts: wrap every story in a full-height bg-background/text-foreground surface so each renders on the app panel color. - get-resources: get/remove transition through the spinner state and flip the resource installed flag; add a 100-languages story. - comment-list: derive the visible threads from the comment/scope filters (mirroring the web view's query); add a cross-chapter thread for the scope demo. - checks-side-panel: derive the results from the selected check types + scope. - find: add a small in-memory search engine over the seed corpus so the term, match-case, word restriction, regex, and scope all re-run the search and update the highlighting; replace/replace-all announce the command, show the replaced state, then commit (the result drops out as a re-find would); Cancel reverts. - inventory: announce the occurrence-row navigation and the approve/unapprove status change via alert. - model-text-panel: import the editor's usj-nodes/nodes-menu CSS and hide the read-only toolbar so the context menu is styled and the stray current-marker label no longer shows above the editor. - registration-form-view story: wire "Change" to enter edit mode, Cancel to revert, and Save to persist back to the read-only view. - Add .storybook/STORYBOOK-INTERACTIVITY.md documenting these conventions. Co-Authored-By: Claude Code --- .storybook/STORYBOOK-INTERACTIVITY.md | 101 +++++ .storybook/preview.ts | 12 +- .storybook/story.utils.ts | 4 + .../src/comment-list.stories.tsx | 61 ++- .../registration-form-view.stories.tsx | 43 +- .../src/get-resources.stories.tsx | 177 +++++++- .../src/model-text-panel.stories.tsx | 31 ++ .../checks-side-panel.stories.tsx | 54 ++- .../checks/inventories/inventory.stories.tsx | 50 ++- .../src/find/find.stories.tsx | 391 +++++++++++++----- 10 files changed, 781 insertions(+), 143 deletions(-) create mode 100644 .storybook/STORYBOOK-INTERACTIVITY.md diff --git a/.storybook/STORYBOOK-INTERACTIVITY.md b/.storybook/STORYBOOK-INTERACTIVITY.md new file mode 100644 index 00000000000..d405bd3c0f8 --- /dev/null +++ b/.storybook/STORYBOOK-INTERACTIVITY.md @@ -0,0 +1,101 @@ +# Storybook story guidelines (paranext-core root) + +How to write Storybook stories for bundled-extension web views (and similar PAPI-coupled +components) in this repo so they behave like the real app. Read this before adding or editing a +`*.stories.tsx` under `extensions/src/**` or `src/**`. + +> TL;DR: a story must be **fully interactive like the running app** — filters actually filter, +> search actually searches (and re-highlights), scope changes change the results, installs/saves +> transition through their states, and writes reflect in the UI. A story that only renders a fixed +> snapshot and ignores its own controls is a bug. + +## The split + +Each web view is split into three files: + +1. **`.component.tsx`** — presentational. Owns rendering and orchestration/derivation, but + **no `@papi`**. Props = raw data + `localizedStrings` + operation callbacks (`on*`/`handle*`, + `get*` for reads that depend on a value the component resolves, `show*` for in-app sub-UIs). +2. **`.web-view.tsx`** — thin container: PAPI hooks/commands/PDPs → the component's props. +3. **`.stories.tsx`** — a container backed by a **thin in-memory CRUD service** (seed + `useState` + mutating callbacks) that stands in for PAPI. + +Lift any child's internal `useLocalizedStrings` / `logger` / data hooks **up to the web view** so the +component (and everything a story imports) is `@papi`-free. + +**Hard gate:** `npm run storybook:build` must pass. Storybook's webpack builder cannot resolve +`@papi/*`, so nothing reachable from a story may import it (directly or transitively). + +## Make it interactive (the part that's easy to get wrong) + +The presentational component usually renders **already-derived** data — the web view does the +filtering/searching/scoping via its PAPI queries and feeds the component the result. **The story +harness must reproduce that derivation in-memory**, not pass the raw seed straight through. + +- **Filters / scope / search are controlled** → the harness holds the filter state AND derives the + displayed data from it, mirroring the web view's query semantics. Examples in this repo: + - `comment-list.stories.tsx` — derives the visible threads from the comment/scope filters. + - `checks-side-panel.stories.tsx` — derives `checkResults` from the selected check types + scope. + - `find.stories.tsx` — a small in-memory search engine over a seed corpus; the term, match-case, + word-restriction, regex, and scope all re-run the search and update the highlighting. +- **State transitions happen** → e.g. `get-resources.stories.tsx` flips a resource through + installing (`idsBeingHandled`) → installed, and back for remove. +- **Writes reflect** → mutate the in-memory store immutably so reads re-render (approve/deny, + add/edit/delete, replace-then-commit). +- **Lead with the populated state** → the first exported story shows data present; loading/empty/ + error come after. + +## Callback conventions + +| The real app action… | In the story… | +| ----------------------------------------------------- | ------------------------------------------------------------------------ | +| Opens a **completely different UI** (editor, dialog…) | `alertCommand('namespace.command', { args })` (announce, don't navigate) | +| Calls **another in-app UI component** | Wire the **real** component (e.g. render `ResourcePickerDialog` inline) | +| **Saves/changes data** (the meaningful edits) | Mutate the in-memory store so it reflects; add a failure story | +| **Saves a setting** (passive config, e.g. text dir) | `console.log` only | +| A **business** failure that already exists | `rejectingMock('user-facing reason')` in a dedicated failure story | + +Use the shared helpers: `.storybook/story.utils.ts` (`alertCommand`, `rejectingMock`) and +`.storybook/localization.utils.ts` (`getLocalizedStrings` → real English strings). Keep the service +layer thin — CRUD-like mutation + only the error handling the web view already has. No new +validation, no "giant" mock backend. + +## Panel background + +`preview.ts` wraps every story in a full-height `tw:bg-background tw:text-foreground` surface so it +sits on the same panel color a web view occupies in an app dock. Don't fight this in a story. + +## Editor (`Editorial`) stories + +The `@eten-tech-foundation/platform-editor` editor needs styling the app loads globally but +Storybook does not. Without it the context menu is unstyled and the read-only marker toolbar shows +as a stray inline label (e.g. "p - Paragraph - Normal - First Line Indent"). In a story: + +- Import `lib/platform-bible-react/src/components/demo/scripture-editor/usj-nodes.css` and + `nodes-menu.css`. +- Inject the icon-free wrapper style (see `model-text-panel.stories.tsx` and + `src/stories/platform/ten-layout-shared.tsx`); in particular `.editor-toolbar-container-readonly { +display: none; }`. +- **Do not** import `editor.css` / `editor-overrides.css` — they reference toolbar icon SVGs by + absolute URL that the css-loader can't resolve, breaking the build. + +Note: `usj-nodes.css` bare `table`/`td`/`rt` selectors are scoped to `.usfm` to avoid leaking table +borders into other stories — keep them scoped if you touch that file. + +## A couple of footguns + +- `Canon` is a **runtime value** from `@sillsdev/scripture`; `platform-bible-utils` re-exports it as + a **type only**. Import `Canon` from `@sillsdev/scripture` or it's `undefined` at render + (typechecks and builds, throws at runtime). +- Responsive components read the real window (e.g. the dictionary's drawer uses + `matchMedia('(min-width: 1024px)')`). Storybook's canvas is wide, so width-dependent variants + (like that drawer) won't appear unless the iframe itself is narrow. + +## Verify before committing + +1. `npx tsc -p ./tsconfig.json` — clean. +2. ESLint on changed files — clean (prettier-format first). +3. `npm run storybook:build` — exit 0 (the real `@papi`-free gate). +4. Open each story and exercise it: filters/search/scope change the results, writes reflect, + transitions run, the failure story shows its business error, and there are no runtime errors in + the console. diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 83302bfc70a..05e981565d7 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -28,7 +28,9 @@ const preview: Preview = { }, decorators: [ - // Apply Platform.Bible Tailwind preflight wrapper to the iframe's body. + // Apply Platform.Bible Tailwind preflight wrapper to the iframe's body, and paint the body with + // the app panel background (`bg-background`/`text-foreground`) so every story renders on the + // same surface a web view occupies inside an app dock panel — not Storybook's default white. // See lib/platform-bible-react/src/index.css for details on the .pr-twp class. // useEffect ensures mutations are cleaned up when navigating between stories. (Story) => { @@ -45,7 +47,13 @@ const preview: Preview = { }; }, []); - return React.createElement(Story); + // Wrap each story in a full-height panel-background surface so components that don't fill the + // viewport still sit on the app panel color (matching how a web view fills its dock panel). + return React.createElement( + 'div', + { className: 'tw:bg-background tw:text-foreground tw:min-h-screen' }, + React.createElement(Story), + ); }, ], }; diff --git a/.storybook/story.utils.ts b/.storybook/story.utils.ts index 6bedcf3da93..a76e037baa1 100644 --- a/.storybook/story.utils.ts +++ b/.storybook/story.utils.ts @@ -1,6 +1,10 @@ /** * Shared Storybook helpers for the bundled-extension webview component stories. * + * See `.storybook/STORYBOOK-INTERACTIVITY.md` for the full guidelines on writing fully-interactive, + * real-app-like stories (the split pattern, reproducing the web view's filtering/search derivation, + * callback conventions, editor styling, and verification). + * * Webview components are split into a presentational component (covered by these stories) and a * thin data-loading `*.web-view.tsx` wrapper. In the real app the wrapper implements the `on*` * action callbacks with PAPI commands; in Storybook we mock them here so reviewers can exercise the diff --git a/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx b/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx index d3220c2d67c..02c6ca7b03e 100644 --- a/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx +++ b/extensions/src/legacy-comment-manager/src/comment-list.stories.tsx @@ -9,7 +9,10 @@ import { CommentListPanel, CommentListPanelProps, COMMENT_LIST_PANEL_EXTRA_STRING_KEYS, + FILTER_UNREAD_ASSIGNED, + FILTER_UNRESOLVED_ASSIGNED, ScopeFilter, + SCOPE_FILTER_CURRENT_CHAPTER, UNFILTERED, } from './comment-list.component'; @@ -88,8 +91,60 @@ const sampleThreads: LegacyCommentThread[] = [ }), ], }, + // In a different chapter (GEN 2) so the "current chapter" scope filter visibly removes it. + { + id: 'thread-3', + verseRef: 'GEN 2:7', + status: 'Todo', + type: 'Normal', + modifiedDate: '2024-01-04T08:00:00.0000000-00:00', + isSpellingNote: false, + isBTNote: false, + isConsultantNote: false, + isRead: false, + assignedUser: CURRENT_USER, + comments: [ + makeComment({ + id: 'c4', + thread: 'thread-3', + verseRef: 'GEN 2:7', + user: 'Dana', + contents: 'Should this be "formed" or "created"?', + isRead: false, + }), + ], + }, ]; +/** The "current chapter" the scope filter narrows to in these stories (GEN 1). */ +const STORY_SCR_REF = { book: 'GEN', chapterNum: 1 }; + +/** + * Mirrors the web view's comment-thread selector: the panel renders already-filtered threads, so + * here we derive the visible threads from the toolbar filters the same way the web view's PDP query + * would (unresolved/unread + assigned-to-me, and the current-chapter scope). + */ +function filterThreads( + threads: LegacyCommentThread[], + commentFilter: CommentFilter, + scopeFilter: ScopeFilter, + currentUser: string, +): LegacyCommentThread[] { + return threads.filter((thread) => { + if (scopeFilter === SCOPE_FILTER_CURRENT_CHAPTER) { + const chapterPrefix = `${STORY_SCR_REF.book} ${STORY_SCR_REF.chapterNum}:`; + if (!thread.verseRef?.startsWith(chapterPrefix)) return false; + } + if (commentFilter === FILTER_UNRESOLVED_ASSIGNED) { + return thread.status === 'Todo' && thread.assignedUser === currentUser; + } + if (commentFilter === FILTER_UNREAD_ASSIGNED) { + return !thread.isRead && thread.assignedUser === currentUser; + } + return true; + }); +} + const resolveTrue = () => Promise.resolve(true); const meta: Meta = { @@ -123,13 +178,17 @@ function createDecorator(config: DecoratorConfig) { const [selectedThreadId, setSelectedThreadId] = useState(undefined); const [threads, setThreads] = useState(config.threads ?? sampleThreads); + // The panel renders already-filtered threads (the web view filters via its query), so derive the + // visible list from the toolbar filters here. + const displayedThreads = filterThreads(threads, commentFilter, scopeFilter, CURRENT_USER); + return (
ReactElement, ) { - const [name, setName] = useState(config.initialName ?? ''); - const [registrationCode, setRegistrationCode] = useState(config.initialCode ?? ''); + const [savedName, setSavedName] = useState(config.savedName ?? ''); + const [savedCode, setSavedCode] = useState(config.savedCode ?? ''); + const [name, setName] = useState(config.initialName ?? config.savedName ?? ''); + const [registrationCode, setRegistrationCode] = useState( + config.initialCode ?? config.savedCode ?? '', + ); + const [isEditing, setIsEditing] = useState(config.isEditing ?? true); return (
0, + isFormDisabled: (config.isFormDisabled ?? false) || !isEditing, isSaveDisabled: config.isSaveDisabled ?? false, showInvalidCode: config.showInvalidCode ?? false, saveState: config.saveState ?? SaveState.HasNotSaved, @@ -91,13 +101,22 @@ function createDecorator(config: DecoratorConfig) { errorDescription: config.errorDescription ?? '', onNameChange: (e) => setName(e.target.value), onRegistrationCodeChange: (e) => setRegistrationCode(e.target.value), - onClickChange: () => alertCommand('registration.edit'), - onCancelEditing: () => alertCommand('registration.cancel'), - onSaveAndRestart: () => + onClickChange: () => setIsEditing(true), + onCancelEditing: () => { + setName(savedName); + setRegistrationCode(savedCode); + setIsEditing(false); + }, + onSaveAndRestart: () => { + // Saving registration restarts the app in the real web view — announce the command. alertCommand('paratextRegistration.setParatextRegistrationData', { name, code: registrationCode, - }), + }); + setSavedName(name); + setSavedCode(registrationCode); + setIsEditing(false); + }, }} />
diff --git a/extensions/src/platform-get-resources/src/get-resources.stories.tsx b/extensions/src/platform-get-resources/src/get-resources.stories.tsx index a4664c7fbc1..ef2d915ad54 100644 --- a/extensions/src/platform-get-resources/src/get-resources.stories.tsx +++ b/extensions/src/platform-get-resources/src/get-resources.stories.tsx @@ -1,12 +1,13 @@ import type { Meta, StoryObj } from '@storybook/react-webpack5'; import type { DblResourceData } from 'platform-bible-utils'; -import { ReactElement, useState } from 'react'; +import { ReactElement, useCallback, useState } from 'react'; import { getLocalizedStrings } from '../../../../.storybook/localization.utils'; import { alertCommand, rejectingMock } from '../../../../.storybook/story.utils'; import { GetResources, GetResourcesProps, GET_RESOURCES_STRING_KEYS, + ResourceAction, } from './get-resources.component'; // Get all localized strings needed by the GetResources component @@ -70,6 +71,122 @@ const staticResources: DblResourceData[] = [ }, ]; +/** A large set of resources spread across 100 distinct languages, to exercise the language filter. */ +const LANGUAGE_NAMES = [ + 'Afrikaans', + 'Albanian', + 'Amharic', + 'Arabic', + 'Armenian', + 'Assamese', + 'Aymara', + 'Azerbaijani', + 'Bambara', + 'Basque', + 'Belarusian', + 'Bengali', + 'Bislama', + 'Bosnian', + 'Bulgarian', + 'Burmese', + 'Catalan', + 'Cebuano', + 'Chichewa', + 'Chinese', + 'Corsican', + 'Croatian', + 'Czech', + 'Danish', + 'Dutch', + 'Dzongkha', + 'English', + 'Esperanto', + 'Estonian', + 'Ewe', + 'Fijian', + 'Finnish', + 'French', + 'Galician', + 'Georgian', + 'German', + 'Greek', + 'Guarani', + 'Gujarati', + 'Haitian Creole', + 'Hausa', + 'Hawaiian', + 'Hebrew', + 'Hindi', + 'Hmong', + 'Hungarian', + 'Icelandic', + 'Igbo', + 'Indonesian', + 'Irish', + 'Italian', + 'Japanese', + 'Javanese', + 'Kannada', + 'Kazakh', + 'Khmer', + 'Kinyarwanda', + 'Korean', + 'Kurdish', + 'Kyrgyz', + 'Lao', + 'Latvian', + 'Lingala', + 'Lithuanian', + 'Luganda', + 'Luxembourgish', + 'Macedonian', + 'Malagasy', + 'Malay', + 'Malayalam', + 'Maltese', + 'Maori', + 'Marathi', + 'Mongolian', + 'Nepali', + 'Norwegian', + 'Oromo', + 'Pashto', + 'Persian', + 'Polish', + 'Portuguese', + 'Punjabi', + 'Quechua', + 'Romanian', + 'Russian', + 'Samoan', + 'Serbian', + 'Sesotho', + 'Shona', + 'Sinhala', + 'Slovak', + 'Slovenian', + 'Somali', + 'Spanish', + 'Swahili', + 'Swedish', + 'Tagalog', + 'Tamil', + 'Telugu', + 'Thai', +]; + +const manyLanguageResources: DblResourceData[] = LANGUAGE_NAMES.map((language, index) => ({ + dblEntryUid: `uid-lang-${index}`, + displayName: `${language.slice(0, 3).toUpperCase()}${index}`, + fullName: `${language} Scripture`, + bestLanguageName: language, + type: 'ScriptureResource', + size: 800 + index * 7, + installed: index % 9 === 0, + updateAvailable: false, + projectId: `project-lang-${index}`, +})); + const meta: Meta = { title: 'Bundled Extensions/platform-get-resources/GetResources', component: GetResources, @@ -88,10 +205,15 @@ type DecoratorConfig = { onInstallOrRemoveResource?: GetResourcesProps['onInstallOrRemoveResource']; }; +/** How long the in-memory "service" pretends an install/remove takes, so the spinner is visible. */ +const INSTALL_REMOVE_DELAY_MS = 1200; + /** - * Builds a story decorator that wires the controlled type/language filters to local state and mocks - * the action callbacks. By default actions announce the command they would run via `alertCommand`; - * pass `onInstallOrRemoveResource` to simulate a business failure for the error-handling stories. + * Builds a story decorator that wires the controlled type/language filters to local state and backs + * the resource list with a thin in-memory store. Installing/removing flips into a spinner state + * (`idsBeingHandled`) and then mutates the resource's `installed` flag so the action reflects in + * the UI — exactly like the real app. Pass `onInstallOrRemoveResource` to simulate a business + * failure for the error-handling stories instead. */ function createDecorator(config: DecoratorConfig) { return function GetResourcesDecorator( @@ -99,15 +221,41 @@ function createDecorator(config: DecoratorConfig) { ) { const [selectedTypes, setSelectedTypes] = useState(['ScriptureResource']); const [selectedLanguages, setSelectedLanguages] = useState([]); + const [resources, setResources] = useState( + config.resources ?? staticResources, + ); + const [idsBeingHandled, setIdsBeingHandled] = useState([]); + + // Default install/remove: show the spinner, then flip the resource's installed state after a + // short delay so the table updates (get → installing → installed, and the reverse for remove). + const handleInstallOrRemoveResource = useCallback( + (dblEntryUid: string, action: ResourceAction) => { + setIdsBeingHandled((ids) => [...ids, dblEntryUid]); + return new Promise((resolve) => { + setTimeout(() => { + setResources((rs) => + rs.map((r) => + r.dblEntryUid === dblEntryUid + ? { ...r, installed: action === 'install', updateAvailable: false } + : r, + ), + ); + setIdsBeingHandled((ids) => ids.filter((id) => id !== dblEntryUid)); + resolve(); + }, INSTALL_REMOVE_DELAY_MS); + }); + }, + [], + ); return ( alertCommand('platformScriptureEditor.openResourceViewer', { projectId }), onInstallOrRemoveResource: - config.onInstallOrRemoveResource ?? - ((dblEntryUid, action) => - alertCommand( - action === 'install' - ? 'platformGetResources.dblResourcesProvider.installDblResource' - : 'platformGetResources.dblResourcesProvider.uninstallDblResource', - { dblEntryUid }, - )), + config.onInstallOrRemoveResource ?? handleInstallOrRemoveResource, }} /> ); @@ -133,6 +274,14 @@ export const Default: Story = { decorators: [createDecorator({})], }; +/** + * 100 resources across 100 languages — exercises the language filter dropdown and table scrolling. + * Installing/removing still transitions through the spinner state and reflects in the list. + */ +export const ManyLanguages: Story = { + decorators: [createDecorator({ resources: manyLanguageResources })], +}; + export const Loading: Story = { decorators: [createDecorator({ isLoadingResources: true, resources: [] })], }; diff --git a/extensions/src/platform-scripture-editor/src/model-text-panel.stories.tsx b/extensions/src/platform-scripture-editor/src/model-text-panel.stories.tsx index 88042126c9e..a64e3b2c067 100644 --- a/extensions/src/platform-scripture-editor/src/model-text-panel.stories.tsx +++ b/extensions/src/platform-scripture-editor/src/model-text-panel.stories.tsx @@ -15,8 +15,38 @@ import type { } from 'platform-scripture'; import { useCallback, useMemo, useRef, useState } from 'react'; import { getLocalizedStrings } from '../../../../.storybook/localization.utils'; +// The editor's nodes/context menu + USJ node styling. The real app loads these globally; Storybook +// doesn't, so without them the editor's context menu is unstyled and its read-only marker toolbar +// renders as a stray inline label. We import the same icon-free subset the editorial layout stories +// use (editor.css/editor-overrides.css are skipped — they reference toolbar icon SVGs by absolute +// URL that the css-loader can't resolve; the minimal wrapper styles are inlined in +// EDITOR_WRAPPER_STYLE below). +/* eslint-disable import/no-relative-packages -- these editor demo CSS files are not part of + platform-bible-react's package exports (only `.` → dist is exported), so they can only be pulled + in by relative path; this mirrors src/stories/platform/ten-layout-shared.tsx. */ +import '../../../../lib/platform-bible-react/src/components/demo/scripture-editor/usj-nodes.css'; +import '../../../../lib/platform-bible-react/src/components/demo/scripture-editor/nodes-menu.css'; +/* eslint-enable import/no-relative-packages */ import { ModelTextPanel, MODEL_TEXT_PANEL_STRING_KEYS } from './model-text-panel.component'; +/** + * Icon-free subset of the editor's wrapper styles (from the platform scripture-editor + * `editor.css`). Crucially it hides the read-only toolbar container — otherwise the editor's + * current-marker label (e.g. "p - Paragraph - Normal - First Line Indent") shows as stray text + * above the editor. + */ +const EDITOR_WRAPPER_STYLE = ` + .editor-container { color: inherit; position: relative; line-height: 20px; font-weight: 400; text-align: start; } + .editor-toolbar-container-readonly { display: none; } + .editor-toolbar-container-editable { display: inline; } + .editor-inner { position: relative; } + .editor-input { min-height: 150px; font-size: 15px; position: relative; tab-size: 1; outline: 0; padding: 15px 10px; flex: auto; } + .editor-input > p { direction: inherit; margin-top: 0; margin-bottom: 0; line-height: 1.5; } + .editor-text-bold { font-weight: bold; } + .editor-text-italic { font-style: italic; } + .editor-text-underline { text-decoration: underline; } +`; + /** * `ModelTextPanel` shows a project's configured "model text" Scripture resource read-only. It owns * the orchestration (resolve configured model text → match a DBL resource → auto-install → load @@ -132,6 +162,7 @@ function ModelTextPanelHarness({ config }: { config: DecoratorConfig }) { return ( <> + { + const typeId = result.checkId ?? result.checkResultType; + if (!selectedCheckTypeIds.includes(typeId)) return false; + if (scope === CheckScopes.All) return true; + if (result.verseRef.book !== STORY_SCR_REF.book) return false; + if (scope === CheckScopes.Book) return true; + return result.verseRef.chapterNum === STORY_SCR_REF.chapterNum; + }); +} + const completedJobReport: CheckJobStatusReport = { jobId: 'job-1', status: 'completed', @@ -185,6 +217,24 @@ function ChecksSidePanelHarness({ config }: { config: HarnessConfig }) { [config.failWrites, failingWrite, setDenied], ); + // The panel renders already-filtered results, so derive the visible results from the toolbar's + // check-type and scope selections (the web view would re-run/re-query for these). + const displayedResults = useMemo( + () => filterResults(results, selectedCheckTypeIds, scope), + [results, selectedCheckTypeIds, scope], + ); + + // When the default completed job is in use, keep the total in sync with what's shown so the status + // bar reports "all loaded" rather than a phantom in-progress count as filters change. + const jobStatusReport = useMemo( + () => + config.jobStatusReport ?? { + ...completedJobReport, + totalResultsCount: displayedResults.length, + }, + [config.jobStatusReport, displayedResults.length], + ); + return ( (DEFAULT_SCR_REF); const [scope, setScope] = useState('chapter'); + + // Clicking an occurrence row navigates the scroll group / editor to that reference in the app. + const setVerseRef = useCallback((verseRef: SerializedVerseRef) => { + setScrRef(verseRef); + alertCommand('platformScriptureEditor.scrollGroupSetVerseRef', { + verseRef: `${verseRef.book} ${verseRef.chapterNum}:${verseRef.verseNum}`, + }); + }, []); const [approvedItems, setApprovedItems] = useState(config.initialApproved ?? []); const [unapprovedItems, setUnapprovedItems] = useState(config.initialUnapproved ?? []); // Occurrences loaded on demand, keyed by item key (mirrors the webview's occurrence cache). @@ -190,19 +198,33 @@ function InventoryHarness({ config }: { config: HarnessConfig }) { }, [config.items, config.loading, approvedItems, unapprovedItems, occurrencesByKey]); // Approve/unapprove move the key between the two lists so the row's status updates immediately. - const onApprovedItemsChange = useCallback((items: string[]) => { - // Settings write in the app; here we reflect it in-memory so the list re-renders. - // eslint-disable-next-line no-console - console.log('setValidItems', items); - setApprovedItems(items); - }, []); + // Changing an item's status is a real edit (it changes which items are valid), so announce the + // newly-approved/unapproved items via alert and reflect the change in-memory. + const onApprovedItemsChange = useCallback( + (items: string[]) => { + const newlyApproved = items.filter((item) => !approvedItems.includes(item)); + if (newlyApproved.length > 0) + alertCommand('platformScripture.inventory.setItemStatus', { + items: newlyApproved, + status: 'approved', + }); + setApprovedItems(items); + }, + [approvedItems], + ); - const onUnapprovedItemsChange = useCallback((items: string[]) => { - // Settings write in the app; here we reflect it in-memory so the list re-renders. - // eslint-disable-next-line no-console - console.log('setInvalidItems', items); - setUnapprovedItems(items); - }, []); + const onUnapprovedItemsChange = useCallback( + (items: string[]) => { + const newlyUnapproved = items.filter((item) => !unapprovedItems.includes(item)); + if (newlyUnapproved.length > 0) + alertCommand('platformScripture.inventory.setItemStatus', { + items: newlyUnapproved, + status: 'unapproved', + }); + setUnapprovedItems(items); + }, + [unapprovedItems], + ); // Selecting an item loads its occurrences from the seed (the app reads them from the provider). const onItemSelected = useCallback( @@ -218,7 +240,7 @@ function InventoryHarness({ config }: { config: HarnessConfig }) { const sharedProps = { inventoryItems, - setVerseRef: setScrRef, + setVerseRef, localizedStrings: sharedStrings, approvedItems, onApprovedItemsChange, diff --git a/extensions/src/platform-scripture/src/find/find.stories.tsx b/extensions/src/platform-scripture/src/find/find.stories.tsx index f5adefc3d5c..d60c03c4d56 100644 --- a/extensions/src/platform-scripture/src/find/find.stories.tsx +++ b/extensions/src/platform-scripture/src/find/find.stories.tsx @@ -8,7 +8,7 @@ import { type UsfmVerseRefVerseLocation, } from 'platform-bible-utils'; import { FindJobStatus, WordRestriction } from 'platform-scripture'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getLocalizedStrings } from '../../../../../.storybook/localization.utils'; import { alertCommand } from '../../../../../.storybook/story.utils'; import { Find, FIND_LOCALIZED_STRING_KEYS, type BookResultEntry } from './find.component'; @@ -20,18 +20,24 @@ import { HidableFindResult, SEARCH_RESULT_LOCALIZED_STRING_KEYS } from './search * scope selector, filters, a find/replace mode toggle, and a results list grouped by book. In the * app the webview owns the find-job lifecycle (begin/poll/stop), replace-with-revert, * version-history commits, and editor navigation, feeding the panel the derived results and - * progress. These stories feed it from a thin in-memory service so the flow is interactive: replace - * mutates the seed results in place, the scope/filters/mode are driven by local state, and editor - * navigation announces the command the webview would run. + * progress. + * + * These stories stand in for that webview with a thin in-memory search engine over a small seed + * corpus, so the flow is fully interactive: typing a term, toggling the filters (match case, word + * restriction, regex), and changing the scope all re-run the search and update the results and the + * highlighting live. Replace / Replace All announce the command the webview would run, show the + * "replaced" state, then commit (the matched result drops out, as a real re-find would); Cancel + * reverts the pending replace. Selecting a result announces the editor navigation command. */ const localizedStrings = getLocalizedStrings([...FIND_LOCALIZED_STRING_KEYS]); const scopeSelectorLocalizedStrings = getLocalizedStrings([...SCOPE_SELECTOR_STRING_KEYS]); const searchResultLocalizedStrings = getLocalizedStrings([...SEARCH_RESULT_LOCALIZED_STRING_KEYS]); -const SEARCH_TERM = 'God'; +const DEFAULT_SEARCH_TERM = 'God'; -// Seed USX for the two books we search across, so the selected result can render verse context. +// Seed USX for the books we search across, so results can render verse context and the search +// engine has real text to scan. const seedUsxByBook: Record = { GEN: ` @@ -55,47 +61,15 @@ const seedUsjByBook: Record = Object.fromEntries( Object.entries(seedUsxByBook).map(([bookId, usx]) => [bookId, usxStringToUsj(usx)]), ); -/** - * Build a {@link HidableFindResult} for an occurrence of the search term in a verse, computing the - * USFM offsets from the seed USJ so the selected result renders the matched text in context. Uses - * the same `UsjReaderWriter` the component uses (no `@papi`). - */ -function makeResult( - bookId: string, - chapterNum: number, - verseNum: number, - occurrence = 1, -): HidableFindResult { - const verseRef: SerializedVerseRef = { book: bookId, chapterNum, verseNum }; - const readerWriter = new UsjReaderWriter(seedUsjByBook[bookId], { - markersMap: USFM_MARKERS_MAP_PARATEXT_3_0, - }); - const usfm = readerWriter.toUsfm(); - const verseStartIndex = readerWriter.usfmVerseLocationToIndexInUsfm(verseRef); - - // Find the requested occurrence of the term at or after the verse start. - let matchIndex = -1; - let searchFrom = verseStartIndex; - for (let i = 0; i < occurrence; i++) { - matchIndex = usfm.indexOf(SEARCH_TERM, searchFrom); - searchFrom = matchIndex + SEARCH_TERM.length; - } - - const start: UsfmVerseRefVerseLocation = { verseRef, offset: matchIndex - verseStartIndex }; - const end: UsfmVerseRefVerseLocation = { - verseRef, - offset: matchIndex - verseStartIndex + SEARCH_TERM.length, - }; - return { start, end, text: SEARCH_TERM }; -} - -const seedResults: HidableFindResult[] = [ - makeResult('GEN', 1, 1), - makeResult('GEN', 1, 2), - makeResult('GEN', 1, 3), - makeResult('JHN', 1, 1, 1), - makeResult('JHN', 1, 1, 2), -]; +/** The verses present in each seed book, in order, used to scan verse regions of the USFM. */ +const seedVersesByBook: Record = { + GEN: [ + { book: 'GEN', chapterNum: 1, verseNum: 1 }, + { book: 'GEN', chapterNum: 1, verseNum: 2 }, + { book: 'GEN', chapterNum: 1, verseNum: 3 }, + ], + JHN: [{ book: 'JHN', chapterNum: 1, verseNum: 1 }], +}; const availableBookIds = ['GEN', 'JHN']; @@ -108,35 +82,153 @@ const localizedBookData = new Map([ ['JHN', { localizedId: 'John', localizedName: 'John' }], ]); +type SearchParams = { + term: string; + shouldMatchCase: boolean; + wordRestriction: WordRestriction; + searchTextType: SearchTextType; + isRegexAllowed: boolean; + scope: Scope; + selectedBookIds: string[]; + verseRef: SerializedVerseRef; +}; + +/** Escape regex metacharacters so a plain search term is matched literally. */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Build the regular expression for the current search params (or `undefined` for an empty term or + * an invalid user-supplied regex). Honors match-case, the word-boundary restriction, and the + * allow-regex toggle the same way the real find job's options would. + */ +function buildSearchRegex(params: SearchParams): RegExp | undefined { + if (!params.term) return undefined; + const flags = `g${params.shouldMatchCase ? '' : 'i'}`; + if (params.isRegexAllowed) { + try { + return new RegExp(params.term, flags); + } catch { + // Invalid regex while the user is typing → no matches (matches the app surfacing zero results). + return undefined; + } + } + const escaped = escapeRegExp(params.term); + switch (params.wordRestriction) { + case 'wholeWord': + return new RegExp(`\\b${escaped}\\b`, flags); + case 'startOfWord': + return new RegExp(`\\b${escaped}`, flags); + case 'endOfWord': + return new RegExp(`${escaped}\\b`, flags); + default: + return new RegExp(escaped, flags); + } +} + +/** + * The books the current scope searches: the current book for chapter/book, the chosen set + * otherwise. + */ +function booksInScope(params: SearchParams): string[] { + if (params.scope === 'selectedBooks') + return params.selectedBookIds.filter((id) => seedUsjByBook[id]); + return [params.verseRef.book]; +} + +/** + * The in-memory search engine standing in for the find job: scans the seed USFM verse-by-verse for + * the term and returns a result per occurrence, with USFM offsets computed the same way the verse + * context is rendered (`usfmVerseLocationToIndexInUsfm`) so the highlight lands on the match. (The + * seed corpus has no non-verse text, so the match-content-in filter re-runs the search but returns + * the same set here.) + */ +function runSearch(params: SearchParams): HidableFindResult[] { + const baseRegex = buildSearchRegex(params); + if (!baseRegex) return []; + + const results: HidableFindResult[] = []; + booksInScope(params).forEach((bookId) => { + const usj = seedUsjByBook[bookId]; + if (!usj) return; + const readerWriter = new UsjReaderWriter(usj, { markersMap: USFM_MARKERS_MAP_PARATEXT_3_0 }); + const usfm = readerWriter.toUsfm(); + const verses = seedVersesByBook[bookId] ?? []; + verses.forEach((verse, index) => { + if (params.scope === 'chapter' && verse.chapterNum !== params.verseRef.chapterNum) return; + const verseStart = readerWriter.usfmVerseLocationToIndexInUsfm(verse); + const nextVerse = verses[index + 1]; + const verseEnd = nextVerse + ? readerWriter.usfmVerseLocationToIndexInUsfm(nextVerse) + : usfm.length; + const region = usfm.slice(verseStart, verseEnd); + // Fresh regex per region so the shared lastIndex never leaks between verses. + const regex = new RegExp(baseRegex.source, baseRegex.flags); + Array.from(region.matchAll(regex)).forEach((match) => { + if (!match[0]) return; + const offset = match.index ?? 0; + const start: UsfmVerseRefVerseLocation = { verseRef: verse, offset }; + const end: UsfmVerseRefVerseLocation = { + verseRef: verse, + offset: offset + match[0].length, + }; + results.push({ start, end, text: match[0] }); + }); + }); + }); + return results; +} + +/** Stable per-result key (book chapter:verse@offset) for tracking hidden/replaced/committed state. */ +const resultKey = (result: HidableFindResult): string => + `${result.start.verseRef.book} ${result.start.verseRef.chapterNum}:${result.start.verseRef.verseNum}@${result.start.offset}`; + const completedStatus: FindJobStatus = 'completed'; const runningStatus: FindJobStatus = 'running'; +/** How long the simulated replace takes before it commits (matches the result card's progress bar). */ +const REPLACE_COMMIT_DELAY_MS = 1300; + type HarnessConfig = { - /** Seed results the in-memory service serves. */ + /** Live in-memory search (default). Set false for fixed-state showcase stories. */ + live?: boolean; + /** Fixed results when `live` is false. */ results?: HidableFindResult[]; /** Initial search term. */ searchTerm?: string; /** Initial mode. */ activeMode?: 'find' | 'replace'; - /** The find-job status the status bar reflects. */ - searchStatus?: FindJobStatus | undefined; + /** Initial scope. */ + scope?: Scope; + /** Initial selected books for the `selectedBooks` scope. */ + selectedBookIds?: string[]; + /** The find-job status the status bar reflects (fixed-state stories only). */ + searchStatus?: FindJobStatus; /** Percent complete for an in-progress search. */ searchProgress?: number; - /** Total results the job reports. */ + /** Total results the job reports (fixed-state stories only). */ totalNumberOfResults?: number; + /** Start with every result already in the replaced state (the Replaced showcase). */ + initiallyReplacedAll?: boolean; }; /** - * Thin in-memory service container: holds the search/replace/filter state and the results, mutates - * the results on replace/replace-all (mark replaced) and on hide, returns seed USJ for verse - * context, and routes editor navigation to `alertCommand` (the different-UI action the webview - * would perform). + * Thin in-memory service container. It owns the search/replace/filter state and runs the search + * engine reactively, then layers hidden / replaced / committed result state on top so hide and + * replace behave like the app: replace shows the "replaced" state, announces the command, and after + * a short delay commits (the result drops out as a re-find would); Cancel reverts pending + * replaces. */ function FindHarness({ config }: { config: HarnessConfig }) { - const [searchTerm, setSearchTerm] = useState(config.searchTerm ?? SEARCH_TERM); + const isLive = config.live !== false; + + const [searchTerm, setSearchTerm] = useState(config.searchTerm ?? DEFAULT_SEARCH_TERM); const [recentSearches, setRecentSearches] = useState(['Lord', 'beginning']); - const [scope, setScope] = useState('book'); - const [selectedBookIds, setSelectedBookIds] = useState(['GEN']); + const [scope, setScope] = useState(config.scope ?? 'selectedBooks'); + const [selectedBookIds, setSelectedBookIds] = useState( + config.selectedBookIds ?? ['GEN', 'JHN'], + ); const [shouldMatchCase, setShouldMatchCase] = useState(false); const [searchTextType, setSearchTextType] = useState('all'); const [wordRestriction, setWordRestriction] = useState('none'); @@ -146,27 +238,85 @@ function FindHarness({ config }: { config: HarnessConfig }) { const [replaceTerm, setReplaceTerm] = useState('Yahweh'); const [preserveCase, setPreserveCase] = useState(false); - const [results, setResults] = useState(config.results ?? seedResults); - const [focusedResultIndex, setFocusedResultIndex] = useState( - config.activeMode === 'replace' ? 0 : undefined, - ); - const [numberOfHiddenResults, setNumberOfHiddenResults] = useState(0); + const [focusedResultIndex, setFocusedResultIndex] = useState(undefined); + + // Result overlay: user-dismissed, pending-replace (red), and committed (replace ran through). + const [hiddenKeys, setHiddenKeys] = useState>(new Set()); + const [replacedKeys, setReplacedKeys] = useState>(new Set()); + const [committedKeys, setCommittedKeys] = useState>(new Set()); + + const replacedKeysRef = useRef>(replacedKeys); + useEffect(() => { + replacedKeysRef.current = replacedKeys; + }, [replacedKeys]); + const commitTimerRef = useRef | undefined>(undefined); const verseRef = useMemo( () => ({ book: 'GEN', chapterNum: 1, verseNum: 1 }), [], ); + const baseResults = useMemo(() => { + if (!isLive) return config.results ?? []; + return runSearch({ + term: searchTerm, + shouldMatchCase, + wordRestriction, + searchTextType, + isRegexAllowed, + scope, + selectedBookIds, + verseRef, + }); + }, [ + isLive, + config.results, + searchTerm, + shouldMatchCase, + wordRestriction, + searchTextType, + isRegexAllowed, + scope, + selectedBookIds, + verseRef, + ]); + + // A fresh result set (new search) clears the transient overlay and resets focus. The Replaced + // showcase seeds every result into the replaced state instead. + useEffect(() => { + setHiddenKeys(new Set()); + setCommittedKeys(new Set()); + setReplacedKeys(config.initiallyReplacedAll ? new Set(baseResults.map(resultKey)) : new Set()); + setFocusedResultIndex(activeMode === 'replace' && baseResults.length > 0 ? 0 : undefined); + if (commitTimerRef.current) { + clearTimeout(commitTimerRef.current); + commitTimerRef.current = undefined; + } + }, [baseResults, config.initiallyReplacedAll, activeMode]); + + const displayedResults = useMemo( + () => + baseResults.map((result) => { + const key = resultKey(result); + return { + ...result, + isHidden: hiddenKeys.has(key) || committedKeys.has(key), + isReplaced: replacedKeys.has(key) && !committedKeys.has(key), + }; + }), + [baseResults, hiddenKeys, replacedKeys, committedKeys], + ); + const resultsByBook = useMemo>(() => { const map = new Map(); - results.forEach((result, originalIndex) => { + displayedResults.forEach((result, originalIndex) => { const bookId = result.start.verseRef.book; const entries = map.get(bookId) ?? []; entries.push({ result, originalIndex }); map.set(bookId, entries); }); return map; - }, [results]); + }, [displayedResults]); const addRecentSearchItem = useCallback((term: string) => { if (term.trim() === '') return; @@ -187,37 +337,79 @@ function FindHarness({ config }: { config: HarnessConfig }) { [], ); - const handleHideResult = useCallback((index: number) => { - setResults((prev) => - prev.map((result, i) => (i === index ? { ...result, isHidden: true } : result)), - ); - setNumberOfHiddenResults((prev) => prev + 1); - setFocusedResultIndex(undefined); + const handleHideResult = useCallback( + (index: number) => { + const target = displayedResults[index]; + if (!target) return; + const key = resultKey(target); + setHiddenKeys((prev) => new Set(prev).add(key)); + setFocusedResultIndex(undefined); + }, + [displayedResults], + ); + + // After the replaced animation, commit the pending replaces: the matched results drop out, just + // as the app's mandatory re-find would no longer return the replaced occurrences. + const scheduleReplaceCommit = useCallback(() => { + if (commitTimerRef.current) clearTimeout(commitTimerRef.current); + commitTimerRef.current = setTimeout(() => { + setCommittedKeys((prev) => new Set([...prev, ...replacedKeysRef.current])); + setReplacedKeys(new Set()); + commitTimerRef.current = undefined; + }, REPLACE_COMMIT_DELAY_MS); }, []); const handleReplace = useCallback( (resultIndex?: number) => { const indexToReplace = resultIndex ?? focusedResultIndex; if (indexToReplace === undefined) return; - // Mark the replaced result so the UI reflects it; in the app a re-find then refreshes. - setResults((prev) => - prev.map((result, i) => (i === indexToReplace ? { ...result, isReplaced: true } : result)), - ); + const target = displayedResults[indexToReplace]; + if (!target || target.isReplaced || target.isHidden) return; + alertCommand('platformScripture.replaceText', { + reference: `${target.start.verseRef.book} ${target.start.verseRef.chapterNum}:${target.start.verseRef.verseNum}`, + search: searchTerm, + replace: replaceTerm, + }); + setReplacedKeys((prev) => new Set(prev).add(resultKey(target))); + scheduleReplaceCommit(); }, - [focusedResultIndex], + [displayedResults, focusedResultIndex, searchTerm, replaceTerm, scheduleReplaceCommit], ); const handleReplaceAll = useCallback(() => { - setResults((prev) => - prev.map((result) => (result.isHidden ? result : { ...result, isReplaced: true })), - ); - }, []); + const toReplace = displayedResults.filter((result) => !result.isHidden && !result.isReplaced); + if (toReplace.length === 0) return; + alertCommand('platformScripture.replaceAllText', { + search: searchTerm, + replace: replaceTerm, + count: toReplace.length, + }); + setReplacedKeys((prev) => { + const next = new Set(prev); + toReplace.forEach((result) => next.add(resultKey(result))); + return next; + }); + scheduleReplaceCommit(); + }, [displayedResults, searchTerm, replaceTerm, scheduleReplaceCommit]); const handleCancelReplace = useCallback(() => { - // Cancel/revert the pending replace: unmark the replaced results. - setResults((prev) => prev.map((result) => ({ ...result, isReplaced: false }))); + if (commitTimerRef.current) { + clearTimeout(commitTimerRef.current); + commitTimerRef.current = undefined; + } + // Revert the pending (not-yet-committed) replaces. + setReplacedKeys(new Set()); }, []); + const numberOfHiddenResults = hiddenKeys.size + committedKeys.size; + const liveSearchStatus: FindJobStatus | undefined = searchTerm.trim() + ? completedStatus + : undefined; + const searchStatus: FindJobStatus | undefined = isLive ? liveSearchStatus : config.searchStatus; + const totalNumberOfResults = isLive + ? baseResults.length + : (config.totalNumberOfResults ?? baseResults.length); + return ( ({ ...result, isReplaced: true })), - }), - ], + decorators: [createDecorator({ activeMode: 'replace', initiallyReplacedAll: true })], }; From 14ac72ec0f5ea66e3d3f1a53651cf88716b7477f Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Fri, 22 May 2026 17:09:03 +0200 Subject: [PATCH 19/23] fix internet settings layout, registration field validation, and inventory status alignment Component (app-facing) fixes flagged while reviewing the stories: - internet-settings: make the form scroll so the proxy-settings card no longer overflows the fixed-height panel with no scrollbar, and keep the "Paratext servers" label left-aligned (the shared Grid right-aligns label columns, which pushed this standalone label far right on a wide panel). - registration-form-view: use shadcn's field-validation styling (aria-invalid) on the registration name and code inputs instead of the bespoke invalid border. - inventory status column: center the status buttons in the cell to match the centered column header (they were left-aligned). Co-Authored-By: Claude Code --- .../registration-form-view.component.tsx | 10 +- .../src/internet-settings.component.tsx | 8 +- .../advanced/inventory/inventory-columns.tsx | 110 +++++++++--------- 3 files changed, 70 insertions(+), 58 deletions(-) diff --git a/extensions/src/paratext-registration/src/components/registration-form-view.component.tsx b/extensions/src/paratext-registration/src/components/registration-form-view.component.tsx index d30ff6783ba..0c7e5a7b511 100644 --- a/extensions/src/paratext-registration/src/components/registration-form-view.component.tsx +++ b/extensions/src/paratext-registration/src/components/registration-form-view.component.tsx @@ -122,6 +122,9 @@ export function RegistrationFormView({ className="tw:max-w-[260px]" value={name} required + // Use shadcn's field-validation styling (destructive border + ring) for the required + // name when it's empty. + aria-invalid={name.trim().length === 0} disabled={isFormDisabled} onChange={onNameChange} /> @@ -133,13 +136,14 @@ export function RegistrationFormView({ {isEditing ? ( +
{localizedStrings['%paratextRegistration_description_internetUse_disclaimer%']}
@@ -215,7 +215,11 @@ export function InternetSettingsForm({ )} - {localizedStrings['%paratextRegistration_label_selectedServer%']} + {/* Keep this label left-aligned: Grid right-aligns label columns for the multi-row forms, + but this standalone row's label drifts far right on a wide panel, so override it. */} + + {localizedStrings['%paratextRegistration_label_selectedServer%']} + +