Repository navigation
Load the Contact Us support launcher from the MFE footer - #346
zamanafzal wants to merge 9 commits into
Conversation
Footer.jsx is copied into every legacy MFE build, so a component here reaches all seven mitxonline MFEs the same way AppziScript already does. The launcher initialises only when CONTACT_US_SUPPORT_API_URL is set, which ol-infrastructure sets on rc and production only. The bundle is loaded from the learning MFE's copy rather than added to each MFE's own public/ tree. extra_npm_bundles runs `cp package/dist/bundles/*`, which is 66MB once source maps are counted, and paying that on six more artifacts to ship one 900KB file is not worth it. A classic script tag makes a no-cors request, so authoring loading it from the LMS domain is fine -- this is the same shape as Appzi loading from w.appzi.io. The smoot-design floor moves to ^6.39.0 because that is where contactUsLauncher first shipped; 6.38.1 carries no such bundle, and the learning MFE is now the only copy of it. Refs hq#13991
The six non-learning MFEs load the bundle out of the learning MFE's deploy, and nothing orders those deploys, so a 404 window is real. Without an onerror handler the launcher just never appeared, with nothing in the console; `window.contactUsLauncher?.init` swallowed the same way when a static host rewrote the 404 to index.html, since a classic script that throws during evaluation still fires `load`. Also honour CONTACT_US_BUNDLE_PATH, matching AI_DRAWER_BUNDLE_PATH and FEEDBACK_DRAWER_BUNDLE_PATH, so a local harness can point at its own build. Refs hq#13991
There was a problem hiding this comment.
Summary
Adds a shared Contact Us launcher to the legacy footer, enabling support chat across MFEs with configured API access and authenticated user context. The launcher dynamically loads the learning MFE’s bundle, initializes it, and cleans up on unmount.
Key Changes
- Loads and initializes the Contact Us support launcher across legacy MFEs.
- Passes support API configuration and authenticated user email to chat requests.
- Upgrades Smoot Design to include the shared launcher bundle.
All Relevant File Changes (click to expand)
- deployments/mit-ol/mfe_slot_config/legacy/Footer.jsx: Dynamically loads, configures, and unmounts the Contact Us launcher.
- deployments/mit-ol/mfe_slot_config/legacy/build_config.yaml: Updates Smoot Design dependency to version 6.39.0.
💬 Feedback & Commands
Share feedback on my comments with 👍 👎 — I'll stop raising issues you push back on. Think I missed something? Adjust my sensitivity →
5 things I can do →
#optibot review— I'll re-review your latest changes (incremental)#optibot full review— I'll re-review the entire PR from scratch#optibot fix— I'll apply my suggested fixes to your code#optibot compliance— I'll run a compliance check on your changes#optibot release notes— I'll write up release notes for your production release
💡 Tip: Add repo-specific conventions that I'll apply to every review — Review Memory.
- 🔄 re-review this PR
There was a problem hiding this comment.
Summary
Adds a support contact launcher to the learning platform, making chat available across MFEs with authenticated user context. The launcher loads asynchronously from the learning MFE and initializes chat using configured API and bundle URLs.
Key Changes
- Adds Contact Us chat launcher to the shared footer across MFEs.
- Passes authenticated users’ email addresses to support chat requests.
- Ensures launcher scripts handle loading failures and clean up on unmount.
- Updates Smoot Design dependency to include the contact launcher bundle.
All Relevant File Changes (click to expand)
- deployments/mit-ol/mfe_slot_config/legacy/Footer.jsx: Loads, initializes, and removes the Contact Us launcher script.
- deployments/mit-ol/mfe_slot_config/legacy/build_config.yaml: Requires Smoot Design version 6.39.0 for the launcher bundle.
💬 Feedback & Commands
Share feedback on my comments with 👍 👎 — I'll stop raising issues you push back on. Think I missed something? Adjust my sensitivity →
5 things I can do →
#optibot review— I'll re-review your latest changes (incremental)#optibot full review— I'll re-review the entire PR from scratch#optibot fix— I'll apply my suggested fixes to your code#optibot compliance— I'll run a compliance check on your changes#optibot release notes— I'll write up release notes for your production release
💡 Tip: Add repo-specific conventions that I'll apply to every review — Review Memory.
There was a problem hiding this comment.
🔵 Needs a closer look
Cross-origin loading and Appzi coexistence need release-candidate validation, and rollout requires promoting the learning MFE first.
No open issues 🎉
What changed in this PR
Adds the Contact Us support launcher to the shared legacy MFE footer, loading its bundle from the learning MFE’s deployment.
Changes:
- Enables the launcher when
CONTACT_US_SUPPORT_API_URLis set and passes the signed-in learner’s email when available. - Adds bundle-load error reporting and cleanup, and raises the
@mitodl/smoot-designminimum to^6.39.0.
| File | Description |
|---|---|
| deployments/mit-ol/mfe_slot_config/legacy/Footer.jsx | Loads and initializes the optional support launcher alongside Appzi. |
| deployments/mit-ol/mfe_slot_config/legacy/build_config.yaml | Updates the learning MFE’s shared bundle dependency and documents its use. |
Review effort: Balanced
A throwing unmount propagated out of the React cleanup. The two sibling bundle loaders, AIDrawerManagerSidebar and FeedbackDrawerSlot, already guard theirs the same way.
…rt request The footer already read authenticatedUser.email; name and preferred_username come off the same JWT at the same moment and were being dropped. learn-ai has no session on this cross-origin POST, so these are the only values that can populate the Zendesk requester fields on a ticket filed from the MFE.
| ...(userName ? { user_name: userName } : {}), | ||
| ...(userUsername ? { user_username: userUsername } : {}), | ||
| }; | ||
| launcher = window.contactUsLauncher.init({ |
There was a problem hiding this comment.
This ties the launcher's lifetime to the footer, and the learning MFE remounts the footer on every course tab change. On frontend-app-learning master (which mitxonline builds), each tab (OutlineTab, DatesTab, ProgressTab, CoursewareContainer) renders its own TabPage, and TabPage renders <FooterSlot />. So going from the outline to courseware, or to Dates or Progress, runs this cleanup: unmount() destroys the open card and its conversation, and the bundle is evaluated again and init() runs again.
The card is deliberately non-modal so learners can keep using the page, which makes navigating mid-conversation a supported case. A learner who opens Contact Us on a broken unit and then checks another tab loses the conversation. Their next send also has clear_history: true, which starts a new thread and can mean a second ticket. This also misses hq#14033's "one launcher per page load".
Suggestion: load the bundle and call init() once per page, outside the component lifecycle, e.g. with a module-level promise like loadPromise in feedbackBundle.js. Then don't unmount on footer cleanup.
There was a problem hiding this comment.
Fixed as you suggested: module-level cached promise, no effect cleanup. The card mounts onto document.body, outside React's tree, so surviving the remount only takes not tearing it down.
One correction to the mechanism: the tab bar isn't client-side. ResponsiveCourseTabs.jsx:118-124 and upstream CourseTabLink.tsx:13-19 are plain <a href>, so a tab click is a full page load. The remount that bites is outline ↔ courseware — separate <Route> entries, crossed by SequenceTitle.tsx:27 and CourseBreadcrumbs.jsx:68.
The rejection is cached too, unlike feedbackBundle.js: that retries on a click, this would re-log on every navigation for a failure that won't fix itself.
| // learn-ai gets no session on this cross-origin POST, so the ticket's | ||
| // requester fields are whatever we send here or nothing. | ||
| const requestBody = { | ||
| ...(userEmail ? { user_email: userEmail } : {}), |
There was a problem hiding this comment.
Not a blocker for this PR: as the comment above says, learn-ai has no session here, so this client-supplied user_email is the requester's entire identity. Anyone can POST to support_agent/ with any address and file a ticket as that person. Is that limited on the learn-ai or Zendesk side (e.g. where replies go)?
There was a problem hiding this comment.
It's the requester proper — zendesk.py:314 sets "requester": {"email": …} — so a public agent reply does go to that address.
What limits it, all learn-ai side:
- A signed-in learner can't be renamed by the body:
consumers.py:936-960prefers the session email. - An unverified address is marked rather than trusted —
tim_unverified_requestertag, subject replaced, first comment filed private (zendesk.py:296-299,:312-313) — so filing doesn't mail the submitted text to a spoofed address. That was mitodl/hq#13985, closed by learn-ai#132.
What isn't limited is the rate: the anonymous throttle key re-derives on every request under signed_cookies (mitodl/hq#13968, open).
Agreed it's not a blocker here — the footer only passes values the backend already accepts. I didn't re-check whether Zendesk's "Verify anonymous requests" is on for the live instance, or how it notifies when the first comment is private.
The learning MFE renders FooterSlot inside each tab's TabPage, and Outline, Dates, Progress and courseware each render their own TabPage under a separate route. A tab change therefore remounted the footer and ran the effect cleanup, which unmounted the launcher: an open card closed, the conversation went with it, and since historyCleared resets on a fresh init the next message carried clear_history: true, starting a new thread and filing a second ticket for one learner's problem. init() has no idempotency guard either, so the bundle was re-evaluated and re-initialised every time. Load the bundle and call init() behind a module-level promise instead, and drop the cleanup. The card mounts onto document.body, outside React's tree, so surviving the footer unmount takes nothing but not tearing it down. That is hq#14033's "one launcher per page load". The rejection is cached as well. Every remount used to retry, and the six non-learning MFEs 404 until the learning MFE is promoted, so retrying turned a known-bad bundle into one request per tab change. Report it once instead. init() is wrapped because a synchronous throw would otherwise escape the load handler and leave the promise pending, with nothing saying why no launcher appeared. Refs hq#14033
init() can throw a non-Error, in which case `.message` is undefined and the console line says nothing. Passing the value through keeps the stack, which is the only clue when the throw comes from inside the bundle. Refs hq#14033
The comment blamed course tab changes, but the tab bar is plain <a href> (CourseTabLink.tsx:13-19), so those are full page loads and clear module state anyway. The real client-side remounts cross the outline/courseware route boundary: SequenceTitle.tsx:27 opening a subsection, and CourseBreadcrumbs.jsx:68 going back to the outline. Both render their own TabPage, which is where FooterSlot lives (TabPage.tsx:157). Also record why this promise keeps its rejection where the sibling feedbackBundle.js discards it. Refs hq#14033
#346 moved the launcher to a module-level promise so a footer remount stops tearing it down. That is incompatible with the gate as written here, which kept `enabled` in component state: every remount reset it to null, unmounted the launcher, re-fetched the gate and re-injected the bundle. feoh flagged exactly this on both PRs. Resolved by hoisting the gate to module scope alongside the launcher: - The gate answer is cached per course key, so a remount reuses it instead of hiding the launcher and asking again. A different course still re-asks, and the launcher stays up until that answer lands rather than flickering. - Bundle loading is now separate from mounting, so the kill switch can take the launcher down and a re-enable brings it back without refetching the bundle. - `launcherWanted` records the desired state, because the mount awaits the bundle and a flag flip can land mid-load. - `init()` throwing is reported instead of vanishing into a settled promise. Verified with /tmp/sdd363/gate.mjs, which extracts the real slice of Footer.jsx and evaluates it, with a React stub that honours both the dependency array and useState. Against the pre-merge gate: 13 passed, 5 failed. After: 18, 0.
What are the relevant tickets?
Refs mitodl/hq#14033, under mitodl/hq#13712
Description (What does it do?)
Footer.jsxis copied into every legacy MFE build, which is how the Appzi script reachesall seven.
ContactUsLauncherScriptmounts the same way, next toAppziScript: loadscontactUsLauncher.umd.js, callsinit()with the support endpoint and, when signed in,the learner's email, name and username. learn-ai has no session on this cross-origin POST,
so those three are the ticket's only requester identity. No endpoint, no launcher.
The bundle load and
init()sit at module scope and the effect has no cleanup, becauseFooterSlotrenders insideTabPageand the outline and courseware are separate routes —opening a subsection remounts the footer with no page load. Unmounting there would close an
open card and reset
historyCleared, so the next message starts a new backend thread andfiles a second ticket. The card lives on
document.body, outside React, so surviving theremount only takes not tearing it down. The rejection is cached too: the bundle 404s until
the learning MFE is promoted, and that should log once, not refetch on every navigation.
Only the learning MFE lists
extra_npm_bundles; the other six load its copy over anabsolute URL, since authoring is on
studio_domainand a classic<script src>isno-cors.
^6.38.1already resolved to 6.39.x, so the bump just makes the floor explicit.Pairs with mitodl/ol-infrastructure#6549 (endpoint on mitxonline rc only). #363 stacks on
this branch and adds the runtime waffle gate.
Screenshot:
How can this be tested?
uv run pytest tests/core -q— 243 passed, none of it touching the new component; there'sno JS test harness in this repo for the slot files.
A throwaway Node harness covers the remount behaviour: it extracts the
ContactUsLauncherScriptslice out of thisFooter.jsxand evaluates it against stubbeduseEffect/useLocation/document/fetch, so it runs the real code. 10 green here, 4/10before the module-level move. Not committed — nothing here to hang a JS test off.
By hand on a local Tutor stack, learn-ai on
:8009, as a learner enrolled incourse-v1:test+13+1:Footer.jsxand the 6.39.xcontactUsLauncher.umd.jsinto afrontend-app-learningcheckout (public/static/smoot-design/).CONTACT_US_SUPPORT_API_URL='http://local.openedx.io:8009/http/support_agent/'andCONTACT_US_BUNDLE_PATH='/static/smoot-design/contactUsLauncher.umd.js'— the local MFEhas no
/learn/static/to serve the default from.user_email,user_name,user_username(third screenshot), each omitted rather thanempty when absent.
Additional Context
studio.courses.rc.learn.mit.eduisn't in learn-ai's QA
cors_frontend_origins. The no-cors script still loads so the cardopens, but the chat POST is preflighted and the preflight has no matching origin. Adding
the origin is an ol-infrastructure change, not this one.
nothing orders the promotions, so until learning rebuilds, the bundle 404s.
production-only, #6549 rc-only — but there will be one when production is enabled. Appzi's
placement lives in their dashboard, so check the overlap on rc first.