From 2026ea4aea29d424eba3d8725006cf787a90ad36 Mon Sep 17 00:00:00 2001 From: Andrew Polk Date: Fri, 23 Oct 2026 16:23:11 -0700 Subject: [PATCH] Collection Settings in React: Subscription page (BL-16734) The page, laid out like the card's mockup, shares the code box and status line with the WinForms tab, which becomes a self-contained LegacySubscriptionSettings that the cutover deletes whole. The dialog keeps the code being typed and posts it with the other values; Apply hands it to ApplySubscriptionAndBookshelf before the xmatter check. A typed code is described by a stateless settings/subscription?code=, so Cancel leaves nothing behind. Subscription badges and gated menu items open the dialog in the browser; a badge on the dialog's own pages switches page through Config-R's ConfigrPaneHandle, keeping edits. E2E tests: Notion Test Case 849. Co-Authored-By: Claude Opus 5.5 --- docs/collection-settings-react/PLAN.md | 39 +- src/BloomBrowserUI/bookEdit/workspaceRoot.ts | 5 + .../CollectionSettingsDialog.test.tsx | 432 +++++++++++++----- .../collection/CollectionSettingsDialog.tsx | 85 ++-- .../collection/collectionSettingsTypes.ts | 6 + .../settingsPages/SubscriptionPage.tsx | 100 ++++ .../collection/subscriptionCodeControl.tsx | 109 +++-- .../subscriptionSettingsTab.entry.tsx | 4 +- .../collection/subscriptionSettingsTab.tsx | 34 +- .../collection/useSubscriptionInfo.ts | 46 +- .../collectionsTab/SubscriptionStatus.tsx | 4 +- .../react_components/featureStatus.ts | 8 +- .../react_components/localizableMenuItem.tsx | 10 +- src/BloomE2E/AUTOMATION-DEBT.md | 12 + src/BloomE2E/README.md | 8 +- src/BloomE2E/helpers/collection.ts | 50 ++ src/BloomE2E/helpers/collectionSettings.ts | 119 ++++- .../collection-settings-subscription.spec.ts | 150 ++++++ .../Collection/CollectionSettingsUpdater.cs | 41 +- src/BloomExe/Workspace/WorkspaceView.cs | 14 +- .../controllers/CollectionSettingsTypes.cs | 28 +- src/BloomExe/web/controllers/CommonApi.cs | 22 +- .../SubscriptionSettingsEditorApi.cs | 28 +- .../CollectionSettingsUpdaterTests.cs | 50 ++ .../controllers/CollectionSettingsApiTests.cs | 1 + 25 files changed, 1135 insertions(+), 270 deletions(-) create mode 100644 src/BloomBrowserUI/collection/settingsPages/SubscriptionPage.tsx create mode 100644 src/BloomE2E/tests/collection-settings-subscription.spec.ts diff --git a/docs/collection-settings-react/PLAN.md b/docs/collection-settings-react/PLAN.md index bfbffd908f69..1e36c2a8ed79 100644 --- a/docs/collection-settings-react/PLAN.md +++ b/docs/collection-settings-react/PLAN.md @@ -87,7 +87,10 @@ What a page card builds on: **Known gap for reused components.** The Subscription, Team Collection and bookshelf components post their edits to `settings/*` endpoints that only feed the WinForms dialog. The first card that -reuses one decides how its edits reach the React POST. +reuses one decides how its edits reach the React POST. BL-16734 did it for the subscription: the +dialog keeps the code being typed in its own state (the editor is not a Config-R control), puts it +into `values.subscription.code` for the restart check and the POST, and the reused components +describe it with a stateless `GET settings/subscription?code=`, so Cancel has nothing to reset. ## 4. Decisions @@ -138,13 +141,20 @@ client), Page Numbering Style (Q5). Values for xmatter, QR and places already ex **BL-16736 Bloom Library.** The bookshelf, with subscription gating (Q10). Post it and hand it to `ApplySubscriptionAndBookshelf`, which has the expired-bookshelf rules. -**BL-16734 Subscription.** Reuse `SubscriptionSettings`. Hand the entered subscription to -`ApplySubscriptionAndBookshelf` (its descriptor-change bookshelf clearing never fires; BL-16904). -Reset its preview on Cancel. The "fix invalid branding" startup path must open this page. Decide -whether the other pages' subscription gating (Experimental's Team Collections box, the bookshelf) -should follow a code typed here but not yet saved; `features/status` only knows the saved one. If -so, replace the Experimental page's own `useGetFeatureStatus` call with a small dialog-level -subscription lookup that every page reads. +**BL-16734 Subscription.** Built. The page lays out its own markup around the shared +`SubscriptionControls` and `SubscriptionStatus`; the WinForms tab is a self-contained copy +(`LegacySubscriptionSettings`, deleted whole in the cutover). The entered code goes +to `ApplySubscriptionAndBookshelf` (its descriptor-change bookshelf clearing never fires; +BL-16904), before the xmatter check, since the branding can choose the pack. It also moved the +"open settings on the Subscription page" callers to the new dialog. Subscription badges and gated +menu items call `showCollectionSettingsDialog` in the browser (from an iframe, through the +workspace bundle's exports); `common/showSettingsDialog` remains for the comic book ReadMe links, +and `CheckForInvalidBranding` launches it from C#. That startup check never fires (nothing sets +`InvalidBranding`); it is removed in the cutover (section 6), not revived. Until the cutover, a +badge on one of the WinForms dialog's own tabs does nothing. A badge on one of the dialog's own +pages switches the open pane to the Subscription page, keeping the edits so far, +through the page control Config-R gains in BL-17032. +The other pages' gating still follows the saved subscription, not a code typed but not saved. **BL-16735 Team Collection.** Reuse `TeamCollectionSettingsPanel` with its overlay and warning. Validate administrator emails in the client (`CollectionSettings.ValidateAdministrators` has the @@ -163,7 +173,7 @@ were on another tab there); in the values the font is already part of each langu **BL-16738 Experimental.** Per Q9. Built: a feature that lands adds an `ExperimentalFeatureSetting` row in `settingsPages/ExperimentalPage.tsx` and a property in `ExperimentalValues`. -## 6. Cutover (new card) +## 6. Cutover (BL-17042) - First go through the WinForms OK handler (`_okButton_Click`) line by line and confirm each rule exists in the React path or was dropped on purpose. @@ -171,10 +181,13 @@ row in `settingsPages/ExperimentalPage.tsx` and a property in `ExperimentalValue bundles and their vite entries, `DialogBeingEdited`, and the legacy launch path. The new save path still reads `CollectionSettingsDialog.AutoUpdateSupportedOnThisPlatform` (in `AdvancedValues`), so that flag needs a new home when the WinForms class goes. -- Point the "open settings on the Subscription page" callers (subscription badges, menu items, - `CheckForInvalidBranding`) at the new dialog. A caller in an iframe reaches the top frame's - `showCollectionSettingsDialog` through the workspace bundle's exports, as `showBookSettingsDialog` - does; the Comic Book ReadMe links are `fetch`es in book content, so they keep an endpoint. +- The "open settings on the Subscription page" callers already open the new dialog (BL-16734). + The `forFixingEnterpriseSubscription` parameter of `OpenLegacySettingsDialog` goes with it. +- Remove the dead invalid-branding startup check: `CollectionSettings.InvalidBranding`, + `WorkspaceView.CheckForInvalidBranding`, and its callers in `Shell` and `Program.cs`. Nothing + has set `InvalidBranding` since "Generalize Subscriptions (BL-14379)" (March 2025), so the check + always returns at once. Bringing back a startup prompt for an expired or wrong code would be a + separate product decision; the Collections tab's expiry banner covers that case today. - Mark unused `CollectionSettingsDialog.*` strings obsolete (never delete), update the help mapping and help pages, add an e2e test that opens the dialog and changes a setting (and update `src/BloomE2E/AUTOMATION-DEBT.md`), post test ideas on BL-16271. diff --git a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts index 845f82ad0e56..8a1c0f35491a 100644 --- a/src/BloomBrowserUI/bookEdit/workspaceRoot.ts +++ b/src/BloomBrowserUI/bookEdit/workspaceRoot.ts @@ -60,6 +60,7 @@ export interface IWorkspaceExports { ): void; showAboutDialogFromWorkspaceRoot(): void; showBookSettingsDialog(initiallySelectedPageKey?: string): void; + showCollectionSettingsDialog(initialPageKey?: string): void; showDecodableReaderSetupDialog(): void; closeDecodableReaderSetupDialog(): void; showImageGalleryDialog(img: HTMLElement, searchLang: string): void; @@ -92,6 +93,8 @@ export { showBookGridSetupDialog }; import "../lib/errorHandler"; import { showBookSettingsDialog } from "./bookAndPageSettings/BookAndPageSettingsDialog"; export { showBookSettingsDialog }; +import { showCollectionSettingsDialog } from "../collection/CollectionSettingsDialog"; +export { showCollectionSettingsDialog }; import { closeDecodableReaderSetupDialog, showDecodableReaderSetupDialog, @@ -449,6 +452,7 @@ interface WorkspaceBundleApi { showLinkTargetChooserDialog: typeof showLinkTargetChooserDialog; showBookGridSetupDialog: typeof showBookGridSetupDialog; showBookSettingsDialog: typeof showBookSettingsDialog; + showCollectionSettingsDialog: typeof showCollectionSettingsDialog; showDecodableReaderSetupDialog: typeof showDecodableReaderSetupDialog; closeDecodableReaderSetupDialog: typeof closeDecodableReaderSetupDialog; showRegistrationDialog: typeof showRegistrationDialogForEditTab; @@ -499,6 +503,7 @@ window.workspaceBundle = { showLinkTargetChooserDialog, showBookGridSetupDialog, showBookSettingsDialog, + showCollectionSettingsDialog, showDecodableReaderSetupDialog, closeDecodableReaderSetupDialog, showRegistrationDialog: showRegistrationDialogForEditTab, diff --git a/src/BloomBrowserUI/collection/CollectionSettingsDialog.test.tsx b/src/BloomBrowserUI/collection/CollectionSettingsDialog.test.tsx index e1b61e9dd96e..368eb2e25889 100644 --- a/src/BloomBrowserUI/collection/CollectionSettingsDialog.test.tsx +++ b/src/BloomBrowserUI/collection/CollectionSettingsDialog.test.tsx @@ -41,6 +41,7 @@ const initialValues: ICollectionSettingsValues = { province: "", district: "", }, + subscription: { code: "Saved-Code-123456-7890" }, bloomLibrary: { defaultBookshelf: "" }, advanced: { autoUpdate: true, collectionName: "Test Collection" }, experimental: { "team-collections": false, tables: false }, @@ -65,8 +66,9 @@ const { mockGet: vi.fn(), mockPostJson: vi.fn(), mockCloseDialog: vi.fn(), - // Lets a test close and re-open the dialog, which is what the real launch plumbing does. - dialogState: { open: true }, + // Lets a test close and re-open the dialog, which is what the real launch plumbing does, and + // launch it again (a new event) while it is open. + dialogState: { open: true, openingEvent: {} as Record }, // Whether Bloom has answered the subscription check yet, and whether the collection's tier // includes Team Collections. teamCollectionFeature: { loaded: true, enabled: true }, @@ -106,6 +108,40 @@ vi.mock("../react_components/requiresSubscription", () => ({ ), })); +// The Subscription page's code editor and status talk to Bloom themselves. These +// stand-ins show the code they were given, and let a test type a new one. +vi.mock("./subscriptionCodeControl", () => ({ + SubscriptionControls: (props: { + code: string; + onCodeChange: (code: string) => void; + }) => ( +
+
{props.code}
+
+ ), +})); + +vi.mock("../collectionsTab/SubscriptionStatus", () => ({ + SubscriptionStatus: () => null, +})); + +vi.mock("../react_components/markdown", () => ({ + Markdown: () => null, +})); + +// On the Subscription page, the note box holds only the community invitation. +vi.mock("../react_components/boxes", () => ({ + NoteBox: () =>
, +})); + vi.mock("../utils/bloomApi", () => ({ get: mockGet, postJson: mockPostJson, @@ -117,7 +153,7 @@ vi.mock("../react_components/l10nHooks", () => ({ vi.mock("../react_components/BloomDialog/BloomDialogPlumbing", () => ({ useEventLaunchedBloomDialog: () => ({ - openingEvent: {}, + openingEvent: dialogState.openingEvent, closeDialog: mockCloseDialog, propsForBloomDialog: { open: dialogState.open }, }), @@ -167,117 +203,183 @@ vi.mock("../react_components/BloomDialog/commonDialogComponents", () => ({ ), })); -const { MockConfigrGroup } = vi.hoisted(() => ({ +const { + MockConfigrGroup, + MockConfigrStatic, + MockMountedWith, + MockShownPage, + withPaneRef, +} = vi.hoisted(() => ({ + // The dialog gives ConfigrPane a ref, so the stand-in must accept one; this passes it on + // to the stand-in as its paneRef prop. + withPaneRef: (Pane: React.FunctionComponent

) => { + const PaneTakingRef = Pane as unknown as React.FunctionComponent<{ + paneRef: React.ForwardedRef; + }>; + const MockConfigrPane = React.forwardRef((props: object, ref) => ( + + )); + MockConfigrPane.displayName = "MockConfigrPane"; + return MockConfigrPane; + }, + // The page the pane shows. Like Config-r, it opens on the initially selected page, moves + // when the user clicks another one, and moves when the pane's handle asks it to. + MockShownPage: (props: { + paneRef: React.ForwardedRef; + initialPageKey?: string; + }) => { + const [shownPageKey, setShownPageKey] = React.useState( + props.initialPageKey, + ); + React.useImperativeHandle(props.paneRef, () => ({ + selectTopLevelPage: setShownPageKey, + })); + return ( + <> +

{shownPageKey}
+