General source code refactoring
This commit is contained in:
47
README.md
47
README.md
@@ -4,8 +4,16 @@ Browser extension that adds the "Opportunities Extension" shortcut to the permit
|
|||||||
|
|
||||||
## Development Structure
|
## Development Structure
|
||||||
|
|
||||||
- `src/content.js`: single source file maintained by the project.
|
- `src/content/`: page integration, modal shell, theme and content composition.
|
||||||
|
- `src/background/`: synchronous message registration and background composition.
|
||||||
|
- `src/features/`: opportunities, products, consumption, AI summaries, preferences and debug.
|
||||||
|
- `src/auth/`: credential-specific selection, reuse and session recovery.
|
||||||
|
- `src/services/`: Fusion/SPA operations and background HTTP/JSON transport.
|
||||||
|
- `src/platform/`: narrow browser messaging, cookies, tabs and storage adapters.
|
||||||
|
- `src/shared/messages.js`: names of the existing cross-context messages.
|
||||||
- `scripts/build.mjs`: generates browser-specific packages.
|
- `scripts/build.mjs`: generates browser-specific packages.
|
||||||
|
- `tests/`: isolated synthetic regression tests and a visual fixture.
|
||||||
|
- `docs/`: architecture/contracts and incremental refactoring record.
|
||||||
- `dist/chromium/`: output for Google Chrome and Microsoft Edge.
|
- `dist/chromium/`: output for Google Chrome and Microsoft Edge.
|
||||||
- `dist/firefox/`: output for Firefox.
|
- `dist/firefox/`: output for Firefox.
|
||||||
|
|
||||||
@@ -19,6 +27,41 @@ npm run build
|
|||||||
|
|
||||||
The build recreates `dist/chromium` and `dist/firefox` from the same source.
|
The build recreates `dist/chromium` and `dist/firefox` from the same source.
|
||||||
|
|
||||||
|
Do not edit files under `dist` directly. Both targets use Manifest V3, with a
|
||||||
|
Chromium service worker and Firefox background scripts. The build composes classic
|
||||||
|
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
|
||||||
|
context. It serializes `src/content/styles/redwood.css` at build time. There is no
|
||||||
|
runtime module loader, browser-side eval, ES-module requirement or new dependency.
|
||||||
|
|
||||||
|
## Tests and Maintenance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests use built-in Node.js modules and synthetic credentials, without real Fusion
|
||||||
|
requests. The characterization suite verifies manifests, source/output equality,
|
||||||
|
fiscal dates, payloads, preferences, pagination, cache/progress and both browser API
|
||||||
|
styles. Architecture tests enforce source inclusion, explicit factory dependencies,
|
||||||
|
isolated state and unchanged CSS. These complement consumption, session recovery
|
||||||
|
and AI suites. `check` also fails if generated packages are stale.
|
||||||
|
There is no separate lint configuration or runtime dependency installation.
|
||||||
|
|
||||||
|
See [architecture and contracts](docs/architecture.md) and the
|
||||||
|
[incremental refactoring record](docs/refactoring.md) for execution boundaries,
|
||||||
|
authentication, storage, known risks and verification limits.
|
||||||
|
Mock-based tests do not replace testing the installed extension in each browser.
|
||||||
|
|
||||||
|
Without npm, run `node scripts/build.mjs`, `node scripts/test.cjs` and
|
||||||
|
`node scripts/build.mjs --check`. Development checks were run with Node 24.19.0.
|
||||||
|
|
||||||
|
For a local UI smoke test, run `node scripts/preview.mjs` and open the loopback URL
|
||||||
|
printed by the command. The fixture uses synthetic opportunities, tokens, products,
|
||||||
|
AI text and consumption; unexpected requests fail closed. It does not authenticate
|
||||||
|
against Fusion or read real extension storage. `/consumption-trend.html` provides
|
||||||
|
a focused chart fixture. Stop the server with Ctrl+C when finished.
|
||||||
|
|
||||||
## Release 1.0.0
|
## Release 1.0.0
|
||||||
|
|
||||||
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
|
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
|
||||||
@@ -37,7 +80,7 @@ The build recreates `dist/chromium` and `dist/firefox` from the same source.
|
|||||||
|
|
||||||
## Loading in Development Mode
|
## Loading in Development Mode
|
||||||
|
|
||||||
### Chrome ou Microsoft Edge
|
### Chrome or Microsoft Edge
|
||||||
|
|
||||||
1. Open `chrome://extensions` or `edge://extensions`.
|
1. Open `chrome://extensions` or `edge://extensions`.
|
||||||
2. Enable Developer mode.
|
2. Enable Developer mode.
|
||||||
|
|||||||
979
dist/chromium/background.js
vendored
979
dist/chromium/background.js
vendored
File diff suppressed because it is too large
Load Diff
7571
dist/chromium/content.js
vendored
7571
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
979
dist/firefox/background.js
vendored
979
dist/firefox/background.js
vendored
File diff suppressed because it is too large
Load Diff
7571
dist/firefox/content.js
vendored
7571
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
198
docs/architecture.md
Normal file
198
docs/architecture.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Architecture and Compatibility Contracts
|
||||||
|
|
||||||
|
This document describes the integrated modular implementation. Verification and
|
||||||
|
preexisting risks are tracked in [refactoring.md](refactoring.md).
|
||||||
|
|
||||||
|
## Sources, Outputs, and Runtime Boundaries
|
||||||
|
|
||||||
|
- Editable sources are factory modules under `src/`; the former monoliths were removed.
|
||||||
|
- `scripts/sources.cjs` explicitly lists modules for each context. `compose.cjs`
|
||||||
|
validates syntax, serializes CSS, wraps the modules in an IIFE and calls the
|
||||||
|
corresponding application factory/start method. `build.mjs` writes two packages.
|
||||||
|
- `dist/chromium` serves Chrome and Edge; `dist/firefox` serves Firefox. Do not edit outputs.
|
||||||
|
- Both manifests use V3. Chromium has a classic background service worker; Firefox
|
||||||
|
uses `background.scripts`. Neither entry point is declared as a module.
|
||||||
|
- Content scripts run at `document_idle`, in all matching frames on the two
|
||||||
|
configured Fusion welcome pages. Their code also checks the hostname/path.
|
||||||
|
- There are no extension UI pages, injected page-context scripts, custom CSP,
|
||||||
|
declared execution world, or web-accessible resources.
|
||||||
|
- Permissions remain `cookies` and `storage`, with host access limited to the
|
||||||
|
configured Fusion host and `spa.oracle.com`.
|
||||||
|
|
||||||
|
Content owns the DOM and per-frame state. Background owns privileged cookies,
|
||||||
|
temporary tabs, extension storage and SPA requests. These contexts communicate
|
||||||
|
using serializable runtime messages, not shared variables. Page localStorage is
|
||||||
|
different from extension storage and is not a safe place for credentials.
|
||||||
|
|
||||||
|
## Current Ownership and Side Effects
|
||||||
|
|
||||||
|
| Context | Responsibilities | State and effects |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Content | Fusion tile and main/preferences/consumption modals | DOM nodes, injected CSS, keyboard/pointer/resize listeners, MutationObserver |
|
||||||
|
| Content | Opportunity query and presentation | Filter sets, sorting, search, request versions, column formatters, dashboard animation |
|
||||||
|
| Content | Products UI | Current request ID, product map, expanded rows, progress, last update |
|
||||||
|
| Content | Consumption UI | Plan state, per-plan/period trend cache, chart navigation and selected series |
|
||||||
|
| Content | AI summary | Per-opportunity in-flight state and summary; fresh generation on expansion |
|
||||||
|
| Content | Authentication/debug | General and scoped token promises, SPA authorizations, request logs, visibility flag |
|
||||||
|
| Background | Message dispatcher | Synchronously registered listener, asynchronous replies using `return true` |
|
||||||
|
| Background | Products | Four workers, per-opportunity in-flight map, lazy cache read and serialized writes |
|
||||||
|
| Background | Cookie/session recovery | Inactive tabs, polling/retry delays, cleanup in `finally` |
|
||||||
|
| Background | SPA operations | HTTP/JSON parsing, account/plan extraction and debug envelopes |
|
||||||
|
|
||||||
|
Closing the main modal hides it rather than destroying it. Many requests can
|
||||||
|
continue while it is hidden. Version and request-ID checks suppress stale UI
|
||||||
|
updates; they do not abort network traffic. No AbortController is currently used.
|
||||||
|
Some listeners have window guards; there is no general disposal lifecycle.
|
||||||
|
|
||||||
|
## Authentication and API Routing
|
||||||
|
|
||||||
|
Never substitute one token family for another or assume an opaque token is a JWT.
|
||||||
|
Production tokens are held in memory, not deliberately persisted by the extension.
|
||||||
|
|
||||||
|
| Credential | Acquisition | Consumers | Lifetime/recovery |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| XSRF | Background cookie lookup, then content `document.cookie` fallback | `x-xsrf-token` for both tokenrelays | Cookie recovery tab on missing/invalid response; existing retry rules |
|
||||||
|
| Fusion general | GET `/fscmRestApi/tokenrelay` | Opportunity POST and product GET Bearer | Content promise/cache; no expiry-based refresh equivalent to Sales Common |
|
||||||
|
| Sales Common | GET `/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F` | AI summary Bearer | In-memory `expires_in`, shared in-flight acquisition; at most one summary retry after 401/403 |
|
||||||
|
| ACCP-TS | SPA consumer service `uiaasHeader.value` | Account summary authorization | Retrieved with the consumer service response; expiry recorded, not automatically acted on |
|
||||||
|
| OCICONS-TS | SPA consumer service `uiaasHeader.value` | Account plans and consumption trend authorization | Same service response, distinct credential from ACCP-TS |
|
||||||
|
|
||||||
|
Fusion calls use `https://eeho.fa.us2.oraclecloud.com`; SPA calls use
|
||||||
|
`https://spa.oracle.com/oalcrm/web/api/`. Preserve `credentials: include` and
|
||||||
|
operation-specific headers rather than unifying all calls under a default token.
|
||||||
|
|
||||||
|
| Request | Method/path | Important contract |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| requestOpportunities | POST `/crmRestApi/searchResources/11.13.18.05/custom-actions/queries` | RecordSet OR plus EffectiveDate AND, original fields, Preference transient, pages of 15 |
|
||||||
|
| requestOpptyProducts | GET `/crmRestApi/rest/rv:be91c002-2e5d-4ed2-a37e-e0c837bf141f/en/11.13.18.05:9/opportunities/{number}/child/ChildRevenue` | Encoded identifier, original query/fields/order, count/offset pagination, maximum 250 pages |
|
||||||
|
| requestGenerateAiSummary | POST `/api/sales-common/gen-ai/v1/summarizations/generate` | `{path,promptCode,object,puid}`; prompt `cx.sales.generate_opportunity_summary`; only summaryText displayed |
|
||||||
|
| requestConsumerTokenService | GET `g2m-consumer-application/consumerTokenService` | No-store; parse JSON; HTML 200 recovery via inactive SPA UI tab; maximum five HTTP attempts |
|
||||||
|
| requestAccountSummary | POST `provider-proxy/g2m-account/service/accountsummary` | ACCP-TS; `[{key:"regId",value:partyNumber}]` |
|
||||||
|
| requestAccountPlans | POST `provider-proxy/g2m-consumption/service/v1/account/accountPlans/{id}` | OCICONS-TS; original hierarchy/products/language/planStatus payload |
|
||||||
|
| requestConsumptionService{Period} | GET `provider-proxy/g2m-consumption/service/v1/plan/trend/{subPlanNum}?quarter=All&timePeriod={period}` | G2m-Authorization; quarterly/monthly/weekly/daily; weekly default |
|
||||||
|
|
||||||
|
SPA parsing removes BOM and the existing anti-JSON-hijacking prefixes before
|
||||||
|
JSON.parse. Content uses response.json directly. Do not silently unify these behaviors.
|
||||||
|
POST retry policies require operation-specific review; do not add generic retries.
|
||||||
|
|
||||||
|
## Message Contracts
|
||||||
|
|
||||||
|
All names below have the `opportunitiesExtension.` prefix. Unknown messages return
|
||||||
|
false without a response. Supported background messages return true immediately
|
||||||
|
and later call sendResponse. `platform/background-api.js` centralizes the existing
|
||||||
|
callback/promise differences. `platform/content-messages.js` retains the original
|
||||||
|
cookie callback path as well as general runtime messaging; no browser behavior
|
||||||
|
was silently changed during extraction.
|
||||||
|
|
||||||
|
| Message | Input | Result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| getXsrfToken | type | ok, cookieName, token, matchedCookieNames, lookupDetails; error on failure |
|
||||||
|
| refreshXsrfCookie | type; sender tab context | ok, refreshed, cookieName, durationMs, error |
|
||||||
|
| requestOpptyProducts | requestId, accessToken, optyNumbers, cacheMaxAgeMs | ok, requestId, completed, total, failed, cacheHits; error envelope on dispatcher failure |
|
||||||
|
| opptyProductsProgress | Background to sender tab/frame | requestId, state, counters; per-item optyNumber/items/fromCache/cachedAt or error |
|
||||||
|
| requestConsumerTokenService | type; sender tab context | ok, authorizations.accountSummary/accountPlans, debugRequests |
|
||||||
|
| requestAccountPlans | partyNumber, authorizations | ok, accountPlanId, plans, debugRequests |
|
||||||
|
| requestConsumptionTrend | subPlanNum, period, authorization | ok, subPlanNum, period, items, debugRequests |
|
||||||
|
|
||||||
|
SPA failures return `{ok:false,error,debugRequests}`. Progress states include
|
||||||
|
`running`, `complete` and `complete-with-errors`; content additionally tracks idle
|
||||||
|
and error states. A new content request ID rejects progress from an older job.
|
||||||
|
Do not expose a caller-provided arbitrary request URL through the background.
|
||||||
|
|
||||||
|
## Storage Contracts
|
||||||
|
|
||||||
|
| Location/key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Page localStorage: `opportunities-extension-filter-preferences` | JSON object: period, stages, statuses, customerKeys, ownerKeys, customerSearch, ownerSearch, tableSearch; unset customer/owner selections may be null |
|
||||||
|
| Page localStorage: `opportunities-extension-theme` | `light` or `dark` |
|
||||||
|
| Page localStorage: `opportunities-extension-opportunity-type-preferences` | JSON object: opportunityTypeValues, opptyProductsCacheHours |
|
||||||
|
| Extension storage.local: `opportunitiesExtension.opptyProductsCache.v1` | Object indexed by OptyNumber, each entry `{fetchedAt,items}` |
|
||||||
|
|
||||||
|
Product TTL defaults to 24 hours and is capped at 30 days in background. Storage
|
||||||
|
failures are tolerated for the active view. Recreating background reloads stored
|
||||||
|
products but does not restore in-flight jobs. Cache TTL does not physically delete
|
||||||
|
expired entries. Account/container isolation limitations are tracked separately.
|
||||||
|
|
||||||
|
## UI Contracts
|
||||||
|
|
||||||
|
Preserve labels/IDs/classes, column order, URLs, clipboard behavior, sort/filter
|
||||||
|
semantics, formatting, theme colors, keyboard shortcuts and row expansion.
|
||||||
|
WON includes WON_PENDING without rewriting StatusCode. Owner uses first/last name
|
||||||
|
with the existing fallbacks. Changing reference resets customers/owners to all;
|
||||||
|
closing/reopening preserves search. Fiscal years begin in June; RENEWALS currently
|
||||||
|
has no date range and therefore does not load opportunities.
|
||||||
|
|
||||||
|
Preserve SVG chart data normalization, animation, tooltips and series selection.
|
||||||
|
AI text is inserted with textContent. Debug masking and DOM isolation have known
|
||||||
|
limitations; see the separate security backlog rather than assuming full redaction.
|
||||||
|
|
||||||
|
## Module Ownership and Dependencies
|
||||||
|
|
||||||
|
`content/main.js` and `background/main.js` only initialize. Each `application.js`
|
||||||
|
is a composition root: constructs feature-owned state and factories, wires explicit
|
||||||
|
function dependencies, then exposes `start`. The returned components are available
|
||||||
|
to isolated tests, not published on the page/global object in production.
|
||||||
|
|
||||||
|
Factories do not import or instantiate peers. Features receive service/auth ports;
|
||||||
|
services/auth receive transport/platform ports; pure models receive data. The
|
||||||
|
composition roots supply callback notifications for UI updates and debug events,
|
||||||
|
preserving existing interactions without module-import cycles. Shared messages
|
||||||
|
are the only source included in both contexts, with distinct instances/state.
|
||||||
|
Some stateful UI collaborators intentionally share a feature-owned state object;
|
||||||
|
these objects are created per application, never stored in a global registry.
|
||||||
|
|
||||||
|
| Original responsibility | Integrated module(s) | Reason for boundary |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| content initialization/tile | `content/main.js`, `fusion-page.js` | Page lifecycle and route guard |
|
||||||
|
| modal/theme/CSS | `content/modal.js`, `theme.js`, `styles.js`, `styles/redwood.css` | DOM shell separate from operations and cascade |
|
||||||
|
| fiscal dates/query payload | `features/opportunities/fiscal-periods.js`, `services/fusion/opportunities.js` | Pure date rules vs HTTP operation |
|
||||||
|
| table/search/filters/formatting/dashboard | `features/opportunities/` | Related presentation responsibilities, explicit state |
|
||||||
|
| saved settings | `features/preferences/storage.js`, `view.js` | Serialization/fallbacks vs dialog |
|
||||||
|
| token families | `auth/content-session.js`, `sales-common.js`, `token-relay.js`, `spa-credentials.js` | Different credentials and lifetime policies |
|
||||||
|
| cookie lookup/recovery, SPA session | `auth/xsrf-*.js`, `consumer-session.js` | Privileged recovery separate from UI |
|
||||||
|
| product queue/cache | `features/products/background-jobs.js`, `cache.js`, `services/fusion/products.js` | Scheduling, persistence and pagination |
|
||||||
|
| product progress/details | `features/products/content-jobs.js`, `details.js` | Message consumer vs row rendering |
|
||||||
|
| AI summary | `features/ai-summary/content.js`, `state.js` | Per-opportunity lifecycle, safe text and auth port |
|
||||||
|
| consumption | `features/consumption/`, `services/spa/` | Plans, chart model/view/controller vs HTTP operations |
|
||||||
|
| debug | `features/debug/content-log.js`, `content-view.js` | Retention/formatting vs DOM presentation |
|
||||||
|
| background dispatcher | `background/messages.js`, `shared/messages.js` | Stable serializable message contracts |
|
||||||
|
| browser APIs | `platform/background-api.js`, `content-messages.js` | Only APIs actually used, not a universal wrapper |
|
||||||
|
|
||||||
|
The background composition deliberately has no DOM dependency. Listener registration
|
||||||
|
is synchronous, before any storage read or request. Worker restart reconstructs
|
||||||
|
volatile state and lazily reads the existing product cache; tokens are not persisted.
|
||||||
|
Content effects (DOM, fetch, storage, animation) enter through the environment at
|
||||||
|
the composition root. localStorage access stays lazy to retain blocked-storage
|
||||||
|
fallbacks. Tests may inject effects or individual callback ports without changing
|
||||||
|
source strings. Time-sensitive modules are tested with isolated clocks.
|
||||||
|
|
||||||
|
## Adding a Feature
|
||||||
|
|
||||||
|
1. Define its message/API/storage contract and synthetic expected behavior first.
|
||||||
|
2. Add a factory under the owning feature, or a service under the correct host.
|
||||||
|
Accept narrow function dependencies; do not call another factory inside it.
|
||||||
|
3. Add its source path to the appropriate array in `scripts/sources.cjs`. Source
|
||||||
|
inclusion and duplicate factory checks run automatically.
|
||||||
|
4. Construct it in that context's `application.js`, supplying auth, platform and
|
||||||
|
render callbacks explicitly. Extend `background/messages.js` only if crossing
|
||||||
|
contexts is necessary; do not expose arbitrary destinations.
|
||||||
|
5. Add a `tests/*.test.cjs` suite using `tests/support/application.cjs` for composed
|
||||||
|
integration or `vm.Script` for an individual pure factory. Use only synthetic
|
||||||
|
data and fail unexpected network requests.
|
||||||
|
6. Run tests, build and `--check`; inspect the local fixture and load both packages
|
||||||
|
in the target browsers. Record any browser not available for verification.
|
||||||
|
|
||||||
|
As a concrete example, adding a new consumption period should first define its
|
||||||
|
request/data/authorization contract and synthetic tests, then extend the period
|
||||||
|
allowlist and normalization/presentation through the existing trend message. It
|
||||||
|
must not add a URL-proxy message or reuse the general Fusion token. These edits
|
||||||
|
belong in the consumption feature and SPA service, not the bootstrap.
|
||||||
|
|
||||||
|
## Source vs Generated Code
|
||||||
|
|
||||||
|
Distribution `content.js` and `background.js` are generated bundles and may remain
|
||||||
|
large. No copy/export/execute JavaScript generator feature was found in the source.
|
||||||
|
AI summaries are text, not executable JavaScript. No empty generators subsystem
|
||||||
|
was added. Build serialization uses JSON.stringify for CSS, retains quotes,
|
||||||
|
backslashes, Unicode and line breaks, and is syntax-checked by vm.Script. Visual
|
||||||
|
fixtures now load composed scripts normally; they no longer eval rewritten sources.
|
||||||
63
docs/browser-smoke.md
Normal file
63
docs/browser-smoke.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Native Browser Validation
|
||||||
|
|
||||||
|
This checklist complements the synthetic tests; it is not evidence of a native
|
||||||
|
pass until executed and recorded separately for each browser.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
- Use an authorized test profile/account. Do not delete cookies, sign out, change
|
||||||
|
account security settings or inspect credential values to force failure cases.
|
||||||
|
- Connect Chrome/Edge through Settings > Computer use for assisted browser tests.
|
||||||
|
Firefox needs a separate supported/manual test session.
|
||||||
|
- Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first.
|
||||||
|
- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load
|
||||||
|
`dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source
|
||||||
|
directory and version 1.0.1; avoid testing a previously installed stale copy.
|
||||||
|
- Keep debug secrets hidden. Record only status codes, request names, durations
|
||||||
|
and sanitized errors. Do not export real tokens, response bodies or account data.
|
||||||
|
- AI generation and tests requiring a deliberately expired session must use an
|
||||||
|
explicitly authorized test environment. Do not trigger live writes solely to
|
||||||
|
complete this checklist.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
| Check | Expected result |
|
||||||
|
| --- | --- |
|
||||||
|
| Package load | No manifest/background/content syntax errors; no additional permissions |
|
||||||
|
| Both Fusion welcome routes | Exactly one tile before the add-icon item; no tile on unrelated pages |
|
||||||
|
| Modal open/close/reopen | Same search value and matching results; Esc closes the correct dialog |
|
||||||
|
| Filters and sorting | Correct customer/owner/stage/status/text combinations; WON includes WON_PENDING |
|
||||||
|
| Reference change | Customer and owner reset to all; stale responses do not overwrite new results |
|
||||||
|
| Preferences | Save/reopen retains selected record sets and cache duration; restore original settings afterward |
|
||||||
|
| Products | Progress completes; badges/counts and expanded rows agree; warm cache avoids redundant product fetches |
|
||||||
|
| AI in authorized test environment | Summary appears without replacing products; concurrent expansions share scoped-token acquisition; reopen uses valid token |
|
||||||
|
| Consumption | Dialog opens promptly, weekly chart by default; plan/period changes and series filters work |
|
||||||
|
| Themes and layout | Light/dark controls work; check long names, WON_PENDING and full update timestamps |
|
||||||
|
| Debug | Expected operation names/statuses; sensitive values remain hidden |
|
||||||
|
| Background restart | Close its inspector, allow suspension, then trigger a message; listener responds and stored product cache is reloaded |
|
||||||
|
| Multiple tabs | Progress remains scoped to requesting frame/job; no duplicate UI/listeners from normal navigation |
|
||||||
|
| Firefox containers | Test only in authorized containers; record current cookie/cache isolation limitations without silently changing behavior |
|
||||||
|
| Natural session expiry | When encountered, record recovery outcome and temporary-tab cleanup without revealing cookies or forcing sign-out |
|
||||||
|
|
||||||
|
Background interruption does not currently resume in-flight jobs. Do not mistake
|
||||||
|
that known limitation for a new regression or claim a mocked restart proves native
|
||||||
|
worker suspension. Any differences must be compared with the pre-refactor behavior.
|
||||||
|
|
||||||
|
## Execution Record
|
||||||
|
|
||||||
|
2026-09-17 preflight:
|
||||||
|
|
||||||
|
| Target | Discovery | Native test result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Chrome | Executable version 153.0.8010.37; automation not connected | Blocked pending connection |
|
||||||
|
| Edge | Executable version 153.0.4234.32; automation not connected | Blocked pending connection |
|
||||||
|
| Firefox | No executable found at the checked standard Program Files path; installation not confirmed | Not run |
|
||||||
|
|
||||||
|
The running-process diagnostic was denied by the environment, so browser running
|
||||||
|
state was not established. Connected targets were Brave and the in-app browser;
|
||||||
|
neither was substituted for this native matrix. The user chose to connect
|
||||||
|
Chrome/Edge. All five local suites and the output consistency check passed again.
|
||||||
|
No real Fusion/SPA calls, runtime changes, release or commit were made in preflight.
|
||||||
|
|
||||||
|
For each completed run, append date, browser/version, package path, checklist
|
||||||
|
outcomes, sanitized errors and remaining gaps. Never mark unexecuted checks passed.
|
||||||
184
docs/refactoring.md
Normal file
184
docs/refactoring.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# Incremental Refactoring Record
|
||||||
|
|
||||||
|
## Stage 1: Characterization Baseline
|
||||||
|
|
||||||
|
Stage 1 initially added tests and documentation only, before any runtime extraction.
|
||||||
|
The following records that historical baseline. The completed integration and
|
||||||
|
remaining verification gaps are recorded under "Completed Extraction Stages" below.
|
||||||
|
|
||||||
|
### Coverage Added
|
||||||
|
|
||||||
|
`tests/characterization.test.cjs` contains 22 test cases using Node's built-in
|
||||||
|
test runner, assert and vm. `tests/support/characterization.cjs` provides isolated
|
||||||
|
fixtures: no real browser cookies, account identifiers, tokens or network access.
|
||||||
|
Unexpected fetches reject rather than falling through to global fetch.
|
||||||
|
|
||||||
|
- Exact current manifests and source/output equality, with classic-script syntax validation.
|
||||||
|
- Disallowed-origin initialization and reinjection when the tile already exists.
|
||||||
|
- Fiscal boundaries, leap years, rolling quarters and unsupported RENEWALS.
|
||||||
|
- Full opportunity query payload, pagination offsets/counts and stale request versions.
|
||||||
|
- Filter storage round-trip, WON_PENDING, search, numeric sort, reference reset and malformed preferences.
|
||||||
|
- Missing-cookie tokenrelay recovery and existing 400/401/403 retry limit.
|
||||||
|
- Chrome callback and Firefox promise mocks for background dispatch, storage, cookies and tabs.
|
||||||
|
- Product pagination, default/custom TTL, concurrency limit, in-flight sharing and partial errors.
|
||||||
|
- Persisted product reads after recreating the background context; storage failures and stalled pages.
|
||||||
|
- XSRF discovery, recovery timeout, successful recovery and temporary-tab cleanup.
|
||||||
|
- SPA credential selection, account payloads, encoded identifiers and debug envelopes.
|
||||||
|
- Invalid messages, network errors, HTTP 401 and HTTP 200 invalid JSON in consumption.
|
||||||
|
|
||||||
|
Existing suites remain intact: consumption normalization/requests, consumer-session
|
||||||
|
recovery, AI summary rendering/state, scoped token reuse/expiry and bounded retry.
|
||||||
|
Tests for the current security defects are deliberately not encoded as acceptable
|
||||||
|
behavior. They belong to separately approved fixes.
|
||||||
|
|
||||||
|
### Former Temporary Test Seam
|
||||||
|
|
||||||
|
The original content IIFE had no exports, so baseline tests initially exposed
|
||||||
|
functions before initialization with a guarded source marker. That temporary
|
||||||
|
mechanism is now removed. Tests use the actual application factories through
|
||||||
|
`tests/support/application.cjs`, without replacing source strings. Background
|
||||||
|
integration tests still execute the production listener and asynchronous replies.
|
||||||
|
|
||||||
|
### Running Checks
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Without npm, use the Node commands in package.json individually. The new suite is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node tests/characterization.test.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
At baseline, `build.mjs --check` did not compare output contents. It now checks
|
||||||
|
both generated manifests and both scripts byte-for-byte against composition.
|
||||||
|
Characterization tests retain the exact manifest expectations.
|
||||||
|
|
||||||
|
Stage-1 verification on Node.js v24.19.0:
|
||||||
|
|
||||||
|
| Command | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `node --check src/content.js` and `node --check src/background.js` | Passed |
|
||||||
|
| `node --check tests/support/characterization.cjs` | Passed |
|
||||||
|
| `node tests/consumptionTrend.test.cjs` | Passed |
|
||||||
|
| `node tests/consumerTokenSessionRetry.test.cjs` | Passed |
|
||||||
|
| `node tests/aiSummary.test.cjs` | Passed |
|
||||||
|
| `node tests/characterization.test.cjs` | 22 passed, 0 failed |
|
||||||
|
| `node scripts/build.mjs --check` | Passed; distribution equivalence also checked by the suite |
|
||||||
|
| `git diff --check` | Passed |
|
||||||
|
|
||||||
|
The individual commands from the npm scripts were executed directly. No build
|
||||||
|
regeneration was needed because runtime sources and the build were unchanged;
|
||||||
|
`git diff -- src dist scripts/build.mjs` was empty.
|
||||||
|
|
||||||
|
### Verification Limits
|
||||||
|
|
||||||
|
- API mocks are not proof of Chrome/Edge/Firefox compatibility or real authentication.
|
||||||
|
- Recreating a VM tests cache rehydration, not actual service-worker suspension or job recovery.
|
||||||
|
- DOM mocks do not prove layout, focus, accessibility or SPA lifecycle correctness.
|
||||||
|
- The original visual fixture used eval. The completed extraction removed it and
|
||||||
|
serves the composed factory through a normal local script instead.
|
||||||
|
- No installed-browser smoke test or live Fusion request was performed in stage 1.
|
||||||
|
- No JavaScript export/generator feature was located. Do not create a generators
|
||||||
|
directory until its actual source and standalone-output contracts are identified.
|
||||||
|
|
||||||
|
## Separate Security and Reliability Backlog
|
||||||
|
|
||||||
|
These are preexisting observations, not authorized functional changes in this refactor.
|
||||||
|
|
||||||
|
| Issue | Evidence/impact | Separate change to evaluate |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Debug redaction after truncation | Invalid/truncated JSON is returned unmasked by formatDebugBody; reproduced with a synthetic token | Redact before truncation, fail closed on malformed bodies, test all response shapes |
|
||||||
|
| Debug secrets and shared DOM | Raw headers/bodies are retained for the reveal control; revealed text is in page DOM | Review retention, safe diagnostic schema and whether revealing credentials remains acceptable |
|
||||||
|
| Cookie store selection | Lookup scans all stores instead of binding to sender account/container | Scope selection to the requesting context and test multi-account behavior |
|
||||||
|
| Product cache isolation | Cache and in-flight keys use OptyNumber only | Design identity partitioning and explicit cache migration/retention policy |
|
||||||
|
| Privileged message validation | No explicit sender identity/origin check; supplied header names are accepted | Validate senders/messages and allowlist headers without introducing an arbitrary proxy |
|
||||||
|
| Session tab retry | Opening failures retry indefinitely; interruption can strand work | Decide cancellation/limits and worker lifecycle recovery explicitly |
|
||||||
|
| DOM lifecycle | Observer can remain active; guards do not cover every reinjection/resize path | Define ownership/disposal and characterize navigation before changing behavior |
|
||||||
|
| Token expiry | General/SPA tokens have policies different from Sales Common | Review independently; do not unify policies as part of extraction |
|
||||||
|
|
||||||
|
Never include real secrets in repro cases, snapshots, documentation or test output.
|
||||||
|
Do not add persistent token storage to compensate for worker termination.
|
||||||
|
|
||||||
|
## Completed Extraction Stages
|
||||||
|
|
||||||
|
All modules listed below are integrated through `scripts/sources.cjs` and the
|
||||||
|
two composition roots. Former `src/content.js` and `src/background.js` were removed;
|
||||||
|
their names remain only for the generated distribution scripts. No version change,
|
||||||
|
commit, publication, runtime dependency, permission expansion or CSP change occurred.
|
||||||
|
|
||||||
|
| Stage/responsibility | Files/boundaries | Preserved contracts and evidence | Remaining risk/next safe step |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Build and pure logic | `scripts/compose.cjs`, `sources.cjs`; fiscal-periods, formatters, consumption model | Classic IIFEs, exact manifests, dates, payload/normalization tests; syntax checked per source and composed bundle | Native browser package loading still needs a smoke test |
|
||||||
|
| Platform and dispatch | `platform/`, `background/main.js`, `messages.js`, `shared/messages.js` | Sync listener registration, callback/promise mocks, async responses, sender-frame progress and unknown-message behavior | Sender validation is a separate security change |
|
||||||
|
| Auth and HTTP operations | `auth/`, `services/` | Credential selection, scoped-token reuse/expiry, 401/403 retry, missing-cookie and HTML-session recovery, request/debug envelopes | Real SSO and worker suspension not simulated completely |
|
||||||
|
| Products | `features/products/` | Four-worker queue, pagination, TTL, cache restore, in-flight sharing, partial errors and expansion behavior | Existing identity/cache-retention policy unchanged |
|
||||||
|
| Content features | opportunities, preferences, AI-summary, consumption and debug factories/state | Filter/search storage round-trip, sorting, stale requests, AI safe text, plan/period controls, preserved labels and DOM | Existing DOM lifecycle and narrow-screen limitations remain |
|
||||||
|
| Bootstrap, CSS and documentation | `content/main.js`, `background/main.js`, application roots, styles; README/docs | CSS byte-identical to original (hash test); factories connected, no orphan sources or peer-factory dependencies; old marker/eval fixture removed | Native browser matrix below remains pending |
|
||||||
|
|
||||||
|
The automated suites were run after the initial extraction and again after the
|
||||||
|
platform/auth refinements, cleanup and final composition. Intermediate verification
|
||||||
|
found and corrected test-harness expectations for source inventory and fixture
|
||||||
|
callback messaging; no production auth, cookie or session policy was changed.
|
||||||
|
|
||||||
|
### Final Automated Verification
|
||||||
|
|
||||||
|
Executed with Node.js 24.19.0, directly through the package-script equivalents:
|
||||||
|
|
||||||
|
| Command | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `node scripts/build.mjs` | Both packages regenerated; all source/composed JavaScript syntax valid |
|
||||||
|
| `node scripts/test.cjs` | All five suite files passed: AI, architecture (6 cases), characterization (22 cases), session recovery, consumption |
|
||||||
|
| `node scripts/build.mjs --check` | Generated scripts/manifests match source composition |
|
||||||
|
| `git diff --check` | No whitespace errors |
|
||||||
|
|
||||||
|
There is no configured linter. Syntax validation uses Node's JavaScript parser
|
||||||
|
through vm.Script, not eval or new Function in the extension. No generators feature
|
||||||
|
was located; standalone JavaScript generation therefore has no feature-specific
|
||||||
|
snapshot to assert. The architectural suite verifies deterministic composition,
|
||||||
|
source coverage, dependency injection, independent per-frame state and lazy
|
||||||
|
localStorage failure handling. Feature suites use synthetic credentials only.
|
||||||
|
|
||||||
|
### UI Smoke Verification
|
||||||
|
|
||||||
|
The loopback fixture was exercised in the Codex in-app browser, using the same
|
||||||
|
composed content factories with injected synthetic runtime/fetch/storage adapters.
|
||||||
|
It is not an installed extension and does not validate privileged browser APIs.
|
||||||
|
|
||||||
|
- Paginated synthetic opportunities, product progress/counts and dashboard rendered.
|
||||||
|
- Search text/filter remained after closing and reopening the modal.
|
||||||
|
- Light/dark toggle and product expansion retained the existing presentation.
|
||||||
|
- AI text, including literal HTML delimiters, appeared as text above the product table.
|
||||||
|
- Preferences retained checkbox/cache-duration selections after Save and reopen.
|
||||||
|
- Cloud Consumption opened with plan tabs; weekly default, daily switch, alternate
|
||||||
|
plan and series visibility toggles worked. Debug listed scoped relay and AI calls.
|
||||||
|
- Screenshots inspected at 1920x1080, 1280x720 and 390x844. Desktop columns rendered
|
||||||
|
with complete Last Update Date and WON_PENDING. Existing compact-table scrolling
|
||||||
|
and clipping at 1280 remain unchanged; mobile controls stack. This is not a full
|
||||||
|
mobile workflow/accessibility audit and no layout redesign was included.
|
||||||
|
- No console errors were reported during the exercised flows.
|
||||||
|
|
||||||
|
### Outstanding Native Browser Matrix
|
||||||
|
|
||||||
|
| Target | Package/runtime | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Chrome | `dist/chromium`, MV3 service worker | Not run as an installed extension |
|
||||||
|
| Edge | `dist/chromium`, MV3 service worker | Not run as an installed extension |
|
||||||
|
| Firefox | `dist/firefox`, MV3 background scripts | Not run as an installed extension |
|
||||||
|
|
||||||
|
The available automation connection exposed the in-app browser and a Brave
|
||||||
|
profile, not Chrome, Edge or Firefox. No live Fusion requests, user-session access
|
||||||
|
or extension installation were used to substitute for the missing native tests.
|
||||||
|
Actual suspension/reactivation, container routing and SSO remain unverified.
|
||||||
|
|
||||||
|
The next safe validation is to load the generated packages in an authorized test
|
||||||
|
profile in each target browser, then exercise tile insertion, modal reopen,
|
||||||
|
filters/preferences, products, AI, consumption, debug and background restart.
|
||||||
|
Resolve the separately listed security issues only in explicitly approved changes.
|
||||||
|
|
||||||
|
See [the native browser checklist and execution record](browser-smoke.md) for the
|
||||||
|
2026-09-17 follow-up. Local tests and output checks passed again; Chrome/Edge were
|
||||||
|
installed but not connected to automation. Native verification remains pending
|
||||||
|
while the user connects those browsers.
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node scripts/build.mjs",
|
"build": "node scripts/build.mjs",
|
||||||
"check": "node --check src/content.js && node --check src/background.js && node tests/consumptionTrend.test.cjs && node tests/consumerTokenSessionRetry.test.cjs && node tests/aiSummary.test.cjs && node scripts/build.mjs --check",
|
"check": "node scripts/build.mjs --check && node scripts/test.cjs",
|
||||||
"test": "node tests/consumptionTrend.test.cjs && node tests/consumerTokenSessionRetry.test.cjs && node tests/aiSummary.test.cjs"
|
"test": "node scripts/test.cjs",
|
||||||
|
"preview": "node scripts/preview.mjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import composition from "./compose.cjs";
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const srcDir = path.join(rootDir, "src");
|
|
||||||
const distDir = path.join(rootDir, "dist");
|
const distDir = path.join(rootDir, "dist");
|
||||||
const contentScript = await readFile(path.join(srcDir, "content.js"), "utf8");
|
const contentScript = composition.composeScript("content");
|
||||||
const backgroundScript = await readFile(path.join(srcDir, "background.js"), "utf8");
|
const backgroundScript = composition.composeScript("background");
|
||||||
const checkOnly = process.argv.includes("--check");
|
const checkOnly = process.argv.includes("--check");
|
||||||
|
|
||||||
const baseManifest = {
|
const baseManifest = {
|
||||||
@@ -70,6 +70,11 @@ for (const target of targets) {
|
|||||||
JSON.parse(manifestJson);
|
JSON.parse(manifestJson);
|
||||||
|
|
||||||
if (checkOnly) {
|
if (checkOnly) {
|
||||||
|
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript };
|
||||||
|
for (const [file, contents] of Object.entries(expected)) {
|
||||||
|
const actual = await readFile(path.join(distDir, target.name, file), "utf8");
|
||||||
|
if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
scripts/compose.cjs
Normal file
29
scripts/compose.cjs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const vm = require("node:vm");
|
||||||
|
const sources = require("./sources.cjs");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
/** Compose classic scripts at build time; never load or evaluate code at runtime. */
|
||||||
|
function composeScript(target, { bootstrap = true } = {}) {
|
||||||
|
const files = sources[target];
|
||||||
|
if (!files) throw new Error(`Unknown script target: ${target}`);
|
||||||
|
if (new Set(files).size !== files.length) throw new Error(`Duplicate source in ${target}`);
|
||||||
|
const parts = files.map(file => {
|
||||||
|
const source = fs.readFileSync(path.join(root, file), "utf8");
|
||||||
|
new vm.Script(source, { filename: file });
|
||||||
|
return `// Source: ${file}\n${source}`;
|
||||||
|
});
|
||||||
|
if (target === "content") {
|
||||||
|
const css = fs.readFileSync(path.join(root, "src/content/styles/redwood.css"), "utf8");
|
||||||
|
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
||||||
|
}
|
||||||
|
const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication";
|
||||||
|
const ending = bootstrap ? `${factory}().start();` : `return ${factory};`;
|
||||||
|
const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`;
|
||||||
|
new vm.Script(output, { filename: `${target}.js` });
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { composeScript, sources };
|
||||||
38
scripts/preview.mjs
Normal file
38
scripts/preview.mjs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import composition from "./compose.cjs";
|
||||||
|
|
||||||
|
// Development-only routes. Never expose the workspace or forward API requests.
|
||||||
|
const routes = new Map([
|
||||||
|
["/", ["../tests/fixtures/extension.html", "text/html"]],
|
||||||
|
["/extension.js", ["../tests/fixtures/extension.js", "text/javascript"]],
|
||||||
|
["/consumption-trend.html", ["../tests/fixtures/consumption-trend.html", "text/html"]]
|
||||||
|
]);
|
||||||
|
const server = http.createServer(async (request, response) => {
|
||||||
|
const pathname = new URL(request.url, "http://127.0.0.1").pathname;
|
||||||
|
response.setHeader("Cache-Control", "no-store");
|
||||||
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
if (request.method !== "GET") {
|
||||||
|
response.writeHead(405).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (pathname === "/fixture-runtime.js") {
|
||||||
|
response.setHeader("Content-Type", "text/javascript");
|
||||||
|
response.end(`window.createFixtureApplication = ${composition.composeScript("content", { bootstrap: false })}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route = routes.get(pathname);
|
||||||
|
if (!route) {
|
||||||
|
response.writeHead(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.setHeader("Content-Type", `${route[1]}; charset=utf-8`);
|
||||||
|
response.end(await readFile(new URL(route[0], import.meta.url)));
|
||||||
|
} catch {
|
||||||
|
response.writeHead(500).end("Fixture could not be loaded.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.listen(Number(process.env.PORT) || 0, "127.0.0.1", () => {
|
||||||
|
console.log(`Synthetic extension preview: http://127.0.0.1:${server.address().port}/`);
|
||||||
|
});
|
||||||
72
scripts/sources.cjs
Normal file
72
scripts/sources.cjs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
module.exports = {
|
||||||
|
"content": [
|
||||||
|
"src/shared/messages.js",
|
||||||
|
"src/content/contracts.js",
|
||||||
|
"src/auth/session-state.js",
|
||||||
|
"src/auth/sales-common-state.js",
|
||||||
|
"src/features/opportunities/query-state.js",
|
||||||
|
"src/features/opportunities/table-state.js",
|
||||||
|
"src/features/opportunities/filter-state.js",
|
||||||
|
"src/features/preferences/state.js",
|
||||||
|
"src/features/products/content-state.js",
|
||||||
|
"src/features/ai-summary/state.js",
|
||||||
|
"src/features/consumption/plans-state.js",
|
||||||
|
"src/features/consumption/trend-state.js",
|
||||||
|
"src/features/opportunities/dashboard-state.js",
|
||||||
|
"src/features/debug/state.js",
|
||||||
|
"src/features/opportunities/columns.js",
|
||||||
|
"src/content/fusion-page.js",
|
||||||
|
"src/content/modal.js",
|
||||||
|
"src/content/theme.js",
|
||||||
|
"src/content/icons.js",
|
||||||
|
"src/content/svg.js",
|
||||||
|
"src/content/styles.js",
|
||||||
|
"src/auth/content-session.js",
|
||||||
|
"src/auth/sales-common.js",
|
||||||
|
"src/auth/token-relay.js",
|
||||||
|
"src/auth/xsrf-content.js",
|
||||||
|
"src/platform/content-messages.js",
|
||||||
|
"src/features/opportunities/controller.js",
|
||||||
|
"src/services/fusion/opportunities.js",
|
||||||
|
"src/features/opportunities/fiscal-periods.js",
|
||||||
|
"src/features/opportunities/table.js",
|
||||||
|
"src/features/opportunities/dashboard.js",
|
||||||
|
"src/features/opportunities/filters.js",
|
||||||
|
"src/features/opportunities/formatters.js",
|
||||||
|
"src/features/opportunities/links.js",
|
||||||
|
"src/features/preferences/storage.js",
|
||||||
|
"src/features/preferences/view.js",
|
||||||
|
"src/features/products/content-jobs.js",
|
||||||
|
"src/features/products/details.js",
|
||||||
|
"src/features/ai-summary/content.js",
|
||||||
|
"src/features/consumption/plans-action.js",
|
||||||
|
"src/features/consumption/plans-view.js",
|
||||||
|
"src/features/consumption/trend-controller.js",
|
||||||
|
"src/features/consumption/chart.js",
|
||||||
|
"src/features/consumption/model.js",
|
||||||
|
"src/features/debug/content-log.js",
|
||||||
|
"src/features/debug/content-view.js",
|
||||||
|
"src/content/main.js",
|
||||||
|
"src/content/application.js"
|
||||||
|
],
|
||||||
|
"background": [
|
||||||
|
"src/shared/messages.js",
|
||||||
|
"src/background/contracts.js",
|
||||||
|
"src/features/products/cache-state.js",
|
||||||
|
"src/auth/consumer-session.js",
|
||||||
|
"src/auth/spa-credentials.js",
|
||||||
|
"src/services/spa/accounts.js",
|
||||||
|
"src/services/spa/consumption.js",
|
||||||
|
"src/services/spa/response-shapes.js",
|
||||||
|
"src/services/http/background-json.js",
|
||||||
|
"src/features/products/background-jobs.js",
|
||||||
|
"src/features/products/cache.js",
|
||||||
|
"src/services/fusion/products.js",
|
||||||
|
"src/auth/xsrf-recovery.js",
|
||||||
|
"src/auth/xsrf-cookies.js",
|
||||||
|
"src/platform/background-api.js",
|
||||||
|
"src/background/main.js",
|
||||||
|
"src/background/messages.js",
|
||||||
|
"src/background/application.js"
|
||||||
|
]
|
||||||
|
};
|
||||||
13
scripts/test.cjs
Normal file
13
scripts/test.cjs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..");
|
||||||
|
const files = fs.readdirSync(path.join(root, "tests")).filter(name => name.endsWith(".test.cjs")).sort();
|
||||||
|
let failed = false;
|
||||||
|
for (const file of files) {
|
||||||
|
const result = spawnSync(process.execPath, [path.join(root, "tests", file)], { cwd: root, stdio: "inherit" });
|
||||||
|
if (result.error) console.error(result.error.message);
|
||||||
|
if (result.status !== 0) failed = true;
|
||||||
|
}
|
||||||
|
process.exitCode = failed ? 1 : 0;
|
||||||
222
src/auth/consumer-session.js
Normal file
222
src/auth/consumer-session.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
function createAuthConsumerSession({
|
||||||
|
CUSTOMER_TOKEN_MAX_ATTEMPTS,
|
||||||
|
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS,
|
||||||
|
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS,
|
||||||
|
CUSTOMER_TOKEN_SERVICE_URL,
|
||||||
|
CUSTOMER_TOKEN_SESSION_URL,
|
||||||
|
extractTokenServiceAuthorization,
|
||||||
|
fetchJson,
|
||||||
|
supportsFirefoxCookieStoreTabs,
|
||||||
|
tabsCreate,
|
||||||
|
tabsRemove,
|
||||||
|
wait
|
||||||
|
}) {
|
||||||
|
async function requestConsumerTokenService(sender) {
|
||||||
|
const debugRequests = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await fetchConsumerTokenServiceUntilJson(sender, debugRequests);
|
||||||
|
const accountSummaryAuthorization = extractTokenServiceAuthorization(
|
||||||
|
tokenResponse,
|
||||||
|
"ACCP-TS",
|
||||||
|
"account-ti-authorization"
|
||||||
|
);
|
||||||
|
const accountPlansAuthorization = extractTokenServiceAuthorization(
|
||||||
|
tokenResponse,
|
||||||
|
"OCICONS-TS",
|
||||||
|
"g2m-authorization"
|
||||||
|
);
|
||||||
|
const tokenRequestDebugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
|
if (tokenRequestDebugEntry) {
|
||||||
|
tokenRequestDebugEntry.metadata = {
|
||||||
|
...tokenRequestDebugEntry.metadata,
|
||||||
|
accountSummaryTokenService: "ACCP-TS",
|
||||||
|
accountSummaryAuthorizationHeader: accountSummaryAuthorization.name || "(missing)",
|
||||||
|
accountSummaryAuthorizationValue: accountSummaryAuthorization.value
|
||||||
|
? `(present, ${accountSummaryAuthorization.value.length} characters)`
|
||||||
|
: "(missing)",
|
||||||
|
accountSummaryAuthorizationExpires: accountSummaryAuthorization.expires || "(not provided)",
|
||||||
|
accountPlansTokenService: "OCICONS-TS",
|
||||||
|
accountPlansAuthorizationHeader: accountPlansAuthorization.name || "(missing)",
|
||||||
|
accountPlansAuthorizationValue: accountPlansAuthorization.value
|
||||||
|
? `(present, ${accountPlansAuthorization.value.length} characters)`
|
||||||
|
: "(missing)",
|
||||||
|
accountPlansAuthorizationExpires: accountPlansAuthorization.expires || "(not provided)"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accountSummaryAuthorization.value) {
|
||||||
|
throw new Error("ACCP-TS uiaasHeader.value was not found in requestConsumerTokenService.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accountPlansAuthorization.value) {
|
||||||
|
throw new Error("OCICONS-TS uiaasHeader.value was not found in requestConsumerTokenService.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
authorizations: {
|
||||||
|
accountSummary: accountSummaryAuthorization,
|
||||||
|
accountPlans: accountPlansAuthorization
|
||||||
|
},
|
||||||
|
debugRequests
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchConsumerTokenServiceUntilJson(sender, debugRequests) {
|
||||||
|
let attempt = 0;
|
||||||
|
let sessionTab = null;
|
||||||
|
let receivedValidJson = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (attempt < CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||||
|
attempt += 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await fetchJson(CUSTOMER_TOKEN_SERVICE_URL, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Cache-Control": "no-cache, no-store, max-age=0",
|
||||||
|
Pragma: "no-cache"
|
||||||
|
}
|
||||||
|
}, "requestConsumerTokenService", debugRequests);
|
||||||
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
receivedValidJson = true;
|
||||||
|
return tokenResponse;
|
||||||
|
} catch (error) {
|
||||||
|
const isHtmlFallback = error
|
||||||
|
&& error.code === "INVALID_JSON_RESPONSE"
|
||||||
|
&& error.httpStatus === 200;
|
||||||
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
|
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
|
if (isHtmlFallback) {
|
||||||
|
debugEntry.metadata.retryLimitReached = "true";
|
||||||
|
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
||||||
|
error.message = debugEntry.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionTab) {
|
||||||
|
try {
|
||||||
|
sessionTab = await openConsumerTokenSessionTabWithRetry(sender, debugEntry);
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
|
||||||
|
debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab";
|
||||||
|
}
|
||||||
|
} catch (sessionError) {
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
|
||||||
|
debugEntry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (debugEntry) {
|
||||||
|
debugEntry.metadata.sessionBootstrapState = "waiting-for-valid-session";
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
|
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
|
||||||
|
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
await wait(retryDelayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("requestConsumerTokenService retry loop ended unexpectedly.");
|
||||||
|
} finally {
|
||||||
|
if (sessionTab && Number.isInteger(sessionTab.id)) {
|
||||||
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tabsRemove(sessionTab.id);
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.sessionBootstrapState = receivedValidJson
|
||||||
|
? "closed-after-valid-json"
|
||||||
|
: "closed-after-retry-finished";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConsumerTokenRetryDelay(attempt) {
|
||||||
|
return Math.min(
|
||||||
|
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS * (2 ** Math.max(attempt - 1, 0)),
|
||||||
|
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConsumerTokenSessionTabWithRetry(sender, debugEntry) {
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
|
// Opening failures must not consume the token service's five attempts.
|
||||||
|
while (true) {
|
||||||
|
attempt += 1;
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
|
||||||
|
debugEntry.metadata.sessionTabOpenAttempt = String(attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tab = await openConsumerTokenSessionTab(sender);
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab";
|
||||||
|
delete debugEntry.metadata.sessionTabNextRetryDelayMs;
|
||||||
|
}
|
||||||
|
return tab;
|
||||||
|
} catch (error) {
|
||||||
|
const retryDelayMs = getConsumerTokenRetryDelay(Math.min(attempt, 5));
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.sessionBootstrapState = "retrying-tab-open";
|
||||||
|
debugEntry.metadata.sessionTabLastOpenError = error.message || "unknown error";
|
||||||
|
debugEntry.metadata.sessionTabNextRetryDelayMs = String(retryDelayMs);
|
||||||
|
}
|
||||||
|
await wait(retryDelayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConsumerTokenSessionTab(sender) {
|
||||||
|
const details = {
|
||||||
|
url: CUSTOMER_TOKEN_SESSION_URL,
|
||||||
|
active: false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
|
||||||
|
details.windowId = sender.tab.windowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
|
||||||
|
details.cookieStoreId = sender.tab.cookieStoreId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabsCreate(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab };
|
||||||
|
}
|
||||||
57
src/auth/content-session.js
Normal file
57
src/auth/content-session.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
function createAuthContentSession({
|
||||||
|
messageTypes,
|
||||||
|
AUTH_STATUS,
|
||||||
|
appendBackgroundRequestLog,
|
||||||
|
authSessionState,
|
||||||
|
requestTokenRelay,
|
||||||
|
sendRuntimeMessage,
|
||||||
|
updateAuthBadge
|
||||||
|
}) {
|
||||||
|
function requestTokenRelayOnce() {
|
||||||
|
if (!authSessionState.tokenRelayRequest) {
|
||||||
|
authSessionState.authStatus = AUTH_STATUS.pending;
|
||||||
|
updateAuthBadge(authSessionState.authStatus);
|
||||||
|
authSessionState.tokenRelayRequest = requestTokenRelay()
|
||||||
|
.then((token) => {
|
||||||
|
authSessionState.accessToken = token;
|
||||||
|
authSessionState.authStatus = AUTH_STATUS.authenticated;
|
||||||
|
requestConsumerTokenServiceOnce().catch(() => {});
|
||||||
|
return token;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
authSessionState.accessToken = "";
|
||||||
|
authSessionState.authStatus = AUTH_STATUS.unauthenticated;
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
updateAuthBadge(authSessionState.authStatus);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateAuthBadge(authSessionState.authStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
return authSessionState.tokenRelayRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestConsumerTokenServiceOnce() {
|
||||||
|
if (!authSessionState.consumerTokenServiceRequest) {
|
||||||
|
authSessionState.consumerTokenServiceRequest = sendRuntimeMessage({
|
||||||
|
type: messageTypes.requestConsumerTokenService
|
||||||
|
}).then((response) => {
|
||||||
|
appendBackgroundRequestLog(response && response.debugRequests);
|
||||||
|
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
throw new Error(response && response.error
|
||||||
|
? response.error
|
||||||
|
: "Unable to load consumer token service.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.authorizations;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return authSessionState.consumerTokenServiceRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestTokenRelayOnce, requestConsumerTokenServiceOnce };
|
||||||
|
}
|
||||||
7
src/auth/sales-common-state.js
Normal file
7
src/auth/sales-common-state.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/** @returns {{salesCommonToken: null | {access_token: string, expiresAt: number}, salesCommonTokenRequest: null | Promise<string>}} */
|
||||||
|
function createAuthSalesCommonState() {
|
||||||
|
return {
|
||||||
|
salesCommonToken: null,
|
||||||
|
salesCommonTokenRequest: null
|
||||||
|
};
|
||||||
|
}
|
||||||
33
src/auth/sales-common.js
Normal file
33
src/auth/sales-common.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
function createAuthSalesCommon({
|
||||||
|
TOKEN_RELAY_URL,
|
||||||
|
authSalesCommonState,
|
||||||
|
requestTokenRelay
|
||||||
|
}) {
|
||||||
|
function requestTokenRelaySalesCommon() {
|
||||||
|
if (authSalesCommonState.salesCommonToken && Date.now() < authSalesCommonState.salesCommonToken.expiresAt) {
|
||||||
|
return Promise.resolve(authSalesCommonState.salesCommonToken.access_token);
|
||||||
|
}
|
||||||
|
if (authSalesCommonState.salesCommonTokenRequest) {
|
||||||
|
return authSalesCommonState.salesCommonTokenRequest;
|
||||||
|
}
|
||||||
|
authSalesCommonState.salesCommonTokenRequest = requestTokenRelay({
|
||||||
|
name: "requestTokenRelaySalesCommon",
|
||||||
|
url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`,
|
||||||
|
includeExpiry: true
|
||||||
|
}).then((token) => {
|
||||||
|
authSalesCommonState.salesCommonToken = token;
|
||||||
|
return token.access_token;
|
||||||
|
}).finally(() => {
|
||||||
|
authSalesCommonState.salesCommonTokenRequest = null;
|
||||||
|
});
|
||||||
|
return authSalesCommonState.salesCommonTokenRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateSalesCommonToken(rejectedToken) {
|
||||||
|
if (authSalesCommonState.salesCommonToken && authSalesCommonState.salesCommonToken.access_token === rejectedToken) {
|
||||||
|
authSalesCommonState.salesCommonToken = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestTokenRelaySalesCommon, invalidateSalesCommonToken };
|
||||||
|
}
|
||||||
8
src/auth/session-state.js
Normal file
8
src/auth/session-state.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
function createAuthSessionState({ AUTH_STATUS } = {}) {
|
||||||
|
return {
|
||||||
|
tokenRelayRequest: null,
|
||||||
|
consumerTokenServiceRequest: null,
|
||||||
|
accessToken: "",
|
||||||
|
authStatus: AUTH_STATUS.idle
|
||||||
|
};
|
||||||
|
}
|
||||||
40
src/auth/spa-credentials.js
Normal file
40
src/auth/spa-credentials.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
function createAuthSpaCredentials({
|
||||||
|
findNestedObject
|
||||||
|
}) {
|
||||||
|
function normalizeMessageAuthorization(authorization, fallbackHeaderName) {
|
||||||
|
const headerName = authorization && typeof authorization.name === "string"
|
||||||
|
? authorization.name.trim()
|
||||||
|
: "";
|
||||||
|
const headerValue = authorization && typeof authorization.value === "string"
|
||||||
|
? authorization.value.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: headerName || fallbackHeaderName,
|
||||||
|
value: headerValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTokenServiceAuthorization(response, tokenServiceName, fallbackHeaderName) {
|
||||||
|
const tokenService = Array.isArray(response)
|
||||||
|
? response.find((entry) => entry && entry.tokenService === tokenServiceName)
|
||||||
|
: findNestedObject(response, (entry) => entry.tokenService === tokenServiceName);
|
||||||
|
const authorizationHeader = tokenService && tokenService.uiaasHeader;
|
||||||
|
const headerName = authorizationHeader && typeof authorizationHeader.label === "string"
|
||||||
|
? authorizationHeader.label.trim()
|
||||||
|
: "";
|
||||||
|
const headerValue = authorizationHeader && typeof authorizationHeader.value === "string"
|
||||||
|
? authorizationHeader.value.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: headerName || fallbackHeaderName,
|
||||||
|
value: headerValue,
|
||||||
|
expires: authorizationHeader && authorizationHeader.expires
|
||||||
|
? String(authorizationHeader.expires)
|
||||||
|
: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { normalizeMessageAuthorization, extractTokenServiceAuthorization };
|
||||||
|
}
|
||||||
153
src/auth/token-relay.js
Normal file
153
src/auth/token-relay.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
function createAuthTokenRelay({
|
||||||
|
TOKEN_RELAY_URL,
|
||||||
|
createDebugBody,
|
||||||
|
getXsrfToken,
|
||||||
|
logRequestFailure,
|
||||||
|
logRequestStart,
|
||||||
|
logRequestSuccess,
|
||||||
|
maskToken,
|
||||||
|
readResponseBodyForDebug,
|
||||||
|
refreshXsrfCookie,
|
||||||
|
renderDebugPanel,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { fetch } = environment;
|
||||||
|
async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) {
|
||||||
|
let refreshResult = null;
|
||||||
|
let refreshAttempted = false;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const xsrfTokenSource = await getXsrfToken();
|
||||||
|
|
||||||
|
if (!xsrfTokenSource.token) {
|
||||||
|
if (refreshAttempted) {
|
||||||
|
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
|
||||||
|
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
|
||||||
|
throw new Error("XSRF token cookie not found after automatic refresh.");
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshResult = await refreshXsrfCookie("missing-cookie");
|
||||||
|
refreshAttempted = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"x-xsrf-token": xsrfTokenSource.token
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody = await readResponseBodyForDebug(response);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logRequestSuccess(requestEntry, response, responseBody);
|
||||||
|
|
||||||
|
if (!refreshAttempted && isInvalidXsrfStatus(response.status)) {
|
||||||
|
refreshResult = await refreshXsrfCookie(`tokenrelay-${response.status}`);
|
||||||
|
refreshAttempted = true;
|
||||||
|
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
|
||||||
|
renderDebugPanel();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Token relay failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let responseData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
responseData = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
logRequestSuccess(requestEntry, response, responseBody);
|
||||||
|
|
||||||
|
if (!refreshAttempted) {
|
||||||
|
refreshResult = await refreshXsrfCookie("tokenrelay-invalid-response");
|
||||||
|
refreshAttempted = true;
|
||||||
|
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
|
||||||
|
renderDebugPanel();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) {
|
||||||
|
logRequestSuccess(requestEntry, response, responseBody);
|
||||||
|
|
||||||
|
if (!refreshAttempted) {
|
||||||
|
refreshResult = await refreshXsrfCookie("tokenrelay-missing-access-token");
|
||||||
|
refreshAttempted = true;
|
||||||
|
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
|
||||||
|
renderDebugPanel();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Token relay response does not contain access_token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
|
||||||
|
...responseData,
|
||||||
|
access_token: maskToken(responseData.access_token)
|
||||||
|
}, null, 2)));
|
||||||
|
|
||||||
|
if (includeExpiry) {
|
||||||
|
const lifetime = Number(responseData.expires_in);
|
||||||
|
return {
|
||||||
|
access_token: responseData.access_token,
|
||||||
|
expiresAt: Number.isFinite(lifetime) && lifetime > 0
|
||||||
|
? requestEntry.startedAt.getTime() + lifetime * 1000 : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return responseData.access_token;
|
||||||
|
} catch (error) {
|
||||||
|
logRequestFailure(requestEntry, error.message || "Request failed.");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name = "requestTokenRelay", url = TOKEN_RELAY_URL) {
|
||||||
|
return logRequestStart({
|
||||||
|
name,
|
||||||
|
method: "GET",
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
xsrfCookieName: xsrfTokenSource.cookieName || "(missing)",
|
||||||
|
xsrfTokenSource: xsrfTokenSource.source,
|
||||||
|
xsrfMatchedCookies: xsrfTokenSource.matchedCookieNames && xsrfTokenSource.matchedCookieNames.length
|
||||||
|
? xsrfTokenSource.matchedCookieNames.join(", ")
|
||||||
|
: "(none)",
|
||||||
|
xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length
|
||||||
|
? xsrfTokenSource.lookupDetails.join(" | ")
|
||||||
|
: "(none)",
|
||||||
|
xsrfTokenError: xsrfTokenSource.error || "",
|
||||||
|
xsrfRefresh: formatXsrfRefreshResult(refreshResult)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInvalidXsrfStatus(status) {
|
||||||
|
return status === 400 || status === 401 || status === 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatXsrfRefreshResult(result) {
|
||||||
|
if (!result) {
|
||||||
|
return "not-requested";
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = result.refreshed ? "refreshed" : "not-refreshed";
|
||||||
|
const cookieName = result.cookieName || "(none)";
|
||||||
|
const duration = Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : "unknown";
|
||||||
|
return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestTokenRelay, createTokenRelayRequestEntry, isInvalidXsrfStatus, formatXsrfRefreshResult };
|
||||||
|
}
|
||||||
42
src/auth/xsrf-content.js
Normal file
42
src/auth/xsrf-content.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
function createAuthXsrfContent({
|
||||||
|
getXsrfTokenFromExtensionCookies,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document } = environment;
|
||||||
|
async function getXsrfToken() {
|
||||||
|
const extensionCookie = await getXsrfTokenFromExtensionCookies();
|
||||||
|
|
||||||
|
if (extensionCookie.token) {
|
||||||
|
return extensionCookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xsrfCookie = document.cookie
|
||||||
|
.split(";")
|
||||||
|
.map((cookie) => cookie.trim())
|
||||||
|
.find((cookie) => cookie.startsWith("XSRF-TOKEN-"));
|
||||||
|
|
||||||
|
if (!xsrfCookie) {
|
||||||
|
return {
|
||||||
|
token: "",
|
||||||
|
cookieName: "",
|
||||||
|
source: extensionCookie.error ? "document.cookie fallback after cookies API error" : "document.cookie fallback",
|
||||||
|
matchedCookieNames: extensionCookie.matchedCookieNames || [],
|
||||||
|
lookupDetails: extensionCookie.lookupDetails || [],
|
||||||
|
error: extensionCookie.error || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieName = xsrfCookie.slice(0, xsrfCookie.indexOf("="));
|
||||||
|
const tokenValue = xsrfCookie.slice(xsrfCookie.indexOf("=") + 1);
|
||||||
|
return {
|
||||||
|
token: decodeURIComponent(tokenValue),
|
||||||
|
cookieName,
|
||||||
|
source: "document.cookie",
|
||||||
|
matchedCookieNames: extensionCookie.matchedCookieNames || [],
|
||||||
|
lookupDetails: extensionCookie.lookupDetails || [],
|
||||||
|
error: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getXsrfToken };
|
||||||
|
}
|
||||||
116
src/auth/xsrf-cookies.js
Normal file
116
src/auth/xsrf-cookies.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
function createAuthXsrfCookies({
|
||||||
|
ORACLE_COOKIE_URLS,
|
||||||
|
ORACLE_DOMAIN,
|
||||||
|
XSRF_COOKIE_NAME,
|
||||||
|
XSRF_COOKIE_PREFIX,
|
||||||
|
cookiesGet,
|
||||||
|
cookiesGetAll,
|
||||||
|
cookiesGetAllCookieStores,
|
||||||
|
removeUndefinedValues
|
||||||
|
}) {
|
||||||
|
async function getXsrfTokenCookie() {
|
||||||
|
const lookupDetails = [];
|
||||||
|
const allCookies = [];
|
||||||
|
const stores = await getCookieStores(lookupDetails);
|
||||||
|
|
||||||
|
for (const store of stores) {
|
||||||
|
const exactCookie = await getExactCookieFromUrls(store.id, lookupDetails);
|
||||||
|
|
||||||
|
if (exactCookie) {
|
||||||
|
allCookies.push(exactCookie);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const store of stores) {
|
||||||
|
await collectCookies({
|
||||||
|
name: XSRF_COOKIE_NAME,
|
||||||
|
storeId: store.id
|
||||||
|
}, allCookies, lookupDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const store of stores) {
|
||||||
|
await collectCookies({
|
||||||
|
domain: ORACLE_DOMAIN,
|
||||||
|
storeId: store.id
|
||||||
|
}, allCookies, lookupDetails);
|
||||||
|
|
||||||
|
await collectCookies({
|
||||||
|
domain: `.${ORACLE_DOMAIN}`,
|
||||||
|
storeId: store.id
|
||||||
|
}, allCookies, lookupDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const store of stores) {
|
||||||
|
for (const url of ORACLE_COOKIE_URLS) {
|
||||||
|
await collectCookies({
|
||||||
|
url,
|
||||||
|
storeId: store.id
|
||||||
|
}, allCookies, lookupDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueCookies = dedupeCookies(allCookies);
|
||||||
|
const xsrfCookies = uniqueCookies.filter((cookie) => cookie.name.startsWith(XSRF_COOKIE_PREFIX));
|
||||||
|
const exactCookie = xsrfCookies.find((cookie) => cookie.name === XSRF_COOKIE_NAME);
|
||||||
|
const hostCookie = xsrfCookies.find((cookie) => cookie.domain === ORACLE_DOMAIN || cookie.domain === `.${ORACLE_DOMAIN}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cookie: exactCookie || hostCookie || xsrfCookies[0] || null,
|
||||||
|
matchedCookieNames: xsrfCookies.map((cookie) => `${cookie.name} (${cookie.domain}${cookie.path})`),
|
||||||
|
lookupDetails
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExactCookieFromUrls(storeId, lookupDetails) {
|
||||||
|
for (const url of ORACLE_COOKIE_URLS) {
|
||||||
|
const cookie = await cookiesGet({
|
||||||
|
url,
|
||||||
|
name: XSRF_COOKIE_NAME,
|
||||||
|
storeId
|
||||||
|
});
|
||||||
|
|
||||||
|
lookupDetails.push(`${JSON.stringify({ url, name: XSRF_COOKIE_NAME, storeId })} => ${cookie ? "found" : "not found"}`);
|
||||||
|
|
||||||
|
if (cookie) {
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCookieStores(lookupDetails) {
|
||||||
|
try {
|
||||||
|
const stores = await cookiesGetAllCookieStores();
|
||||||
|
lookupDetails.push(`getAllCookieStores => ${stores.length} store(s)`);
|
||||||
|
return stores.length ? stores : [{ id: undefined }];
|
||||||
|
} catch (error) {
|
||||||
|
lookupDetails.push(`getAllCookieStores => unavailable (${error.message || "unknown error"})`);
|
||||||
|
return [{ id: undefined }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectCookies(details, target, lookupDetails) {
|
||||||
|
const cleanDetails = removeUndefinedValues(details);
|
||||||
|
const cookies = await cookiesGetAll(cleanDetails);
|
||||||
|
target.push(...cookies);
|
||||||
|
lookupDetails.push(`${JSON.stringify(cleanDetails)} => ${cookies.length} cookie(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeCookies(cookies) {
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
return cookies.filter((cookie) => {
|
||||||
|
const key = `${cookie.name}|${cookie.domain}|${cookie.path}|${cookie.storeId || ""}`;
|
||||||
|
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getXsrfTokenCookie, getExactCookieFromUrls, getCookieStores, collectCookies, dedupeCookies };
|
||||||
|
}
|
||||||
84
src/auth/xsrf-recovery.js
Normal file
84
src/auth/xsrf-recovery.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
function createAuthXsrfRecovery({
|
||||||
|
COOKIE_REFRESH_POLL_MS,
|
||||||
|
COOKIE_REFRESH_TIMEOUT_MS,
|
||||||
|
OPPORTUNITIES_LIST_URL,
|
||||||
|
getXsrfTokenCookie,
|
||||||
|
supportsFirefoxCookieStoreTabs,
|
||||||
|
tabsCreate,
|
||||||
|
tabsRemove,
|
||||||
|
wait
|
||||||
|
}) {
|
||||||
|
async function refreshXsrfCookie(sender) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const previous = (await getXsrfTokenCookie()).cookie;
|
||||||
|
const createProperties = {
|
||||||
|
url: OPPORTUNITIES_LIST_URL,
|
||||||
|
active: false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
|
||||||
|
createProperties.windowId = sender.tab.windowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
|
||||||
|
createProperties.cookieStoreId = sender.tab.cookieStoreId;
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshTab;
|
||||||
|
|
||||||
|
try {
|
||||||
|
refreshTab = await tabsCreate(createProperties);
|
||||||
|
const current = await waitForXsrfCookieChange(previous);
|
||||||
|
const refreshed = hasCookieChanged(previous, current);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: Boolean(current),
|
||||||
|
refreshed,
|
||||||
|
cookieName: current ? current.name : "",
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
error: refreshed ? "" : "XSRF cookie was not created or changed before timeout."
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (refreshTab && Number.isInteger(refreshTab.id)) {
|
||||||
|
try {
|
||||||
|
await tabsRemove(refreshTab.id);
|
||||||
|
} catch (error) {
|
||||||
|
// The temporary tab may already have been closed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForXsrfCookieChange(previousCookie) {
|
||||||
|
const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS;
|
||||||
|
let currentCookie = null;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
currentCookie = (await getXsrfTokenCookie()).cookie;
|
||||||
|
|
||||||
|
if (hasCookieChanged(previousCookie, currentCookie)) {
|
||||||
|
return currentCookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
await wait(COOKIE_REFRESH_POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentCookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCookieChanged(previousCookie, currentCookie) {
|
||||||
|
if (!currentCookie || !currentCookie.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previousCookie) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return previousCookie.name !== currentCookie.name
|
||||||
|
|| previousCookie.value !== currentCookie.value
|
||||||
|
|| previousCookie.expirationDate !== currentCookie.expirationDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { refreshXsrfCookie, waitForXsrfCookieChange, hasCookieChanged };
|
||||||
|
}
|
||||||
1328
src/background.js
1328
src/background.js
File diff suppressed because it is too large
Load Diff
119
src/background/application.js
Normal file
119
src/background/application.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
function createBackgroundApplication(overrides = {}) {
|
||||||
|
const environment = {
|
||||||
|
fetch: globalThis.fetch,
|
||||||
|
browser: globalThis.browser,
|
||||||
|
chrome: globalThis.chrome,
|
||||||
|
setTimeout: globalThis.setTimeout,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
const messageTypes = createMessageTypes();
|
||||||
|
const contracts = createBackgroundContracts();
|
||||||
|
const productsCacheState = createProductsCacheState();
|
||||||
|
const authConsumerSession = createAuthConsumerSession({
|
||||||
|
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
|
||||||
|
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS,
|
||||||
|
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS,
|
||||||
|
CUSTOMER_TOKEN_SERVICE_URL: contracts.CUSTOMER_TOKEN_SERVICE_URL,
|
||||||
|
CUSTOMER_TOKEN_SESSION_URL: contracts.CUSTOMER_TOKEN_SESSION_URL,
|
||||||
|
extractTokenServiceAuthorization: (...args) => authSpaCredentials.extractTokenServiceAuthorization(...args),
|
||||||
|
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||||
|
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
|
||||||
|
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
|
||||||
|
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
|
||||||
|
wait: (...args) => platformBackgroundApi.wait(...args)
|
||||||
|
});
|
||||||
|
const authSpaCredentials = createAuthSpaCredentials({
|
||||||
|
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args)
|
||||||
|
});
|
||||||
|
const spaAccounts = createSpaAccounts({
|
||||||
|
ACCOUNT_PLANS_PAYLOAD: contracts.ACCOUNT_PLANS_PAYLOAD,
|
||||||
|
ACCOUNT_PLANS_URL: contracts.ACCOUNT_PLANS_URL,
|
||||||
|
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
|
||||||
|
extractAccountPlans: (...args) => spaResponseShapes.extractAccountPlans(...args),
|
||||||
|
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||||
|
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
|
||||||
|
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||||
|
});
|
||||||
|
const spaConsumption = createSpaConsumption({
|
||||||
|
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
|
||||||
|
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
|
||||||
|
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||||
|
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||||
|
});
|
||||||
|
const spaResponseShapes = createSpaResponseShapes();
|
||||||
|
const httpBackgroundJson = createHttpBackgroundJson({
|
||||||
|
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const productsBackgroundJobs = createProductsBackgroundJobs({
|
||||||
|
messageTypes,
|
||||||
|
OPPTY_PRODUCTS_CONCURRENCY: contracts.OPPTY_PRODUCTS_CONCURRENCY,
|
||||||
|
getOpptyProducts: (...args) => productsCache.getOpptyProducts(...args),
|
||||||
|
normalizeCacheMaxAge: (...args) => productsCache.normalizeCacheMaxAge(...args),
|
||||||
|
tabsSendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args)
|
||||||
|
});
|
||||||
|
const productsCache = createProductsCache({
|
||||||
|
OPPTY_PRODUCTS_CACHE_KEY: contracts.OPPTY_PRODUCTS_CACHE_KEY,
|
||||||
|
fetchAllOpptyProducts: (...args) => fusionProducts.fetchAllOpptyProducts(...args),
|
||||||
|
productsCacheState,
|
||||||
|
storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args),
|
||||||
|
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
|
||||||
|
});
|
||||||
|
const fusionProducts = createFusionProducts({
|
||||||
|
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
|
||||||
|
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
|
||||||
|
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,
|
||||||
|
ORACLE_DOMAIN: contracts.ORACLE_DOMAIN,
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const authXsrfRecovery = createAuthXsrfRecovery({
|
||||||
|
COOKIE_REFRESH_POLL_MS: contracts.COOKIE_REFRESH_POLL_MS,
|
||||||
|
COOKIE_REFRESH_TIMEOUT_MS: contracts.COOKIE_REFRESH_TIMEOUT_MS,
|
||||||
|
OPPORTUNITIES_LIST_URL: contracts.OPPORTUNITIES_LIST_URL,
|
||||||
|
getXsrfTokenCookie: (...args) => authXsrfCookies.getXsrfTokenCookie(...args),
|
||||||
|
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
|
||||||
|
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
|
||||||
|
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
|
||||||
|
wait: (...args) => platformBackgroundApi.wait(...args)
|
||||||
|
});
|
||||||
|
const authXsrfCookies = createAuthXsrfCookies({
|
||||||
|
ORACLE_COOKIE_URLS: contracts.ORACLE_COOKIE_URLS,
|
||||||
|
ORACLE_DOMAIN: contracts.ORACLE_DOMAIN,
|
||||||
|
XSRF_COOKIE_NAME: contracts.XSRF_COOKIE_NAME,
|
||||||
|
XSRF_COOKIE_PREFIX: contracts.XSRF_COOKIE_PREFIX,
|
||||||
|
cookiesGet: (...args) => platformBackgroundApi.cookiesGet(...args),
|
||||||
|
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
|
||||||
|
cookiesGetAllCookieStores: (...args) => platformBackgroundApi.cookiesGetAllCookieStores(...args),
|
||||||
|
removeUndefinedValues: (...args) => platformBackgroundApi.removeUndefinedValues(...args)
|
||||||
|
});
|
||||||
|
const platformBackgroundApi = createPlatformBackgroundApi({
|
||||||
|
runtimeApi: environment.browser || environment.chrome,
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
|
||||||
|
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
||||||
|
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
||||||
|
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||||
|
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||||
|
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||||
|
const runtimeApi = environment.browser || environment.chrome;
|
||||||
|
function start() {
|
||||||
|
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
start, contracts,
|
||||||
|
productsCacheState,
|
||||||
|
authConsumerSession,
|
||||||
|
authSpaCredentials,
|
||||||
|
spaAccounts,
|
||||||
|
spaConsumption,
|
||||||
|
spaResponseShapes,
|
||||||
|
httpBackgroundJson,
|
||||||
|
productsBackgroundJobs,
|
||||||
|
productsCache,
|
||||||
|
fusionProducts,
|
||||||
|
authXsrfRecovery,
|
||||||
|
authXsrfCookies,
|
||||||
|
platformBackgroundApi
|
||||||
|
};
|
||||||
|
}
|
||||||
69
src/background/contracts.js
Normal file
69
src/background/contracts.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
function createBackgroundContracts() {
|
||||||
|
const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com";
|
||||||
|
const ORACLE_COOKIE_URLS = [
|
||||||
|
"https://eeho.fa.us2.oraclecloud.com/",
|
||||||
|
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome",
|
||||||
|
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome",
|
||||||
|
"https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay"
|
||||||
|
];
|
||||||
|
const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F";
|
||||||
|
const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-";
|
||||||
|
const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list";
|
||||||
|
const COOKIE_REFRESH_TIMEOUT_MS = 15000;
|
||||||
|
const COOKIE_REFRESH_POLL_MS = 250;
|
||||||
|
const OPPTY_PRODUCTS_CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1";
|
||||||
|
const OPPTY_PRODUCTS_PAGE_LIMIT = 15;
|
||||||
|
const OPPTY_PRODUCTS_MAX_PAGES = 250;
|
||||||
|
const OPPTY_PRODUCTS_CONCURRENCY = 4;
|
||||||
|
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService";
|
||||||
|
const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html";
|
||||||
|
const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5;
|
||||||
|
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
|
||||||
|
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;
|
||||||
|
const ACCOUNT_SUMMARY_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-account/service/accountsummary";
|
||||||
|
const ACCOUNT_PLANS_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/account/accountPlans";
|
||||||
|
const CONSUMPTION_TREND_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/trend";
|
||||||
|
const CONSUMPTION_TREND_REQUESTS = {
|
||||||
|
quarterly: "requestConsumptionServiceQuarterly",
|
||||||
|
monthly: "requestConsumptionServiceMonthly",
|
||||||
|
weekly: "requestConsumptionServiceWeekly",
|
||||||
|
daily: "requestConsumptionServiceDaily"
|
||||||
|
};
|
||||||
|
const ACCOUNT_PLANS_PAYLOAD = {
|
||||||
|
hierarchy: "single",
|
||||||
|
products: "all",
|
||||||
|
language: "english",
|
||||||
|
planStatus: [
|
||||||
|
"active",
|
||||||
|
"awaiting_provisioning",
|
||||||
|
"awaiting_provisioning_error",
|
||||||
|
"draft",
|
||||||
|
"expired",
|
||||||
|
"final_billing",
|
||||||
|
"onhold",
|
||||||
|
"provisioned",
|
||||||
|
"signed",
|
||||||
|
"superseded",
|
||||||
|
"superseded_pending_final_billing",
|
||||||
|
"suspended",
|
||||||
|
"terminated",
|
||||||
|
"terminated_pending_final_billing"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const DEBUG_BODY_LIMIT = 100000;
|
||||||
|
const OPPTY_PRODUCTS_FIELDS = [
|
||||||
|
"ProductType", "Description", "InventoryItemId", "ProdGroupName", "ProdGroupId", "OwnerLockAsgnFlag",
|
||||||
|
"Quantity", "RecurTypeCode", "RevnAmountCurcyCode", "UnitPrice", "RevnAmount", "PriceTypeCode",
|
||||||
|
"EffectiveDate", "Name1", "NonRecurringRevenue", "OpportunityOwnerPartyName", "OpportunityOwnerResourcePartyId",
|
||||||
|
"OptyId", "OptyNumber", "PartyName2", "PrTerritoryVersionId", "PrTerritoryVersionIdForManual",
|
||||||
|
"RecurEndDate", "RecurFrequencyCode", "RecurNumberPeriods", "RecurRevenue", "ResourcePartyId", "RevnId",
|
||||||
|
"RevnNumber", "SplitPercent", "SalesCreditTypeCode", "SplitTypeCode", "TerrOwnerPartyName", "UpsideAmount",
|
||||||
|
"UsageRevenue", "StatusCode", "BUOrgId", "TypeCode", "WinProb", "ForecastType_c", "ARRLocalCurrency_c",
|
||||||
|
"CPQLastSyncDate_c", "CPQOperationType_c", "ProdGLID_c", "ProposalNumber_c", "QuoteNumber_c",
|
||||||
|
"ServicesPeriod_c", "CPQIntLastUpdatedBy_c", "CPQUpdatedAmount_c", "CPQUpdatedQuantity_c",
|
||||||
|
"CPQUpdatedRevnType_c", "CPQUpdatedServicePeriod_c", "CPQUpdatedStatus_c", "OrderNumber_c", "PrevAmount_c",
|
||||||
|
"PrevQuantity_c", "PrevRevenueType_c", "PrevServicePeriod_c", "PrevStatus_c", "SubscriptionID_c",
|
||||||
|
"WorkloadName_c", "ConsumptionStartDate_c", "RampMonths_c"
|
||||||
|
];
|
||||||
|
return { ORACLE_DOMAIN, ORACLE_COOKIE_URLS, XSRF_COOKIE_NAME, XSRF_COOKIE_PREFIX, OPPORTUNITIES_LIST_URL, COOKIE_REFRESH_TIMEOUT_MS, COOKIE_REFRESH_POLL_MS, OPPTY_PRODUCTS_CACHE_KEY, OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_CONCURRENCY, CUSTOMER_TOKEN_SERVICE_URL, CUSTOMER_TOKEN_SESSION_URL, CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, ACCOUNT_SUMMARY_URL, ACCOUNT_PLANS_URL, CONSUMPTION_TREND_URL, CONSUMPTION_TREND_REQUESTS, ACCOUNT_PLANS_PAYLOAD, DEBUG_BODY_LIMIT, OPPTY_PRODUCTS_FIELDS };
|
||||||
|
}
|
||||||
3
src/background/main.js
Normal file
3
src/background/main.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
function startBackgroundApplication(dependencies) {
|
||||||
|
registerBackgroundMessages(dependencies);
|
||||||
|
}
|
||||||
116
src/background/messages.js
Normal file
116
src/background/messages.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||||
|
function registerBackgroundMessages({
|
||||||
|
messageTypes,
|
||||||
|
getXsrfTokenCookie,
|
||||||
|
refreshXsrfCookie,
|
||||||
|
requestAccountPlans,
|
||||||
|
requestConsumerTokenService,
|
||||||
|
requestConsumptionTrend,
|
||||||
|
requestOpptyProducts,
|
||||||
|
runtimeApi
|
||||||
|
}) {
|
||||||
|
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
if (!message || !message.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.getXsrfToken) {
|
||||||
|
getXsrfTokenCookie()
|
||||||
|
.then((result) => {
|
||||||
|
sendResponse({
|
||||||
|
ok: true,
|
||||||
|
cookieName: result.cookie ? result.cookie.name : "",
|
||||||
|
token: result.cookie ? result.cookie.value : "",
|
||||||
|
matchedCookieNames: result.matchedCookieNames,
|
||||||
|
lookupDetails: result.lookupDetails
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({
|
||||||
|
ok: false,
|
||||||
|
cookieName: "",
|
||||||
|
token: "",
|
||||||
|
matchedCookieNames: [],
|
||||||
|
lookupDetails: [],
|
||||||
|
error: error.message || "Unable to read cookies."
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.refreshXsrfCookie) {
|
||||||
|
refreshXsrfCookie(sender)
|
||||||
|
.then(sendResponse)
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({
|
||||||
|
ok: false,
|
||||||
|
refreshed: false,
|
||||||
|
cookieName: "",
|
||||||
|
durationMs: 0,
|
||||||
|
error: error.message || "Unable to refresh XSRF cookie."
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestOpptyProducts) {
|
||||||
|
requestOpptyProducts(message, sender)
|
||||||
|
.then(sendResponse)
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({
|
||||||
|
ok: false,
|
||||||
|
requestId: message.requestId || "",
|
||||||
|
error: error.message || "Unable to load opportunity products."
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestConsumerTokenService) {
|
||||||
|
requestConsumerTokenService(sender)
|
||||||
|
.then(sendResponse)
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({
|
||||||
|
ok: false,
|
||||||
|
error: error.message || "Unable to load consumer token service.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestAccountPlans) {
|
||||||
|
requestAccountPlans(message)
|
||||||
|
.then(sendResponse)
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({
|
||||||
|
ok: false,
|
||||||
|
error: error.message || "Unable to load account plans.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestConsumptionTrend) {
|
||||||
|
requestConsumptionTrend(message)
|
||||||
|
.then(sendResponse)
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({
|
||||||
|
ok: false,
|
||||||
|
error: error.message || "Unable to load consumption trend.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
7636
src/content.js
7636
src/content.js
File diff suppressed because it is too large
Load Diff
434
src/content/application.js
Normal file
434
src/content/application.js
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
function createContentApplication(overrides = {}) {
|
||||||
|
const environment = {
|
||||||
|
document: globalThis.document,
|
||||||
|
window: globalThis.window,
|
||||||
|
navigator: globalThis.navigator,
|
||||||
|
get localStorage() { return globalThis.localStorage; },
|
||||||
|
fetch: globalThis.fetch,
|
||||||
|
browser: globalThis.browser,
|
||||||
|
chrome: globalThis.chrome,
|
||||||
|
MutationObserver: globalThis.MutationObserver,
|
||||||
|
Node: globalThis.Node,
|
||||||
|
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||||
|
performance: globalThis.performance,
|
||||||
|
cancelAnimationFrame: globalThis.cancelAnimationFrame,
|
||||||
|
setTimeout: globalThis.setTimeout,
|
||||||
|
clearTimeout: globalThis.clearTimeout,
|
||||||
|
matchMedia: globalThis.matchMedia,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
const messageTypes = createMessageTypes();
|
||||||
|
const contracts = createContentContracts();
|
||||||
|
const authSessionState = createAuthSessionState(contracts);
|
||||||
|
const authSalesCommonState = createAuthSalesCommonState();
|
||||||
|
const opportunitiesQueryState = createOpportunitiesQueryState();
|
||||||
|
const opportunitiesTableState = createOpportunitiesTableState();
|
||||||
|
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
||||||
|
const preferencesState = createPreferencesState(contracts);
|
||||||
|
const productsContentState = createProductsContentState();
|
||||||
|
const aiSummaryState = createAiSummaryState();
|
||||||
|
const consumptionPlansState = createConsumptionPlansState();
|
||||||
|
const consumptionTrendState = createConsumptionTrendState();
|
||||||
|
const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts);
|
||||||
|
const debugState = createDebugState();
|
||||||
|
const columns = createOpportunitiesColumns({
|
||||||
|
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||||
|
formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args),
|
||||||
|
formatWinProbability: (...args) => opportunitiesFormatters.formatWinProbability(...args),
|
||||||
|
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
||||||
|
getOwnerName: (...args) => opportunitiesFormatters.getOwnerName(...args)
|
||||||
|
});
|
||||||
|
const contentFusionPage = createContentFusionPage({
|
||||||
|
ADD_ICON_SELECTOR: contracts.ADD_ICON_SELECTOR,
|
||||||
|
ALLOWED_PATHS: contracts.ALLOWED_PATHS,
|
||||||
|
SALES_GROUP_SELECTOR: contracts.SALES_GROUP_SELECTOR,
|
||||||
|
TILE_ID: contracts.TILE_ID,
|
||||||
|
TILE_LABEL_ID: contracts.TILE_LABEL_ID,
|
||||||
|
appendPath: (...args) => svgPaths.appendPath(...args),
|
||||||
|
openOpportunitiesModal: (...args) => contentModal.openOpportunitiesModal(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const contentModal = createContentModal({
|
||||||
|
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||||
|
AUTH_STATUS: contracts.AUTH_STATUS,
|
||||||
|
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
|
||||||
|
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
|
||||||
|
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
|
||||||
|
CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID,
|
||||||
|
CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID,
|
||||||
|
DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID,
|
||||||
|
ESCAPE_LISTENER_KEY: contracts.ESCAPE_LISTENER_KEY,
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
|
||||||
|
OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID,
|
||||||
|
OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID,
|
||||||
|
OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID,
|
||||||
|
OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID,
|
||||||
|
OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID,
|
||||||
|
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
|
||||||
|
PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID,
|
||||||
|
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||||
|
PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID,
|
||||||
|
STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID,
|
||||||
|
STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID,
|
||||||
|
STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID,
|
||||||
|
TABLE_SEARCH_ID: contracts.TABLE_SEARCH_ID,
|
||||||
|
applyTheme: (...args) => contentTheme.applyTheme(...args),
|
||||||
|
authSessionState,
|
||||||
|
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
|
||||||
|
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
|
||||||
|
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
||||||
|
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
||||||
|
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
|
||||||
|
debugState,
|
||||||
|
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
|
||||||
|
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
|
||||||
|
getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args),
|
||||||
|
getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args),
|
||||||
|
getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args),
|
||||||
|
getSavedTheme: (...args) => contentTheme.getSavedTheme(...args),
|
||||||
|
openPreferencesModal: (...args) => preferencesView.openPreferencesModal(...args),
|
||||||
|
opportunitiesFilterState,
|
||||||
|
opportunitiesQueryState,
|
||||||
|
preferencesState,
|
||||||
|
renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args),
|
||||||
|
renderCustomerFilterList: (...args) => opportunitiesFilters.renderCustomerFilterList(...args),
|
||||||
|
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
||||||
|
renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args),
|
||||||
|
renderOwnerFilterList: (...args) => opportunitiesFilters.renderOwnerFilterList(...args),
|
||||||
|
renderStageFilterButtons: (...args) => opportunitiesFilters.renderStageFilterButtons(...args),
|
||||||
|
renderStatusFilterButtons: (...args) => opportunitiesFilters.renderStatusFilterButtons(...args),
|
||||||
|
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||||
|
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
||||||
|
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
|
||||||
|
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
|
||||||
|
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
|
||||||
|
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
|
||||||
|
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
|
||||||
|
toggleDebugPanel: (...args) => debugContentView.toggleDebugPanel(...args),
|
||||||
|
toggleOwnerFilterPanel: (...args) => opportunitiesFilters.toggleOwnerFilterPanel(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const contentTheme = createContentTheme({
|
||||||
|
THEME_STORAGE_KEY: contracts.THEME_STORAGE_KEY,
|
||||||
|
createThemeIcon: (...args) => contentIcons.createThemeIcon(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const contentIcons = createContentIcons({
|
||||||
|
appendPath: (...args) => svgPaths.appendPath(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const svgPaths = createSvgPaths({ environment });
|
||||||
|
const contentStyles = createContentStyles({
|
||||||
|
STYLE_ID: contracts.STYLE_ID,
|
||||||
|
redwoodStyles,
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const authContentSession = createAuthContentSession({
|
||||||
|
messageTypes,
|
||||||
|
AUTH_STATUS: contracts.AUTH_STATUS,
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
authSessionState,
|
||||||
|
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
updateAuthBadge: (...args) => contentModal.updateAuthBadge(...args)
|
||||||
|
});
|
||||||
|
const authSalesCommon = createAuthSalesCommon({
|
||||||
|
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
|
||||||
|
authSalesCommonState,
|
||||||
|
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args)
|
||||||
|
});
|
||||||
|
const authTokenRelay = createAuthTokenRelay({
|
||||||
|
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
|
||||||
|
createDebugBody: (...args) => debugContentLog.createDebugBody(...args),
|
||||||
|
getXsrfToken: (...args) => authXsrfContent.getXsrfToken(...args),
|
||||||
|
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
|
||||||
|
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
|
||||||
|
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
|
||||||
|
maskToken: (...args) => debugContentLog.maskToken(...args),
|
||||||
|
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
||||||
|
refreshXsrfCookie: (...args) => platformContentMessages.refreshXsrfCookie(...args),
|
||||||
|
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const authXsrfContent = createAuthXsrfContent({
|
||||||
|
getXsrfTokenFromExtensionCookies: (...args) => platformContentMessages.getXsrfTokenFromExtensionCookies(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const platformContentMessages = createPlatformContentMessages({
|
||||||
|
messageTypes,
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const opportunitiesController = createOpportunitiesController({
|
||||||
|
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
||||||
|
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
|
||||||
|
authSessionState,
|
||||||
|
consumptionPlansState,
|
||||||
|
consumptionTrendState,
|
||||||
|
getFiscalPeriodRange: (...args) => opportunitiesFiscalPeriods.getFiscalPeriodRange(...args),
|
||||||
|
opportunitiesDashboardState,
|
||||||
|
opportunitiesFilterState,
|
||||||
|
opportunitiesQueryState,
|
||||||
|
opportunitiesTableState,
|
||||||
|
preferencesState,
|
||||||
|
productsContentState,
|
||||||
|
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
|
||||||
|
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
|
||||||
|
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
|
||||||
|
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
||||||
|
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
|
||||||
|
});
|
||||||
|
const fusionOpportunities = createFusionOpportunities({
|
||||||
|
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
|
||||||
|
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
|
||||||
|
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
|
||||||
|
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
|
||||||
|
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
|
||||||
|
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
|
||||||
|
maskToken: (...args) => debugContentLog.maskToken(...args),
|
||||||
|
getOpportunityTypeValues: () => preferencesState.selectedOpportunityTypeValues,
|
||||||
|
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const opportunitiesFiscalPeriods = createOpportunitiesFiscalPeriods();
|
||||||
|
const opportunitiesTable = createOpportunitiesTable({
|
||||||
|
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
|
||||||
|
OPPORTUNITIES_COLUMNS: columns,
|
||||||
|
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
|
||||||
|
OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL,
|
||||||
|
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
|
||||||
|
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||||
|
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
||||||
|
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
||||||
|
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
|
||||||
|
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
|
||||||
|
createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args),
|
||||||
|
displayOpportunityValue: (...args) => opportunitiesFormatters.displayOpportunityValue(...args),
|
||||||
|
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
||||||
|
getSortedOpportunities: (...args) => opportunitiesFilters.getSortedOpportunities(...args),
|
||||||
|
opportunitiesTableState,
|
||||||
|
productsContentState,
|
||||||
|
renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args),
|
||||||
|
renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args),
|
||||||
|
renderStageDashboard: (...args) => opportunitiesDashboard.renderStageDashboard(...args),
|
||||||
|
resetCustomerFilter: (...args) => opportunitiesFilters.resetCustomerFilter(...args),
|
||||||
|
resetOwnerFilter: (...args) => opportunitiesFilters.resetOwnerFilter(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const opportunitiesDashboard = createOpportunitiesDashboard({
|
||||||
|
STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID,
|
||||||
|
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
||||||
|
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
||||||
|
opportunitiesDashboardState,
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const opportunitiesFilters = createOpportunitiesFilters({
|
||||||
|
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
|
||||||
|
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
|
||||||
|
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
|
||||||
|
CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID,
|
||||||
|
CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID,
|
||||||
|
OPPORTUNITIES_COLUMNS: columns,
|
||||||
|
OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID,
|
||||||
|
OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID,
|
||||||
|
OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID,
|
||||||
|
OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID,
|
||||||
|
OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID,
|
||||||
|
STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID,
|
||||||
|
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
||||||
|
STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID,
|
||||||
|
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
|
||||||
|
getCustomerFilterSummary: (...args) => opportunitiesFormatters.getCustomerFilterSummary(...args),
|
||||||
|
getCustomerKey: (...args) => opportunitiesFormatters.getCustomerKey(...args),
|
||||||
|
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
|
||||||
|
getOwnerKey: (...args) => opportunitiesFormatters.getOwnerKey(...args),
|
||||||
|
getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args),
|
||||||
|
opportunitiesFilterState,
|
||||||
|
opportunitiesTableState,
|
||||||
|
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
||||||
|
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
|
||||||
|
stringifySearchValue: (...args) => opportunitiesFormatters.stringifySearchValue(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const opportunitiesFormatters = createOpportunitiesFormatters({
|
||||||
|
opportunitiesTableState
|
||||||
|
});
|
||||||
|
const opportunitiesLinks = createOpportunitiesLinks({
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const preferencesStorage = createPreferencesStorage({
|
||||||
|
DEFAULT_OPPORTUNITY_TYPE_VALUES: contracts.DEFAULT_OPPORTUNITY_TYPE_VALUES,
|
||||||
|
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
|
||||||
|
FILTER_PREFERENCES_STORAGE_KEY: contracts.FILTER_PREFERENCES_STORAGE_KEY,
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS,
|
||||||
|
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
|
||||||
|
OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS,
|
||||||
|
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
|
||||||
|
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
||||||
|
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
|
||||||
|
opportunitiesFilterState,
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const preferencesView = createPreferencesView({
|
||||||
|
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS,
|
||||||
|
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
|
||||||
|
OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS,
|
||||||
|
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
|
||||||
|
PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID,
|
||||||
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
|
getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args),
|
||||||
|
getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args),
|
||||||
|
preferencesState,
|
||||||
|
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const productsContentJobs = createProductsContentJobs({
|
||||||
|
messageTypes,
|
||||||
|
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||||
|
PRODUCTS_MESSAGE_LISTENER_KEY: contracts.PRODUCTS_MESSAGE_LISTENER_KEY,
|
||||||
|
PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID,
|
||||||
|
preferencesState,
|
||||||
|
productsContentState,
|
||||||
|
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const productsDetails = createProductsDetails({
|
||||||
|
OPPORTUNITIES_COLUMNS: columns,
|
||||||
|
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
|
||||||
|
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||||
|
productsContentState,
|
||||||
|
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
||||||
|
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const aiSummaryContent = createAiSummaryContent({
|
||||||
|
aiSummaryState,
|
||||||
|
invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args),
|
||||||
|
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
|
||||||
|
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
|
||||||
|
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
|
||||||
|
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
||||||
|
requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const consumptionPlansAction = createConsumptionPlansAction({
|
||||||
|
messageTypes,
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
consumptionPlansState,
|
||||||
|
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
||||||
|
openAccountPlansModal: (...args) => consumptionPlansView.openAccountPlansModal(...args),
|
||||||
|
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
||||||
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
showCopyConfirmation: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const consumptionPlansView = createConsumptionPlansView({
|
||||||
|
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||||
|
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
|
createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args),
|
||||||
|
createSvgElement: (...args) => consumptionChart.createSvgElement(...args),
|
||||||
|
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||||
|
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
||||||
|
loadConsumptionTrendForPanel: (...args) => consumptionTrendController.loadConsumptionTrendForPanel(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const consumptionTrendController = createConsumptionTrendController({
|
||||||
|
messageTypes,
|
||||||
|
CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS,
|
||||||
|
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
consumptionTrendState,
|
||||||
|
renderConsumptionTrendChart: (...args) => consumptionChart.renderConsumptionTrendChart(...args),
|
||||||
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const consumptionChart = createConsumptionChart({
|
||||||
|
CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS,
|
||||||
|
CONSUMPTION_TREND_SERIES: contracts.CONSUMPTION_TREND_SERIES,
|
||||||
|
SVG_NAMESPACE: contracts.SVG_NAMESPACE,
|
||||||
|
calculateAverageConsumption: (...args) => consumptionModel.calculateAverageConsumption(...args),
|
||||||
|
consumptionTrendState,
|
||||||
|
createConsumptionTrendKey: (...args) => consumptionTrendController.createConsumptionTrendKey(...args),
|
||||||
|
createNiceConsumptionMaximum: (...args) => consumptionModel.createNiceConsumptionMaximum(...args),
|
||||||
|
formatCompactConsumptionNumber: (...args) => consumptionModel.formatCompactConsumptionNumber(...args),
|
||||||
|
formatConsumptionCurrency: (...args) => consumptionModel.formatConsumptionCurrency(...args),
|
||||||
|
formatConsumptionPointLabel: (...args) => consumptionModel.formatConsumptionPointLabel(...args),
|
||||||
|
formatConsumptionRange: (...args) => consumptionModel.formatConsumptionRange(...args),
|
||||||
|
normalizeConsumptionTrendItems: (...args) => consumptionModel.normalizeConsumptionTrendItems(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const consumptionModel = createConsumptionModel({
|
||||||
|
|
||||||
|
});
|
||||||
|
const debugContentLog = createDebugContentLog({
|
||||||
|
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
|
||||||
|
debugState,
|
||||||
|
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
|
||||||
|
});
|
||||||
|
const debugContentView = createDebugContentView({
|
||||||
|
DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID,
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
debugState,
|
||||||
|
formatDebugBody: (...args) => debugContentLog.formatDebugBody(...args),
|
||||||
|
formatHeaderBlock: (...args) => debugContentLog.formatHeaderBlock(...args),
|
||||||
|
formatMetadataBlock: (...args) => debugContentLog.formatMetadataBlock(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
function start() {
|
||||||
|
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
start, contracts,
|
||||||
|
authSessionState,
|
||||||
|
authSalesCommonState,
|
||||||
|
opportunitiesQueryState,
|
||||||
|
opportunitiesTableState,
|
||||||
|
opportunitiesFilterState,
|
||||||
|
preferencesState,
|
||||||
|
productsContentState,
|
||||||
|
aiSummaryState,
|
||||||
|
consumptionPlansState,
|
||||||
|
consumptionTrendState,
|
||||||
|
opportunitiesDashboardState,
|
||||||
|
debugState,
|
||||||
|
contentFusionPage,
|
||||||
|
contentModal,
|
||||||
|
contentTheme,
|
||||||
|
contentIcons,
|
||||||
|
contentStyles,
|
||||||
|
authContentSession,
|
||||||
|
authSalesCommon,
|
||||||
|
authTokenRelay,
|
||||||
|
authXsrfContent,
|
||||||
|
platformContentMessages,
|
||||||
|
opportunitiesController,
|
||||||
|
fusionOpportunities,
|
||||||
|
opportunitiesFiscalPeriods,
|
||||||
|
opportunitiesTable,
|
||||||
|
opportunitiesDashboard,
|
||||||
|
opportunitiesFilters,
|
||||||
|
opportunitiesFormatters,
|
||||||
|
opportunitiesLinks,
|
||||||
|
preferencesStorage,
|
||||||
|
preferencesView,
|
||||||
|
productsContentJobs,
|
||||||
|
productsDetails,
|
||||||
|
aiSummaryContent,
|
||||||
|
consumptionPlansAction,
|
||||||
|
consumptionPlansView,
|
||||||
|
consumptionTrendController,
|
||||||
|
consumptionChart,
|
||||||
|
consumptionModel,
|
||||||
|
debugContentLog,
|
||||||
|
debugContentView
|
||||||
|
};
|
||||||
|
}
|
||||||
100
src/content/contracts.js
Normal file
100
src/content/contracts.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
function createContentContracts() {
|
||||||
|
const TILE_ID = "c_5c719a88b5624b1eaf81defe2c2ex4x5";
|
||||||
|
const TILE_LABEL_ID = `${TILE_ID}_0`;
|
||||||
|
const SALES_GROUP_SELECTOR = "#yourapps_groupNode_sales";
|
||||||
|
const ADD_ICON_SELECTOR = ".flat-grid-cell.flat-grid-cell-addicon";
|
||||||
|
const MODAL_ID = "opportunities-extension-modal";
|
||||||
|
const STYLE_ID = "opportunities-extension-redwood-styles";
|
||||||
|
const ESCAPE_LISTENER_KEY = "opportunitiesExtensionEscapeListener";
|
||||||
|
const DEBUG_PANEL_ID = "opportunities-extension-debug-panel";
|
||||||
|
const OPPORTUNITIES_TABLE_ID = "opportunities-extension-table";
|
||||||
|
const STAGE_FILTERS_ID = "opportunities-extension-stage-filters";
|
||||||
|
const STATUS_FILTERS_ID = "opportunities-extension-status-filters";
|
||||||
|
const CUSTOMER_FILTER_BUTTON_ID = "opportunities-extension-customer-filter-button";
|
||||||
|
const CUSTOMER_FILTER_PANEL_ID = "opportunities-extension-customer-filter-panel";
|
||||||
|
const CUSTOMER_FILTER_SEARCH_ID = "opportunities-extension-customer-filter-search";
|
||||||
|
const CUSTOMER_FILTER_SELECT_ALL_ID = "opportunities-extension-customer-filter-select-all";
|
||||||
|
const CUSTOMER_FILTER_LIST_ID = "opportunities-extension-customer-filter-list";
|
||||||
|
const OWNER_FILTER_BUTTON_ID = "opportunities-extension-owner-filter-button";
|
||||||
|
const OWNER_FILTER_PANEL_ID = "opportunities-extension-owner-filter-panel";
|
||||||
|
const OWNER_FILTER_SEARCH_ID = "opportunities-extension-owner-filter-search";
|
||||||
|
const OWNER_FILTER_SELECT_ALL_ID = "opportunities-extension-owner-filter-select-all";
|
||||||
|
const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list";
|
||||||
|
const TABLE_SEARCH_ID = "opportunities-extension-table-search";
|
||||||
|
const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard";
|
||||||
|
const PRODUCTS_PROGRESS_ID = "opportunities-extension-products-progress";
|
||||||
|
const PRODUCTS_CACHE_TIMESTAMP_ID = "opportunities-extension-products-cache-timestamp";
|
||||||
|
const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences";
|
||||||
|
const THEME_STORAGE_KEY = "opportunities-extension-theme";
|
||||||
|
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
|
||||||
|
const PREFERENCES_MODAL_ID = "opportunities-extension-preferences-modal";
|
||||||
|
const ACCOUNT_PLANS_MODAL_ID = "opportunities-extension-account-plans-modal";
|
||||||
|
const PRODUCTS_MESSAGE_LISTENER_KEY = "opportunitiesExtensionProductsMessageListener";
|
||||||
|
const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid=";
|
||||||
|
const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id=";
|
||||||
|
const DEBUG_BODY_LIMIT = 12000;
|
||||||
|
const OPPORTUNITIES_PAGE_LIMIT = 15;
|
||||||
|
const DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS = 24;
|
||||||
|
const TOKEN_RELAY_URL = "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay";
|
||||||
|
const OPPORTUNITIES_QUERY_URL = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries";
|
||||||
|
const AUTH_STATUS = {
|
||||||
|
idle: "idle",
|
||||||
|
pending: "pending",
|
||||||
|
authenticated: "authenticated",
|
||||||
|
unauthenticated: "unauthenticated"
|
||||||
|
};
|
||||||
|
const CONSUMPTION_TREND_PERIODS = [
|
||||||
|
{ value: "quarterly", label: "Quarterly", windowSize: 8 },
|
||||||
|
{ value: "monthly", label: "Monthly", windowSize: 13 },
|
||||||
|
{ value: "weekly", label: "Weekly", windowSize: 13 },
|
||||||
|
{ value: "daily", label: "Daily", windowSize: 30 }
|
||||||
|
];
|
||||||
|
const CONSUMPTION_TREND_SERIES = [
|
||||||
|
{ value: "funded", label: "Funded Allocation" },
|
||||||
|
{ value: "overage", label: "Overage" },
|
||||||
|
{ value: "average", label: "Average Consumption" }
|
||||||
|
];
|
||||||
|
const DEFAULT_CONSUMPTION_TREND_PERIOD = "weekly";
|
||||||
|
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||||||
|
const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"];
|
||||||
|
const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"];
|
||||||
|
const OPPORTUNITY_TYPE_OPTIONS = [
|
||||||
|
{ label: "All records I can see", value: "ORA_ALLOPTIES" },
|
||||||
|
{ label: "I am credit receiver", value: "ORA_CREDITRECEIVER_ISME" },
|
||||||
|
{ label: "I am on the team", value: "ORA_MYSALESTEAMOPTIES" },
|
||||||
|
{ label: "I or my subordinate is a credit receiver", value: "ORA_CREDITRECEIVER_MYORG" },
|
||||||
|
{ label: "I own", value: "ORA_MYOPTIES" },
|
||||||
|
{ label: "My subordinates are on the team", value: "ORA_MYSUBORDSSALESTEAMOPTIES" },
|
||||||
|
{ label: "My subordinates own", value: "ORA_MYSUBORDINATESOPTIES" },
|
||||||
|
{ label: "My territory", value: "ORA_MYASSGTERROPTIES" },
|
||||||
|
{ label: "My territory hierarchy", value: "ORA_MYASSGDESCTERROPTIES" },
|
||||||
|
{ label: "My territory membership", value: "ORA_MYASSGMEMBTERROPTIES" },
|
||||||
|
{ label: "My territory membership hierarchy", value: "ORA_MYASSGMEMBDESCTERROPTIES" }
|
||||||
|
];
|
||||||
|
const DEFAULT_OPPORTUNITY_TYPE_VALUES = [
|
||||||
|
"ORA_MYSALESTEAMOPTIES",
|
||||||
|
"ORA_MYASSGTERROPTIES",
|
||||||
|
"ORA_CREDITRECEIVER_ISME"
|
||||||
|
];
|
||||||
|
const OPPTY_PRODUCTS_CACHE_OPTIONS = [
|
||||||
|
{ label: "1 hour", value: 1 },
|
||||||
|
{ label: "6 hours", value: 6 },
|
||||||
|
{ label: "12 hours", value: 12 },
|
||||||
|
{ label: "24 hours (default)", value: 24 },
|
||||||
|
{ label: "48 hours", value: 48 },
|
||||||
|
{ label: "7 days", value: 168 }
|
||||||
|
];
|
||||||
|
const PERIOD_OPTIONS = [
|
||||||
|
"Current Quarter",
|
||||||
|
"Next Quarter",
|
||||||
|
"Previous Quarter",
|
||||||
|
"Current Fiscal Year",
|
||||||
|
"4 Rolling Quarters (CQ + 3)",
|
||||||
|
"RENEWALS (Current + Past Due)"
|
||||||
|
];
|
||||||
|
const ALLOWED_PATHS = [
|
||||||
|
"/hcmUI/faces/FuseWelcome",
|
||||||
|
"/fscmUI/faces/FuseWelcome"
|
||||||
|
];
|
||||||
|
return { TILE_ID, TILE_LABEL_ID, SALES_GROUP_SELECTOR, ADD_ICON_SELECTOR, MODAL_ID, STYLE_ID, ESCAPE_LISTENER_KEY, DEBUG_PANEL_ID, OPPORTUNITIES_TABLE_ID, STAGE_FILTERS_ID, STATUS_FILTERS_ID, CUSTOMER_FILTER_BUTTON_ID, CUSTOMER_FILTER_PANEL_ID, CUSTOMER_FILTER_SEARCH_ID, CUSTOMER_FILTER_SELECT_ALL_ID, CUSTOMER_FILTER_LIST_ID, OWNER_FILTER_BUTTON_ID, OWNER_FILTER_PANEL_ID, OWNER_FILTER_SEARCH_ID, OWNER_FILTER_SELECT_ALL_ID, OWNER_FILTER_LIST_ID, TABLE_SEARCH_ID, STAGE_DASHBOARD_ID, PRODUCTS_PROGRESS_ID, PRODUCTS_CACHE_TIMESTAMP_ID, FILTER_PREFERENCES_STORAGE_KEY, THEME_STORAGE_KEY, OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, PREFERENCES_MODAL_ID, ACCOUNT_PLANS_MODAL_ID, PRODUCTS_MESSAGE_LISTENER_KEY, OPPORTUNITY_DETAIL_URL, ACCOUNT_DETAIL_URL, DEBUG_BODY_LIMIT, OPPORTUNITIES_PAGE_LIMIT, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, TOKEN_RELAY_URL, OPPORTUNITIES_QUERY_URL, AUTH_STATUS, CONSUMPTION_TREND_PERIODS, CONSUMPTION_TREND_SERIES, DEFAULT_CONSUMPTION_TREND_PERIOD, SVG_NAMESPACE, STAGE_OPTIONS, STATUS_OPTIONS, OPPORTUNITY_TYPE_OPTIONS, DEFAULT_OPPORTUNITY_TYPE_VALUES, OPPTY_PRODUCTS_CACHE_OPTIONS, PERIOD_OPTIONS, ALLOWED_PATHS };
|
||||||
|
}
|
||||||
107
src/content/fusion-page.js
Normal file
107
src/content/fusion-page.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
function createContentFusionPage({
|
||||||
|
ADD_ICON_SELECTOR,
|
||||||
|
ALLOWED_PATHS,
|
||||||
|
SALES_GROUP_SELECTOR,
|
||||||
|
TILE_ID,
|
||||||
|
TILE_LABEL_ID,
|
||||||
|
appendPath,
|
||||||
|
openOpportunitiesModal,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { Node, document, window } = environment;
|
||||||
|
function createOpportunitiesTile() {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "flat-grid-cell";
|
||||||
|
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.id = TILE_ID;
|
||||||
|
item.className = "app-nav-item opportunities-extension-tile";
|
||||||
|
item.setAttribute("filmstrip", "Opportunities Extension");
|
||||||
|
item.setAttribute("page", "undefined");
|
||||||
|
item.setAttribute("index", "0");
|
||||||
|
item.setAttribute("type", "subcluster");
|
||||||
|
item.setAttribute("title", "Opportunities Extension");
|
||||||
|
item.setAttribute("group", "groupNode_tools");
|
||||||
|
item.setAttribute("destinationurl", "https://gxpap.oracle.com/ords/pgxpap/f?p=138");
|
||||||
|
item.setAttribute("targetframe", "_blank");
|
||||||
|
item.setAttribute("isdesturlexist", "true");
|
||||||
|
item.setAttribute("role", "presentation");
|
||||||
|
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
svg.setAttribute("viewBox", "0 0 48 48");
|
||||||
|
svg.setAttribute("style", "fill:currentColor");
|
||||||
|
svg.setAttribute("class", "svg-nav suiicon svg-bkgd09");
|
||||||
|
svg.setAttribute("data-icon", "navi_reportsearch");
|
||||||
|
svg.setAttribute("role", "presentation");
|
||||||
|
svg.setAttribute("focusable", "false");
|
||||||
|
|
||||||
|
appendPath(svg, "svg-shortcut", "M28 42.5l-3 2.7v-1.7c-.4 0-1.4 0-2.5.6-1.3 1-1.5 1.6-1.5 1.6s-.4-1.2.8-2.7c1.2-1.6 2.6-1.7 3.2-1.6v-1.6l3 2.7z");
|
||||||
|
appendPath(svg, "svg-cluster", "M28.5 41.3c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2zm-4 0c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2c-.7 0-1.2-.5-1.2-1.2s.5-1.2 1.2-1.2zm-4 0c.7 0 1.2.5 1.2 1.2s-.5 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2z");
|
||||||
|
appendPath(svg, "svg-icon15", "M16 31l-1.6-1-3.4 6.5s0 1 .5 1.4c.5.2 1.4-.4 1.4-.4l3-6.7z");
|
||||||
|
appendPath(svg, "svg-icon03", "M36 10H12c-.8 0-2 1.2-2 2v20c0 .4.2.8.5 1l2-3.6c-1-1.4-1.6-3-1.6-5 0-4.2 3.3-7.6 7.4-7.6H20V16h2v1.7c.7.4 1.3 1 1.8 1.5.6.5 1 1 1.3 1.8h4v7h-4c-.3.7-.8 1.4-1.4 2H35v2H20.3l-2 .2H18L17 34h19c.8 0 2-1.2 2-2V12c0-.8-1.2-2-2-2zm-23 4v-2h22v2H13zm22 14h-5V17h5v11z");
|
||||||
|
appendPath(svg, "svg-icon12", "M18.5 19c-3 0-5.5 2.5-5.5 5.5s2.5 5.5 5.5 5.5 5.5-2.5 5.5-5.5-2.5-5.5-5.5-5.5zm0 9c-2 0-3.5-1.6-3.5-3.5 0-2 1.6-3.5 3.5-3.5s3.5 1.6 3.5 3.5c0 2-1.6 3.5-3.5 3.5z");
|
||||||
|
appendPath(svg, "svg-outline", "M35 34.56H13a2.7 2.7 0 0 1-3-3V14a2.76 2.76 0 0 1 3-3h22a2.74 2.74 0 0 1 3 3v17.56a2.68 2.68 0 0 1-3 3zM16.98 22.32a4.72 4.72 0 1 0 4.73 4.73 4.72 4.72 0 0 0-4.73-4.73zM24 25h3.47v4.72H24V25zm5.78-4.66h4.69v9.38h-4.69v-9.38zM13.5 14.5h20.9v2.4H13.5v-2.4zm6.9 17.59l-.01-1.94zm-2.04-12.75l-5.35-.02zm2.13 12.67H35.7zm-.09-8.05L20.39 19z");
|
||||||
|
appendPath(svg, "svg-outline", "M16.98 31.72a4.68 4.68 0 1 0-4.75-4.67 4.74 4.74 0 0 0 4.75 4.67zm-1.44-.5l-3.6 6.83zM8 40");
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.id = TILE_LABEL_ID;
|
||||||
|
link.className = "app-nav-label flat-grid-nav-label";
|
||||||
|
link.href = "#";
|
||||||
|
link.textContent = "Opportunities Extension";
|
||||||
|
|
||||||
|
item.append(svg, link);
|
||||||
|
wrapper.append(item);
|
||||||
|
wrapper.addEventListener("click", handleTileClick);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTileClick(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
openOpportunitiesModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedPage() {
|
||||||
|
if (window.location.hostname !== "eeho.fa.us2.oraclecloud.com") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ALLOWED_PATHS.some((path) => {
|
||||||
|
return window.location.pathname === path || window.location.pathname.startsWith(`${path}/`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInsertionPoint(salesGroup) {
|
||||||
|
const addIconInsideGroup = salesGroup.querySelector(ADD_ICON_SELECTOR);
|
||||||
|
|
||||||
|
if (addIconInsideGroup) {
|
||||||
|
return addIconInsideGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = salesGroup.closest(".flat-grid, .flat-grid-container, [id*='yourapps'], [id*='groupNode']") || salesGroup.parentElement || document.body;
|
||||||
|
const addIconCells = Array.from(container.querySelectorAll(ADD_ICON_SELECTOR));
|
||||||
|
|
||||||
|
return addIconCells.find((cell) => {
|
||||||
|
return Boolean(salesGroup.compareDocumentPosition(cell) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTile() {
|
||||||
|
if (document.getElementById(TILE_ID)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const salesGroup = document.querySelector(SALES_GROUP_SELECTOR);
|
||||||
|
const addIconCell = salesGroup ? findInsertionPoint(salesGroup) : null;
|
||||||
|
|
||||||
|
if (!salesGroup || !addIconCell) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
addIconCell.before(createOpportunitiesTile());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { createOpportunitiesTile, handleTileClick, isAllowedPage, findInsertionPoint, insertTile };
|
||||||
|
}
|
||||||
74
src/content/icons.js
Normal file
74
src/content/icons.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
function createContentIcons({
|
||||||
|
appendPath,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document } = environment;
|
||||||
|
function createThemeIcon(iconName) {
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
|
||||||
|
svg.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
svg.setAttribute("focusable", "false");
|
||||||
|
|
||||||
|
if (iconName === "sun") {
|
||||||
|
path.setAttribute("d", "M12 3V1m0 22v-2m9-9h2M1 12h2m15.36 6.36 1.42 1.42M4.22 4.22l1.42 1.42m12.72 0 1.42-1.42M4.22 19.78l1.42-1.42M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z");
|
||||||
|
} else {
|
||||||
|
path.setAttribute("d", "M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
path.setAttribute("fill", "none");
|
||||||
|
path.setAttribute("stroke", "currentColor");
|
||||||
|
path.setAttribute("stroke-linecap", "round");
|
||||||
|
path.setAttribute("stroke-linejoin", "round");
|
||||||
|
path.setAttribute("stroke-width", "1.8");
|
||||||
|
svg.append(path);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSettingsIcon() {
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||||
|
const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||||
|
|
||||||
|
svg.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
svg.setAttribute("class", "opportunities-extension-settings-icon");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
svg.setAttribute("focusable", "false");
|
||||||
|
appendPath(svg, "", "M4 7h16M4 17h16");
|
||||||
|
|
||||||
|
[upperControl, lowerControl].forEach((control, index) => {
|
||||||
|
control.setAttribute("cx", index === 0 ? "9" : "15");
|
||||||
|
control.setAttribute("cy", index === 0 ? "7" : "17");
|
||||||
|
control.setAttribute("r", "2.5");
|
||||||
|
control.setAttribute("fill", "currentColor");
|
||||||
|
control.setAttribute("stroke", "currentColor");
|
||||||
|
control.setAttribute("stroke-width", "1.8");
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.querySelector("path").setAttribute("fill", "none");
|
||||||
|
svg.querySelector("path").setAttribute("stroke", "currentColor");
|
||||||
|
svg.querySelector("path").setAttribute("stroke-linecap", "round");
|
||||||
|
svg.querySelector("path").setAttribute("stroke-width", "1.8");
|
||||||
|
svg.append(upperControl, lowerControl);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCloseIcon() {
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
|
||||||
|
svg.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
svg.setAttribute("focusable", "false");
|
||||||
|
path.setAttribute("d", "m6 6 12 12M18 6 6 18");
|
||||||
|
path.setAttribute("fill", "none");
|
||||||
|
path.setAttribute("stroke", "currentColor");
|
||||||
|
path.setAttribute("stroke-linecap", "round");
|
||||||
|
path.setAttribute("stroke-width", "1.8");
|
||||||
|
svg.append(path);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { createThemeIcon, createSettingsIcon, createCloseIcon };
|
||||||
|
}
|
||||||
9
src/content/main.js
Normal file
9
src/content/main.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** Preserve the existing document_idle initialization and observer behavior. */
|
||||||
|
function startContentApplication({ environment, page, styles, products }) {
|
||||||
|
if (!page.isAllowedPage()) return;
|
||||||
|
styles.ensureExtensionStyles();
|
||||||
|
products.ensureProductsBackgroundMessageHandler();
|
||||||
|
if (page.insertTile()) return;
|
||||||
|
const observer = new environment.MutationObserver(() => page.insertTile());
|
||||||
|
observer.observe(environment.document.documentElement, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
521
src/content/modal.js
Normal file
521
src/content/modal.js
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
function createContentModal({
|
||||||
|
ACCOUNT_PLANS_MODAL_ID,
|
||||||
|
AUTH_STATUS,
|
||||||
|
CUSTOMER_FILTER_BUTTON_ID,
|
||||||
|
CUSTOMER_FILTER_LIST_ID,
|
||||||
|
CUSTOMER_FILTER_PANEL_ID,
|
||||||
|
CUSTOMER_FILTER_SEARCH_ID,
|
||||||
|
CUSTOMER_FILTER_SELECT_ALL_ID,
|
||||||
|
DEBUG_PANEL_ID,
|
||||||
|
ESCAPE_LISTENER_KEY,
|
||||||
|
MODAL_ID,
|
||||||
|
OPPORTUNITIES_TABLE_ID,
|
||||||
|
OWNER_FILTER_BUTTON_ID,
|
||||||
|
OWNER_FILTER_LIST_ID,
|
||||||
|
OWNER_FILTER_PANEL_ID,
|
||||||
|
OWNER_FILTER_SEARCH_ID,
|
||||||
|
OWNER_FILTER_SELECT_ALL_ID,
|
||||||
|
PERIOD_OPTIONS,
|
||||||
|
PREFERENCES_MODAL_ID,
|
||||||
|
PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||||
|
PRODUCTS_PROGRESS_ID,
|
||||||
|
STAGE_DASHBOARD_ID,
|
||||||
|
STAGE_FILTERS_ID,
|
||||||
|
STATUS_FILTERS_ID,
|
||||||
|
TABLE_SEARCH_ID,
|
||||||
|
applyTheme,
|
||||||
|
authSessionState,
|
||||||
|
closeAccountPlansModal,
|
||||||
|
closeCustomerFilterPanel,
|
||||||
|
closeOwnerFilterPanel,
|
||||||
|
closePreferencesModal,
|
||||||
|
createSettingsIcon,
|
||||||
|
debugState,
|
||||||
|
ensureExtensionStyles,
|
||||||
|
getCustomerOptions,
|
||||||
|
getOwnerOptions,
|
||||||
|
getSavedOpportunityTypeValues,
|
||||||
|
getSavedOpptyProductsCacheHours,
|
||||||
|
getSavedTheme,
|
||||||
|
openPreferencesModal,
|
||||||
|
opportunitiesFilterState,
|
||||||
|
opportunitiesQueryState,
|
||||||
|
preferencesState,
|
||||||
|
renderCustomerFilter,
|
||||||
|
renderCustomerFilterList,
|
||||||
|
renderOpportunitiesTable,
|
||||||
|
renderOwnerFilter,
|
||||||
|
renderOwnerFilterList,
|
||||||
|
renderStageFilterButtons,
|
||||||
|
renderStatusFilterButtons,
|
||||||
|
requestOpportunitiesForPeriod,
|
||||||
|
requestTokenRelayOnce,
|
||||||
|
resetCustomerAndOwnerFiltersForPeriod,
|
||||||
|
resetOpportunitiesTable,
|
||||||
|
restoreFilterPreferences,
|
||||||
|
saveFilterPreferences,
|
||||||
|
toggleCustomerFilterPanel,
|
||||||
|
toggleDebugPanel,
|
||||||
|
toggleOwnerFilterPanel,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document, window } = environment;
|
||||||
|
function openOpportunitiesModal() {
|
||||||
|
ensureExtensionStyles();
|
||||||
|
debugState.debugSecretsVisible = false;
|
||||||
|
|
||||||
|
const existingModal = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
|
if (existingModal) {
|
||||||
|
existingModal.hidden = false;
|
||||||
|
existingModal.querySelector("select").focus();
|
||||||
|
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||||
|
ensureEscapeKeyHandler();
|
||||||
|
requestTokenRelayOnce().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
debugState.requestLog = [];
|
||||||
|
authSessionState.tokenRelayRequest = null;
|
||||||
|
authSessionState.consumerTokenServiceRequest = null;
|
||||||
|
authSessionState.accessToken = "";
|
||||||
|
opportunitiesQueryState.periodRequestVersion = 0;
|
||||||
|
authSessionState.authStatus = AUTH_STATUS.idle;
|
||||||
|
resetOpportunitiesTable();
|
||||||
|
preferencesState.selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
|
||||||
|
preferencesState.opptyProductsCacheHours = getSavedOpptyProductsCacheHours();
|
||||||
|
overlay.id = MODAL_ID;
|
||||||
|
overlay.className = "opportunities-extension-modal";
|
||||||
|
overlay.setAttribute("data-theme", getSavedTheme());
|
||||||
|
overlay.setAttribute("role", "dialog");
|
||||||
|
overlay.setAttribute("aria-modal", "true");
|
||||||
|
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
|
||||||
|
overlay.setAttribute("aria-describedby", "opportunities-extension-subtitle");
|
||||||
|
|
||||||
|
const shell = document.createElement("section");
|
||||||
|
shell.className = "opportunities-extension-shell";
|
||||||
|
|
||||||
|
const header = document.createElement("header");
|
||||||
|
header.className = "opportunities-extension-header";
|
||||||
|
|
||||||
|
const title = document.createElement("h1");
|
||||||
|
title.id = "opportunities-extension-title";
|
||||||
|
title.textContent = "Opportunities Extension";
|
||||||
|
|
||||||
|
const subtitle = document.createElement("p");
|
||||||
|
subtitle.id = "opportunities-extension-subtitle";
|
||||||
|
subtitle.textContent = "Lista de oportunidades do HCM Opportunities List";
|
||||||
|
|
||||||
|
const authBadge = document.createElement("span");
|
||||||
|
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
|
||||||
|
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
|
||||||
|
authBadge.textContent = "Authenticating";
|
||||||
|
|
||||||
|
const subtitleRow = document.createElement("div");
|
||||||
|
subtitleRow.className = "opportunities-extension-subtitle-row";
|
||||||
|
|
||||||
|
const productsProgressBadge = document.createElement("span");
|
||||||
|
productsProgressBadge.id = PRODUCTS_PROGRESS_ID;
|
||||||
|
productsProgressBadge.className = "opportunities-extension-products-progress";
|
||||||
|
productsProgressBadge.setAttribute("role", "status");
|
||||||
|
productsProgressBadge.hidden = true;
|
||||||
|
|
||||||
|
const productsCacheTimestamp = document.createElement("span");
|
||||||
|
productsCacheTimestamp.id = PRODUCTS_CACHE_TIMESTAMP_ID;
|
||||||
|
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
||||||
|
productsCacheTimestamp.setAttribute("role", "status");
|
||||||
|
productsCacheTimestamp.hidden = true;
|
||||||
|
subtitleRow.append(subtitle, authBadge, productsProgressBadge, productsCacheTimestamp);
|
||||||
|
|
||||||
|
const titleBlock = document.createElement("div");
|
||||||
|
titleBlock.append(title, subtitleRow);
|
||||||
|
|
||||||
|
const closeButton = document.createElement("button");
|
||||||
|
closeButton.type = "button";
|
||||||
|
closeButton.className = "opportunities-extension-icon-button";
|
||||||
|
closeButton.setAttribute("aria-label", "Fechar");
|
||||||
|
closeButton.textContent = "X";
|
||||||
|
closeButton.addEventListener("click", closeOpportunitiesModal);
|
||||||
|
|
||||||
|
const themeButton = document.createElement("button");
|
||||||
|
themeButton.type = "button";
|
||||||
|
themeButton.className = "opportunities-extension-icon-button opportunities-extension-theme-toggle";
|
||||||
|
themeButton.addEventListener("click", () => {
|
||||||
|
const nextTheme = overlay.getAttribute("data-theme") === "dark" ? "light" : "dark";
|
||||||
|
applyTheme(overlay, themeButton, nextTheme);
|
||||||
|
});
|
||||||
|
applyTheme(overlay, themeButton, overlay.getAttribute("data-theme"));
|
||||||
|
|
||||||
|
const preferencesButton = document.createElement("button");
|
||||||
|
preferencesButton.type = "button";
|
||||||
|
preferencesButton.className = "opportunities-extension-icon-button";
|
||||||
|
preferencesButton.setAttribute("aria-label", "Abrir preferências");
|
||||||
|
preferencesButton.title = "Preferences";
|
||||||
|
preferencesButton.append(createSettingsIcon());
|
||||||
|
preferencesButton.addEventListener("click", openPreferencesModal);
|
||||||
|
|
||||||
|
const headerActions = document.createElement("div");
|
||||||
|
headerActions.className = "opportunities-extension-header-actions";
|
||||||
|
headerActions.append(themeButton, preferencesButton, closeButton);
|
||||||
|
|
||||||
|
header.append(titleBlock, headerActions);
|
||||||
|
|
||||||
|
const body = document.createElement("main");
|
||||||
|
body.className = "opportunities-extension-body";
|
||||||
|
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.className = "opportunities-extension-form";
|
||||||
|
|
||||||
|
const field = document.createElement("label");
|
||||||
|
field.className = "opportunities-extension-field";
|
||||||
|
|
||||||
|
const labelText = document.createElement("span");
|
||||||
|
labelText.textContent = "Reference";
|
||||||
|
|
||||||
|
const selectWrap = document.createElement("span");
|
||||||
|
selectWrap.className = "opportunities-extension-select-wrap";
|
||||||
|
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.name = "opportunityPeriod";
|
||||||
|
|
||||||
|
PERIOD_OPTIONS.forEach((optionLabel) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = optionLabel;
|
||||||
|
option.textContent = optionLabel;
|
||||||
|
select.append(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
select.addEventListener("change", () => {
|
||||||
|
resetCustomerAndOwnerFiltersForPeriod();
|
||||||
|
saveFilterPreferences(select.value);
|
||||||
|
requestOpportunitiesForPeriod(select.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
selectWrap.append(select);
|
||||||
|
field.append(labelText, selectWrap);
|
||||||
|
restoreFilterPreferences(select);
|
||||||
|
|
||||||
|
const stageFilter = document.createElement("section");
|
||||||
|
stageFilter.className = "opportunities-extension-stage-filter";
|
||||||
|
|
||||||
|
const stageFilterLabel = document.createElement("span");
|
||||||
|
stageFilterLabel.className = "opportunities-extension-stage-filter-label";
|
||||||
|
stageFilterLabel.textContent = "Stage";
|
||||||
|
|
||||||
|
const stageFilters = document.createElement("div");
|
||||||
|
stageFilters.id = STAGE_FILTERS_ID;
|
||||||
|
stageFilters.className = "opportunities-extension-stage-filter-controls";
|
||||||
|
stageFilters.setAttribute("role", "group");
|
||||||
|
stageFilters.setAttribute("aria-label", "Filter by stage");
|
||||||
|
|
||||||
|
stageFilter.append(stageFilterLabel, stageFilters);
|
||||||
|
|
||||||
|
const customerFilter = document.createElement("section");
|
||||||
|
customerFilter.className = "opportunities-extension-customer-filter";
|
||||||
|
|
||||||
|
const customerFilterLabel = document.createElement("span");
|
||||||
|
customerFilterLabel.className = "opportunities-extension-customer-filter-label";
|
||||||
|
customerFilterLabel.textContent = "Customer";
|
||||||
|
|
||||||
|
const customerFilterButton = document.createElement("button");
|
||||||
|
customerFilterButton.id = CUSTOMER_FILTER_BUTTON_ID;
|
||||||
|
customerFilterButton.type = "button";
|
||||||
|
customerFilterButton.className = "opportunities-extension-customer-filter-trigger";
|
||||||
|
customerFilterButton.setAttribute("aria-expanded", "false");
|
||||||
|
customerFilterButton.setAttribute("aria-controls", CUSTOMER_FILTER_PANEL_ID);
|
||||||
|
customerFilterButton.addEventListener("click", toggleCustomerFilterPanel);
|
||||||
|
|
||||||
|
const customerFilterPanel = document.createElement("section");
|
||||||
|
customerFilterPanel.id = CUSTOMER_FILTER_PANEL_ID;
|
||||||
|
customerFilterPanel.className = "opportunities-extension-customer-filter-panel";
|
||||||
|
customerFilterPanel.hidden = true;
|
||||||
|
|
||||||
|
const customerSearchInput = document.createElement("input");
|
||||||
|
customerSearchInput.id = CUSTOMER_FILTER_SEARCH_ID;
|
||||||
|
customerSearchInput.className = "opportunities-extension-customer-filter-search";
|
||||||
|
customerSearchInput.type = "search";
|
||||||
|
customerSearchInput.placeholder = "Search customers";
|
||||||
|
customerSearchInput.setAttribute("aria-label", "Search customers");
|
||||||
|
customerSearchInput.addEventListener("input", () => {
|
||||||
|
opportunitiesFilterState.customerSearch = customerSearchInput.value;
|
||||||
|
saveFilterPreferences();
|
||||||
|
renderCustomerFilterList();
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectAllLabel = document.createElement("label");
|
||||||
|
selectAllLabel.className = "opportunities-extension-customer-filter-select-all";
|
||||||
|
|
||||||
|
const selectAllCheckbox = document.createElement("input");
|
||||||
|
selectAllCheckbox.id = CUSTOMER_FILTER_SELECT_ALL_ID;
|
||||||
|
selectAllCheckbox.type = "checkbox";
|
||||||
|
selectAllCheckbox.addEventListener("change", () => {
|
||||||
|
const customerOptions = getCustomerOptions();
|
||||||
|
opportunitiesFilterState.selectedCustomerKeys = selectAllCheckbox.checked
|
||||||
|
? new Set(customerOptions.map((customer) => customer.key))
|
||||||
|
: new Set();
|
||||||
|
opportunitiesFilterState.hasSavedCustomerFilter = true;
|
||||||
|
saveFilterPreferences();
|
||||||
|
renderCustomerFilter();
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectAllText = document.createElement("span");
|
||||||
|
selectAllText.textContent = "Select all";
|
||||||
|
selectAllLabel.append(selectAllCheckbox, selectAllText);
|
||||||
|
|
||||||
|
const customerFilterList = document.createElement("div");
|
||||||
|
customerFilterList.id = CUSTOMER_FILTER_LIST_ID;
|
||||||
|
customerFilterList.className = "opportunities-extension-customer-filter-list";
|
||||||
|
|
||||||
|
customerFilterPanel.append(customerSearchInput, selectAllLabel, customerFilterList);
|
||||||
|
customerFilter.append(customerFilterLabel, customerFilterButton, customerFilterPanel);
|
||||||
|
|
||||||
|
const ownerFilter = document.createElement("section");
|
||||||
|
ownerFilter.className = "opportunities-extension-customer-filter";
|
||||||
|
|
||||||
|
const ownerFilterLabel = document.createElement("span");
|
||||||
|
ownerFilterLabel.className = "opportunities-extension-customer-filter-label";
|
||||||
|
ownerFilterLabel.textContent = "Owner";
|
||||||
|
|
||||||
|
const ownerFilterButton = document.createElement("button");
|
||||||
|
ownerFilterButton.id = OWNER_FILTER_BUTTON_ID;
|
||||||
|
ownerFilterButton.type = "button";
|
||||||
|
ownerFilterButton.className = "opportunities-extension-customer-filter-trigger";
|
||||||
|
ownerFilterButton.setAttribute("aria-expanded", "false");
|
||||||
|
ownerFilterButton.setAttribute("aria-controls", OWNER_FILTER_PANEL_ID);
|
||||||
|
ownerFilterButton.addEventListener("click", toggleOwnerFilterPanel);
|
||||||
|
|
||||||
|
const ownerFilterPanel = document.createElement("section");
|
||||||
|
ownerFilterPanel.id = OWNER_FILTER_PANEL_ID;
|
||||||
|
ownerFilterPanel.className = "opportunities-extension-customer-filter-panel";
|
||||||
|
ownerFilterPanel.hidden = true;
|
||||||
|
|
||||||
|
const ownerSearchInput = document.createElement("input");
|
||||||
|
ownerSearchInput.id = OWNER_FILTER_SEARCH_ID;
|
||||||
|
ownerSearchInput.className = "opportunities-extension-customer-filter-search";
|
||||||
|
ownerSearchInput.type = "search";
|
||||||
|
ownerSearchInput.placeholder = "Search owners";
|
||||||
|
ownerSearchInput.setAttribute("aria-label", "Search owners");
|
||||||
|
ownerSearchInput.addEventListener("input", () => {
|
||||||
|
opportunitiesFilterState.ownerSearch = ownerSearchInput.value;
|
||||||
|
saveFilterPreferences();
|
||||||
|
renderOwnerFilterList();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerSelectAllLabel = document.createElement("label");
|
||||||
|
ownerSelectAllLabel.className = "opportunities-extension-customer-filter-select-all";
|
||||||
|
|
||||||
|
const ownerSelectAllCheckbox = document.createElement("input");
|
||||||
|
ownerSelectAllCheckbox.id = OWNER_FILTER_SELECT_ALL_ID;
|
||||||
|
ownerSelectAllCheckbox.type = "checkbox";
|
||||||
|
ownerSelectAllCheckbox.addEventListener("change", () => {
|
||||||
|
const ownerOptions = getOwnerOptions();
|
||||||
|
opportunitiesFilterState.selectedOwnerKeys = ownerSelectAllCheckbox.checked
|
||||||
|
? new Set(ownerOptions.map((owner) => owner.key))
|
||||||
|
: new Set();
|
||||||
|
opportunitiesFilterState.hasSavedOwnerFilter = true;
|
||||||
|
saveFilterPreferences();
|
||||||
|
renderOwnerFilter();
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerSelectAllText = document.createElement("span");
|
||||||
|
ownerSelectAllText.textContent = "Select all";
|
||||||
|
ownerSelectAllLabel.append(ownerSelectAllCheckbox, ownerSelectAllText);
|
||||||
|
|
||||||
|
const ownerFilterList = document.createElement("div");
|
||||||
|
ownerFilterList.id = OWNER_FILTER_LIST_ID;
|
||||||
|
ownerFilterList.className = "opportunities-extension-customer-filter-list";
|
||||||
|
|
||||||
|
ownerFilterPanel.append(ownerSearchInput, ownerSelectAllLabel, ownerFilterList);
|
||||||
|
ownerFilter.append(ownerFilterLabel, ownerFilterButton, ownerFilterPanel);
|
||||||
|
|
||||||
|
const tableSearchField = document.createElement("label");
|
||||||
|
tableSearchField.className = "opportunities-extension-table-search-field";
|
||||||
|
|
||||||
|
const tableSearchLabel = document.createElement("span");
|
||||||
|
tableSearchLabel.textContent = "Search";
|
||||||
|
|
||||||
|
const tableSearchInput = document.createElement("input");
|
||||||
|
tableSearchInput.id = TABLE_SEARCH_ID;
|
||||||
|
tableSearchInput.type = "search";
|
||||||
|
tableSearchInput.placeholder = "Search opportunities";
|
||||||
|
tableSearchInput.setAttribute("aria-label", "Search opportunities");
|
||||||
|
tableSearchInput.value = opportunitiesFilterState.tableSearch;
|
||||||
|
tableSearchInput.addEventListener("input", () => {
|
||||||
|
opportunitiesFilterState.tableSearch = tableSearchInput.value;
|
||||||
|
saveFilterPreferences();
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
tableSearchField.append(tableSearchLabel, tableSearchInput);
|
||||||
|
|
||||||
|
const statusFilter = document.createElement("section");
|
||||||
|
statusFilter.className = "opportunities-extension-status-filter";
|
||||||
|
|
||||||
|
const statusFilterLabel = document.createElement("span");
|
||||||
|
statusFilterLabel.className = "opportunities-extension-status-filter-label";
|
||||||
|
statusFilterLabel.textContent = "Status";
|
||||||
|
|
||||||
|
const statusFilters = document.createElement("div");
|
||||||
|
statusFilters.id = STATUS_FILTERS_ID;
|
||||||
|
statusFilters.className = "opportunities-extension-status-filter-controls";
|
||||||
|
statusFilters.setAttribute("role", "group");
|
||||||
|
statusFilters.setAttribute("aria-label", "Filter by status");
|
||||||
|
|
||||||
|
statusFilter.append(statusFilterLabel, statusFilters);
|
||||||
|
form.addEventListener("submit", (event) => event.preventDefault());
|
||||||
|
form.append(field, customerFilter, ownerFilter, tableSearchField, statusFilter, stageFilter);
|
||||||
|
body.append(form);
|
||||||
|
|
||||||
|
const stageDashboard = document.createElement("section");
|
||||||
|
stageDashboard.id = STAGE_DASHBOARD_ID;
|
||||||
|
stageDashboard.className = "opportunities-extension-stage-dashboard";
|
||||||
|
stageDashboard.setAttribute("aria-label", "Opportunity amount by stage");
|
||||||
|
body.append(stageDashboard);
|
||||||
|
|
||||||
|
const tableSurface = document.createElement("section");
|
||||||
|
tableSurface.className = "opportunities-extension-table-surface";
|
||||||
|
|
||||||
|
const opportunitiesTable = document.createElement("table");
|
||||||
|
opportunitiesTable.id = OPPORTUNITIES_TABLE_ID;
|
||||||
|
opportunitiesTable.className = "opportunities-extension-table";
|
||||||
|
opportunitiesTable.setAttribute("aria-label", "Opportunities");
|
||||||
|
tableSurface.append(opportunitiesTable);
|
||||||
|
|
||||||
|
body.append(tableSurface);
|
||||||
|
|
||||||
|
const debugPanel = document.createElement("aside");
|
||||||
|
debugPanel.id = DEBUG_PANEL_ID;
|
||||||
|
debugPanel.className = "opportunities-extension-debug-panel";
|
||||||
|
debugPanel.hidden = true;
|
||||||
|
body.append(debugPanel);
|
||||||
|
|
||||||
|
shell.append(header, body);
|
||||||
|
overlay.append(shell);
|
||||||
|
document.body.append(overlay);
|
||||||
|
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||||
|
ensureEscapeKeyHandler();
|
||||||
|
renderStageFilterButtons();
|
||||||
|
renderStatusFilterButtons();
|
||||||
|
renderCustomerFilter();
|
||||||
|
renderOwnerFilter();
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
requestOpportunitiesForPeriod(select.value);
|
||||||
|
select.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAuthBadge(status) {
|
||||||
|
const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`);
|
||||||
|
|
||||||
|
if (!badge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeStatus = status === AUTH_STATUS.authenticated || status === AUTH_STATUS.unauthenticated
|
||||||
|
? status
|
||||||
|
: AUTH_STATUS.pending;
|
||||||
|
|
||||||
|
badge.setAttribute("data-auth-status", badgeStatus);
|
||||||
|
badge.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`;
|
||||||
|
badge.textContent = badgeStatus === AUTH_STATUS.authenticated
|
||||||
|
? "Authenticated"
|
||||||
|
: badgeStatus === AUTH_STATUS.unauthenticated
|
||||||
|
? "Unauthenticated"
|
||||||
|
: "Authenticating";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOpportunitiesModal() {
|
||||||
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
closePreferencesModal();
|
||||||
|
closeAccountPlansModal();
|
||||||
|
modal.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugState.debugSecretsVisible = false;
|
||||||
|
document.documentElement.classList.remove("opportunities-extension-scroll-lock");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEscapeKeyHandler() {
|
||||||
|
if (window[ESCAPE_LISTENER_KEY]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window[ESCAPE_LISTENER_KEY] = true;
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
|
if (!modal || modal.hidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.ctrlKey && event.key.toLowerCase() === "d") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleDebugPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
||||||
|
|
||||||
|
if (accountPlansModal && !accountPlansModal.hidden) {
|
||||||
|
closeAccountPlansModal();
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID);
|
||||||
|
|
||||||
|
if (preferencesModal && !preferencesModal.hidden) {
|
||||||
|
closePreferencesModal();
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closeCustomerFilterPanel()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closeOwnerFilterPanel()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeOpportunitiesModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", (event) => {
|
||||||
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
|
||||||
|
const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID);
|
||||||
|
|
||||||
|
if (!modal || modal.hidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panel && !panel.hidden) {
|
||||||
|
const customerFilter = panel.closest(".opportunities-extension-customer-filter");
|
||||||
|
|
||||||
|
if (customerFilter && !customerFilter.contains(event.target)) {
|
||||||
|
closeCustomerFilterPanel(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ownerPanel && !ownerPanel.hidden) {
|
||||||
|
const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter");
|
||||||
|
|
||||||
|
if (ownerFilter && !ownerFilter.contains(event.target)) {
|
||||||
|
closeOwnerFilterPanel(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler };
|
||||||
|
}
|
||||||
20
src/content/styles.js
Normal file
20
src/content/styles.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
function createContentStyles({
|
||||||
|
STYLE_ID,
|
||||||
|
redwoodStyles,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document } = environment;
|
||||||
|
function ensureExtensionStyles() {
|
||||||
|
if (document.getElementById(STYLE_ID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent = redwoodStyles;
|
||||||
|
|
||||||
|
document.head.append(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ensureExtensionStyles };
|
||||||
|
}
|
||||||
3126
src/content/styles/redwood.css
Normal file
3126
src/content/styles/redwood.css
Normal file
File diff suppressed because it is too large
Load Diff
10
src/content/svg.js
Normal file
10
src/content/svg.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
function createSvgPaths({ environment }) {
|
||||||
|
const { document } = environment;
|
||||||
|
function appendPath(svg, className, d) {
|
||||||
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
path.setAttribute("class", className);
|
||||||
|
path.setAttribute("d", d);
|
||||||
|
svg.append(path);
|
||||||
|
}
|
||||||
|
return { appendPath };
|
||||||
|
}
|
||||||
32
src/content/theme.js
Normal file
32
src/content/theme.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
function createContentTheme({
|
||||||
|
THEME_STORAGE_KEY,
|
||||||
|
createThemeIcon,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const getLocalStorage = () => environment.localStorage;
|
||||||
|
function getSavedTheme() {
|
||||||
|
try {
|
||||||
|
return getLocalStorage().getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light";
|
||||||
|
} catch (error) {
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(modal, button, theme) {
|
||||||
|
const isDarkTheme = theme === "dark";
|
||||||
|
|
||||||
|
modal.setAttribute("data-theme", isDarkTheme ? "dark" : "light");
|
||||||
|
button.textContent = "";
|
||||||
|
button.setAttribute("aria-label", isDarkTheme ? "Ativar modo claro" : "Ativar modo escuro");
|
||||||
|
button.title = isDarkTheme ? "Light mode" : "Dark mode";
|
||||||
|
button.append(createThemeIcon(isDarkTheme ? "sun" : "moon"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
getLocalStorage().setItem(THEME_STORAGE_KEY, isDarkTheme ? "dark" : "light");
|
||||||
|
} catch (error) {
|
||||||
|
// Continue with the selected theme when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getSavedTheme, applyTheme };
|
||||||
|
}
|
||||||
103
src/features/ai-summary/content.js
Normal file
103
src/features/ai-summary/content.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
function createAiSummaryContent({
|
||||||
|
aiSummaryState,
|
||||||
|
invalidateSalesCommonToken,
|
||||||
|
logRequestFailure,
|
||||||
|
logRequestStart,
|
||||||
|
logRequestSuccess,
|
||||||
|
readResponseBodyForDebug,
|
||||||
|
requestTokenRelaySalesCommon,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document, fetch } = environment;
|
||||||
|
function requestGenerateAiSummary(optyNumber) {
|
||||||
|
const key = String(optyNumber);
|
||||||
|
const existing = aiSummaryState.aiSummariesByOptyNumber.get(key);
|
||||||
|
if (existing && existing.status === "loading") {
|
||||||
|
return existing.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = { status: "loading", summaryText: "", error: "", promise: null };
|
||||||
|
aiSummaryState.aiSummariesByOptyNumber.set(key, state);
|
||||||
|
state.promise = (async () => {
|
||||||
|
let entry;
|
||||||
|
try {
|
||||||
|
let token = await requestTokenRelaySalesCommon();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("Authentication is required to generate the summary.");
|
||||||
|
}
|
||||||
|
const path = "/api/sales-common/gen-ai/v1/summarizations/generate";
|
||||||
|
const url = `https://eeho.fa.us2.oraclecloud.com${path}`;
|
||||||
|
const payload = { path, promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: key };
|
||||||
|
let response;
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
const headers = { Accept: "application/json", Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
||||||
|
entry = logRequestStart({
|
||||||
|
name: "requestGenerateAiSummary", method: "POST", url, headers,
|
||||||
|
body: JSON.stringify(payload, null, 2), metadata: { optyNumber: key, attempt: attempt + 1 }
|
||||||
|
});
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
||||||
|
if (response.status !== 401 && response.status !== 403) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// A late failure for an old token must not discard a newly refreshed token.
|
||||||
|
invalidateSalesCommonToken(token);
|
||||||
|
if (attempt === 0) {
|
||||||
|
token = await requestTokenRelaySalesCommon();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`requestGenerateAiSummary failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("requestGenerateAiSummary returned an invalid JSON response.");
|
||||||
|
}
|
||||||
|
if (!data || typeof data.summaryText !== "string" || !data.summaryText.trim()) {
|
||||||
|
throw new Error("requestGenerateAiSummary returned an empty summary.");
|
||||||
|
}
|
||||||
|
state.summaryText = data.summaryText;
|
||||||
|
state.status = "ready";
|
||||||
|
} catch (error) {
|
||||||
|
state.status = "error";
|
||||||
|
state.error = error.message || "Unable to generate the AI summary.";
|
||||||
|
if (entry) {
|
||||||
|
logRequestFailure(entry, state.error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Update only visible summaries, without reopening rows or rebuilding the table.
|
||||||
|
document.querySelectorAll(".opportunities-extension-ai-summary").forEach((alert) => {
|
||||||
|
if (alert.dataset.optyNumber === key) {
|
||||||
|
alert.replaceWith(createAiSummaryAlert(key));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return state.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAiSummaryAlert(optyNumber) {
|
||||||
|
const state = aiSummaryState.aiSummariesByOptyNumber.get(optyNumber);
|
||||||
|
const alert = document.createElement("div");
|
||||||
|
alert.className = "opportunities-extension-ai-summary";
|
||||||
|
alert.dataset.optyNumber = optyNumber;
|
||||||
|
alert.setAttribute("role", "status");
|
||||||
|
alert.setAttribute("aria-live", "polite");
|
||||||
|
alert.setAttribute("aria-busy", String(!state || state.status === "loading"));
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = "Summary generated by AI";
|
||||||
|
const description = document.createElement("p");
|
||||||
|
description.textContent = !state || state.status === "loading"
|
||||||
|
? "Generating summary..."
|
||||||
|
: state.status === "error" ? state.error : state.summaryText;
|
||||||
|
alert.classList.toggle("opportunities-extension-ai-summary-error", Boolean(state && state.status === "error"));
|
||||||
|
alert.append(title, description);
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestGenerateAiSummary, createAiSummaryAlert };
|
||||||
|
}
|
||||||
5
src/features/ai-summary/state.js
Normal file
5
src/features/ai-summary/state.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
function createAiSummaryState() {
|
||||||
|
return {
|
||||||
|
aiSummariesByOptyNumber: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
383
src/features/consumption/chart.js
Normal file
383
src/features/consumption/chart.js
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
function createConsumptionChart({
|
||||||
|
CONSUMPTION_TREND_PERIODS,
|
||||||
|
CONSUMPTION_TREND_SERIES,
|
||||||
|
SVG_NAMESPACE,
|
||||||
|
calculateAverageConsumption,
|
||||||
|
consumptionTrendState,
|
||||||
|
createConsumptionTrendKey,
|
||||||
|
createNiceConsumptionMaximum,
|
||||||
|
formatCompactConsumptionNumber,
|
||||||
|
formatConsumptionCurrency,
|
||||||
|
formatConsumptionPointLabel,
|
||||||
|
formatConsumptionRange,
|
||||||
|
normalizeConsumptionTrendItems,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document, window } = environment;
|
||||||
|
function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) {
|
||||||
|
chart.textContent = "";
|
||||||
|
chart.setAttribute("aria-busy", "false");
|
||||||
|
const points = normalizeConsumptionTrendItems(items);
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "opportunities-extension-consumption-chart-empty";
|
||||||
|
empty.textContent = "No consumption data found for this period.";
|
||||||
|
chart.append(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = CONSUMPTION_TREND_PERIODS.find((item) => item.value === period);
|
||||||
|
const windowSize = option ? option.windowSize : points.length;
|
||||||
|
const offsetKey = createConsumptionTrendKey(subPlanNum, period);
|
||||||
|
const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1);
|
||||||
|
const offset = Math.min(consumptionTrendState.consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset);
|
||||||
|
const end = Math.max(0, points.length - (offset * windowSize));
|
||||||
|
const start = Math.max(0, end - windowSize);
|
||||||
|
const visiblePoints = points.slice(start, end);
|
||||||
|
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, offset);
|
||||||
|
|
||||||
|
const navigation = document.createElement("div");
|
||||||
|
navigation.className = "opportunities-extension-consumption-navigation";
|
||||||
|
const olderButton = createConsumptionNavigationButton("older", start === 0);
|
||||||
|
const newerButton = createConsumptionNavigationButton("newer", end === points.length);
|
||||||
|
const range = document.createElement("strong");
|
||||||
|
range.textContent = formatConsumptionRange(visiblePoints, period);
|
||||||
|
olderButton.addEventListener("click", () => {
|
||||||
|
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1));
|
||||||
|
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
||||||
|
});
|
||||||
|
newerButton.addEventListener("click", () => {
|
||||||
|
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1));
|
||||||
|
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
||||||
|
});
|
||||||
|
navigation.append(olderButton, range, newerButton);
|
||||||
|
|
||||||
|
const figure = document.createElement("figure");
|
||||||
|
figure.className = "opportunities-extension-consumption-figure";
|
||||||
|
const visual = document.createElement("div");
|
||||||
|
visual.className = "opportunities-extension-consumption-visual";
|
||||||
|
const tooltip = document.createElement("div");
|
||||||
|
tooltip.className = "opportunities-extension-consumption-tooltip";
|
||||||
|
tooltip.hidden = true;
|
||||||
|
tooltip.setAttribute("role", "tooltip");
|
||||||
|
const activeSeries = getConsumptionTrendSeriesFilter(subPlanNum);
|
||||||
|
const svg = createConsumptionSvg(
|
||||||
|
visiblePoints,
|
||||||
|
period,
|
||||||
|
end === points.length,
|
||||||
|
activeSeries,
|
||||||
|
tooltip
|
||||||
|
);
|
||||||
|
const legend = createConsumptionLegend(
|
||||||
|
section,
|
||||||
|
chart,
|
||||||
|
items,
|
||||||
|
period,
|
||||||
|
subPlanNum,
|
||||||
|
activeSeries
|
||||||
|
);
|
||||||
|
visual.append(svg, legend);
|
||||||
|
figure.append(visual, tooltip);
|
||||||
|
chart.append(navigation, figure);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConsumptionTrendSeriesFilter(subPlanNum) {
|
||||||
|
if (!consumptionTrendState.consumptionTrendSeriesFilters.has(subPlanNum)) {
|
||||||
|
consumptionTrendState.consumptionTrendSeriesFilters.set(
|
||||||
|
subPlanNum,
|
||||||
|
new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return consumptionTrendState.consumptionTrendSeriesFilters.get(subPlanNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) {
|
||||||
|
const legend = document.createElement("div");
|
||||||
|
legend.className = "opportunities-extension-consumption-legend";
|
||||||
|
legend.setAttribute("role", "group");
|
||||||
|
legend.setAttribute("aria-label", "Chart series filters");
|
||||||
|
|
||||||
|
CONSUMPTION_TREND_SERIES.forEach((series) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
const swatch = document.createElement("span");
|
||||||
|
const label = document.createElement("span");
|
||||||
|
button.type = "button";
|
||||||
|
button.dataset.series = series.value;
|
||||||
|
button.setAttribute("aria-pressed", String(activeSeries.has(series.value)));
|
||||||
|
button.setAttribute("aria-label", `${activeSeries.has(series.value) ? "Hide" : "Show"} ${series.label}`);
|
||||||
|
swatch.className = `opportunities-extension-consumption-legend-swatch opportunities-extension-consumption-legend-${series.value}`;
|
||||||
|
swatch.setAttribute("aria-hidden", "true");
|
||||||
|
label.textContent = series.label;
|
||||||
|
button.append(swatch, label);
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (activeSeries.has(series.value)) {
|
||||||
|
activeSeries.delete(series.value);
|
||||||
|
} else {
|
||||||
|
activeSeries.add(series.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
||||||
|
chart.querySelector(`[data-series="${series.value}"]`)?.focus();
|
||||||
|
});
|
||||||
|
legend.append(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
return legend;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConsumptionNavigationButton(direction, disabled) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-consumption-navigation-button";
|
||||||
|
button.disabled = disabled;
|
||||||
|
button.setAttribute("aria-label", direction === "older" ? "Show older consumption data" : "Show newer consumption data");
|
||||||
|
button.title = direction === "older" ? "Older" : "Newer";
|
||||||
|
const icon = createSvgElement("svg", {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
"aria-hidden": "true",
|
||||||
|
focusable: "false"
|
||||||
|
});
|
||||||
|
icon.append(createSvgElement("path", {
|
||||||
|
d: direction === "older" ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
"stroke-linecap": "round",
|
||||||
|
"stroke-linejoin": "round",
|
||||||
|
"stroke-width": "2"
|
||||||
|
}));
|
||||||
|
button.append(icon);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) {
|
||||||
|
const width = 1000;
|
||||||
|
const height = 320;
|
||||||
|
const plot = { left: 62, top: 24, right: 980, bottom: 258 };
|
||||||
|
const plotWidth = plot.right - plot.left;
|
||||||
|
const plotHeight = plot.bottom - plot.top;
|
||||||
|
const average = calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries);
|
||||||
|
const maximum = createNiceConsumptionMaximum(Math.max(average, ...points.map((point) => point.total)));
|
||||||
|
const svg = createSvgElement("svg", {
|
||||||
|
viewBox: `0 0 ${width} ${height}`,
|
||||||
|
role: "img",
|
||||||
|
"aria-label": `${period} cloud consumption chart`
|
||||||
|
});
|
||||||
|
svg.classList.add("opportunities-extension-consumption-svg");
|
||||||
|
|
||||||
|
for (let tick = 0; tick <= 5; tick += 1) {
|
||||||
|
const value = (maximum / 5) * tick;
|
||||||
|
const y = plot.bottom - ((value / maximum) * plotHeight);
|
||||||
|
svg.append(
|
||||||
|
createSvgElement("line", { x1: plot.left, y1: y, x2: plot.right, y2: y, class: "consumption-grid-line" }),
|
||||||
|
createSvgText(plot.left - 10, y + 4, formatCompactConsumptionNumber(value), "consumption-axis-label", "end")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = plotWidth / Math.max(points.length, 1);
|
||||||
|
const barWidth = Math.min(44, Math.max(10, step * 0.58));
|
||||||
|
|
||||||
|
points.forEach((point, index) => {
|
||||||
|
const x = plot.left + (step * index) + ((step - barWidth) / 2);
|
||||||
|
const fundedAmount = activeSeries.has("funded") ? point.funded : 0;
|
||||||
|
const overageAmount = activeSeries.has("overage") ? point.overage : 0;
|
||||||
|
const visibleTotal = fundedAmount + overageAmount;
|
||||||
|
const fundedHeight = (fundedAmount / maximum) * plotHeight;
|
||||||
|
const overageHeight = (overageAmount / maximum) * plotHeight;
|
||||||
|
const group = createSvgElement("g", {
|
||||||
|
class: "consumption-bar-group",
|
||||||
|
tabindex: "0",
|
||||||
|
role: "img",
|
||||||
|
"aria-label": createConsumptionPointAriaLabel(point, period)
|
||||||
|
});
|
||||||
|
|
||||||
|
["pointerenter", "pointermove", "focus"].forEach((eventName) => {
|
||||||
|
group.addEventListener(eventName, (event) => {
|
||||||
|
showConsumptionTooltip(tooltip, point, period, event);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
["pointerleave", "blur"].forEach((eventName) => {
|
||||||
|
group.addEventListener(eventName, () => {
|
||||||
|
tooltip.hidden = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fundedHeight > 0) {
|
||||||
|
const fundedBar = createSvgElement("rect", {
|
||||||
|
x,
|
||||||
|
y: plot.bottom - fundedHeight,
|
||||||
|
width: barWidth,
|
||||||
|
height: fundedHeight,
|
||||||
|
class: "consumption-bar-funded"
|
||||||
|
});
|
||||||
|
appendConsumptionBarAnimation(
|
||||||
|
fundedBar,
|
||||||
|
plot.bottom,
|
||||||
|
plot.bottom - fundedHeight,
|
||||||
|
fundedHeight,
|
||||||
|
index * 34
|
||||||
|
);
|
||||||
|
group.append(fundedBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overageHeight > 0) {
|
||||||
|
const overageTop = plot.bottom - fundedHeight - overageHeight;
|
||||||
|
const overageBar = createSvgElement("rect", {
|
||||||
|
x,
|
||||||
|
y: overageTop,
|
||||||
|
width: barWidth,
|
||||||
|
height: overageHeight,
|
||||||
|
class: "consumption-bar-overage"
|
||||||
|
});
|
||||||
|
appendConsumptionBarAnimation(
|
||||||
|
overageBar,
|
||||||
|
plot.bottom - fundedHeight,
|
||||||
|
overageTop,
|
||||||
|
overageHeight,
|
||||||
|
(index * 34) + 90
|
||||||
|
);
|
||||||
|
group.append(overageBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = plot.bottom - fundedHeight - overageHeight;
|
||||||
|
|
||||||
|
if (visibleTotal > 0) {
|
||||||
|
const valueLabel = createSvgText(
|
||||||
|
x + (barWidth / 2),
|
||||||
|
Math.max(plot.top + 11, top - 7),
|
||||||
|
formatCompactConsumptionNumber(visibleTotal),
|
||||||
|
"consumption-value-label consumption-animated-label",
|
||||||
|
"middle"
|
||||||
|
);
|
||||||
|
valueLabel.style.animationDelay = `${(index * 34) + 360}ms`;
|
||||||
|
group.append(valueLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = createSvgText(
|
||||||
|
x + (barWidth / 2),
|
||||||
|
plot.bottom + 18,
|
||||||
|
formatConsumptionPointLabel(point, period),
|
||||||
|
"consumption-x-label",
|
||||||
|
period === "monthly" || period === "quarterly" ? "middle" : "end"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (period === "weekly" || period === "daily") {
|
||||||
|
label.setAttribute("transform", `rotate(-65 ${x + (barWidth / 2)} ${plot.bottom + 18})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
group.append(label);
|
||||||
|
svg.append(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeSeries.has("average")) {
|
||||||
|
const averageY = plot.bottom - ((average / maximum) * plotHeight);
|
||||||
|
const averageLine = createSvgElement("line", {
|
||||||
|
x1: plot.left,
|
||||||
|
y1: averageY,
|
||||||
|
x2: plot.right,
|
||||||
|
y2: averageY,
|
||||||
|
class: "consumption-average-line consumption-animated-average"
|
||||||
|
});
|
||||||
|
svg.append(averageLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) {
|
||||||
|
if (prefersReducedMotion()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.append(
|
||||||
|
createSvgElement("animate", {
|
||||||
|
attributeName: "y",
|
||||||
|
from: fromY,
|
||||||
|
to: toY,
|
||||||
|
dur: "0.52s",
|
||||||
|
begin: `${delayMs}ms`,
|
||||||
|
fill: "freeze",
|
||||||
|
calcMode: "spline",
|
||||||
|
keyTimes: "0;1",
|
||||||
|
keySplines: "0.2 0.8 0.2 1"
|
||||||
|
}),
|
||||||
|
createSvgElement("animate", {
|
||||||
|
attributeName: "height",
|
||||||
|
from: "0",
|
||||||
|
to: height,
|
||||||
|
dur: "0.52s",
|
||||||
|
begin: `${delayMs}ms`,
|
||||||
|
fill: "freeze",
|
||||||
|
calcMode: "spline",
|
||||||
|
keyTimes: "0;1",
|
||||||
|
keySplines: "0.2 0.8 0.2 1"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefersReducedMotion() {
|
||||||
|
return typeof window.matchMedia === "function"
|
||||||
|
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConsumptionPointAriaLabel(point, period) {
|
||||||
|
return `${formatConsumptionPointLabel(point, period)}. `
|
||||||
|
+ `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}. `
|
||||||
|
+ `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}. `
|
||||||
|
+ `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConsumptionTooltip(tooltip, point, period, event) {
|
||||||
|
const figure = tooltip.parentElement;
|
||||||
|
|
||||||
|
if (!figure) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip.textContent = "";
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = formatConsumptionPointLabel(point, period);
|
||||||
|
const details = document.createElement("span");
|
||||||
|
details.textContent = `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}`;
|
||||||
|
const overage = document.createElement("span");
|
||||||
|
overage.textContent = `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}`;
|
||||||
|
const total = document.createElement("span");
|
||||||
|
total.textContent = `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}`;
|
||||||
|
tooltip.append(title, details, overage, total);
|
||||||
|
tooltip.hidden = false;
|
||||||
|
|
||||||
|
const figureBounds = figure.getBoundingClientRect();
|
||||||
|
const targetBounds = event.currentTarget.getBoundingClientRect();
|
||||||
|
const pointerX = Number.isFinite(event.clientX) && event.clientX > 0
|
||||||
|
? event.clientX
|
||||||
|
: targetBounds.left + (targetBounds.width / 2);
|
||||||
|
const pointerY = Number.isFinite(event.clientY) && event.clientY > 0
|
||||||
|
? event.clientY
|
||||||
|
: targetBounds.top;
|
||||||
|
const left = Math.min(
|
||||||
|
Math.max(8, pointerX - figureBounds.left + 12),
|
||||||
|
Math.max(8, figureBounds.width - tooltip.offsetWidth - 8)
|
||||||
|
);
|
||||||
|
const top = Math.max(8, pointerY - figureBounds.top - tooltip.offsetHeight - 10);
|
||||||
|
tooltip.style.left = `${left}px`;
|
||||||
|
tooltip.style.top = `${top}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSvgElement(name, attributes = {}) {
|
||||||
|
const element = document.createElementNS(SVG_NAMESPACE, name);
|
||||||
|
|
||||||
|
Object.entries(attributes).forEach(([key, value]) => {
|
||||||
|
element.setAttribute(key, String(value));
|
||||||
|
});
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSvgText(x, y, value, className, anchor) {
|
||||||
|
const text = createSvgElement("text", { x, y, class: className, "text-anchor": anchor });
|
||||||
|
text.textContent = value;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { renderConsumptionTrendChart, getConsumptionTrendSeriesFilter, createConsumptionLegend, createConsumptionNavigationButton, createConsumptionSvg, appendConsumptionBarAnimation, prefersReducedMotion, createConsumptionPointAriaLabel, showConsumptionTooltip, createSvgElement, createSvgText };
|
||||||
|
}
|
||||||
155
src/features/consumption/model.js
Normal file
155
src/features/consumption/model.js
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
function createConsumptionModel() {
|
||||||
|
function normalizeConsumptionTrendItems(items) {
|
||||||
|
const points = new Map();
|
||||||
|
|
||||||
|
(Array.isArray(items) ? items : []).forEach((item) => {
|
||||||
|
const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : ""));
|
||||||
|
|
||||||
|
if (!dateKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!points.has(dateKey)) {
|
||||||
|
points.set(dateKey, {
|
||||||
|
dateKey,
|
||||||
|
date: parseConsumptionDate(item.fyDate),
|
||||||
|
fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [],
|
||||||
|
fiscalQtr: item.fiscalQtr || "",
|
||||||
|
funded: 0,
|
||||||
|
overage: 0,
|
||||||
|
arr: 0,
|
||||||
|
currencyCode: item.currencyCode || "USD"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = points.get(dateKey);
|
||||||
|
const amount = toFiniteNumber(item.usedAmount);
|
||||||
|
const category = String(item.usageCategory || "Funded Allocation").toUpperCase();
|
||||||
|
|
||||||
|
if (category === "OVERAGE") {
|
||||||
|
point.overage += amount;
|
||||||
|
} else {
|
||||||
|
point.funded += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(points.values())
|
||||||
|
.map((point) => ({ ...point, total: point.funded + point.overage }))
|
||||||
|
.sort((left, right) => left.dateKey.localeCompare(right.dateKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConsumptionPointTotal(point, activeSeries) {
|
||||||
|
const selectedSeries = activeSeries && typeof activeSeries.has === "function"
|
||||||
|
? activeSeries
|
||||||
|
: new Set(["funded", "overage"]);
|
||||||
|
return (selectedSeries.has("funded") ? point.funded : 0)
|
||||||
|
+ (selectedSeries.has("overage") ? point.overage : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) {
|
||||||
|
const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points;
|
||||||
|
return completePoints.reduce(
|
||||||
|
(sum, point) => sum + getConsumptionPointTotal(point, activeSeries),
|
||||||
|
0
|
||||||
|
) / Math.max(completePoints.length, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNiceConsumptionMaximum(value) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const magnitude = 10 ** Math.floor(Math.log10(value));
|
||||||
|
const normalized = value / magnitude;
|
||||||
|
const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||||
|
return step * magnitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConsumptionPointLabel(point, period) {
|
||||||
|
if (period === "quarterly") {
|
||||||
|
return point.fiscalTime[0] || point.fiscalQtr || point.dateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period === "monthly") {
|
||||||
|
return point.fiscalTime[1] || formatConsumptionDate(point.date, { month: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return point.fiscalTime[0] || formatConsumptionDate(point.date, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConsumptionRange(points, period) {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return "No data";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period === "quarterly") {
|
||||||
|
return `${formatConsumptionPointLabel(points[0], period)} - ${formatConsumptionPointLabel(points[points.length - 1], period)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period === "monthly") {
|
||||||
|
const options = { month: "short", year: "numeric" };
|
||||||
|
return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = { day: "2-digit", month: "short" };
|
||||||
|
return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConsumptionDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = String(value).split("-").map(Number);
|
||||||
|
return parts.length === 3 && parts.every(Number.isFinite)
|
||||||
|
? new Date(parts[0], parts[1] - 1, parts[2])
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConsumptionDate(date, options) {
|
||||||
|
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("en-US", options).format(date).replace(",", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompactConsumptionNumber(value) {
|
||||||
|
const amount = toFiniteNumber(value);
|
||||||
|
|
||||||
|
if (Math.abs(amount) >= 1000000) {
|
||||||
|
return `${(amount / 1000000).toFixed(amount >= 10000000 ? 0 : 1).replace(".0", "")}M`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(amount) >= 1000) {
|
||||||
|
return `${Math.round(amount / 1000)}K`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(Math.round(amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConsumptionCurrency(value, currencyCode) {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currencyCode || "USD",
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(toFiniteNumber(value));
|
||||||
|
} catch (error) {
|
||||||
|
return `${currencyCode || "USD"} ${Math.round(toFiniteNumber(value)).toLocaleString("en-US")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFiniteNumber(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { normalizeConsumptionTrendItems, getConsumptionPointTotal, calculateAverageConsumption, createNiceConsumptionMaximum, formatConsumptionPointLabel, formatConsumptionRange, parseConsumptionDate, formatConsumptionDate, formatCompactConsumptionNumber, formatConsumptionCurrency, toFiniteNumber };
|
||||||
|
}
|
||||||
93
src/features/consumption/plans-action.js
Normal file
93
src/features/consumption/plans-action.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
function createConsumptionPlansAction({
|
||||||
|
messageTypes,
|
||||||
|
appendBackgroundRequestLog,
|
||||||
|
consumptionPlansState,
|
||||||
|
getNestedValue,
|
||||||
|
openAccountPlansModal,
|
||||||
|
renderOpportunitiesTable,
|
||||||
|
requestConsumerTokenServiceOnce,
|
||||||
|
sendRuntimeMessage,
|
||||||
|
showCopyConfirmation,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document } = environment;
|
||||||
|
function createCloudConsumptionButton(opportunity) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
const optyNumber = String(opportunity.OptyNumber || "");
|
||||||
|
const partyNumber = getNestedValue(opportunity, ["CustomerAccount", "PartyNumber"]);
|
||||||
|
const requestState = consumptionPlansState.accountPlansRequestState.get(optyNumber);
|
||||||
|
const isLoading = requestState && requestState.status === "loading";
|
||||||
|
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-action-button";
|
||||||
|
button.setAttribute("aria-label", `View cloud consumption for ${optyNumber || "opportunity"}`);
|
||||||
|
button.title = isLoading ? "Loading customer consumption" : "View customer consumption";
|
||||||
|
button.disabled = Boolean(isLoading);
|
||||||
|
button.classList.toggle("opportunities-extension-action-button-loading", Boolean(isLoading));
|
||||||
|
button.append(createCloudConsumptionIcon());
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (!partyNumber) {
|
||||||
|
showCopyConfirmation("Customer Party Number is unavailable.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" });
|
||||||
|
openAccountPlansModal(opportunity, [], "loading");
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
requestConsumerTokenServiceOnce()
|
||||||
|
.then((authorizations) => sendRuntimeMessage({
|
||||||
|
type: messageTypes.requestAccountPlans,
|
||||||
|
partyNumber: String(partyNumber),
|
||||||
|
authorizations
|
||||||
|
}))
|
||||||
|
.then((response) => {
|
||||||
|
appendBackgroundRequestLog(response && response.debugRequests);
|
||||||
|
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
throw new Error(response && response.error ? response.error : "Unable to load cloud consumption.");
|
||||||
|
}
|
||||||
|
|
||||||
|
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
||||||
|
status: "ready",
|
||||||
|
plans: Array.isArray(response.plans) ? response.plans : []
|
||||||
|
});
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
openAccountPlansModal(opportunity, response.plans || [], "ready");
|
||||||
|
}).catch((error) => {
|
||||||
|
const errorMessage = error.message || "Unable to load cloud consumption.";
|
||||||
|
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
||||||
|
status: "error",
|
||||||
|
error: errorMessage
|
||||||
|
});
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
openAccountPlansModal(opportunity, [], "error", errorMessage);
|
||||||
|
showCopyConfirmation(errorMessage, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCloudConsumptionIcon() {
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
const cloud = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
const usage = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
|
||||||
|
svg.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
svg.setAttribute("focusable", "false");
|
||||||
|
cloud.setAttribute("d", "M7.2 18.5h10.1a4.2 4.2 0 0 0 .5-8.37A6.25 6.25 0 0 0 5.9 8.75a4.9 4.9 0 0 0 1.3 9.75Z");
|
||||||
|
usage.setAttribute("d", "M9 15v-2m3 2v-4m3 4V9");
|
||||||
|
|
||||||
|
[cloud, usage].forEach((path) => {
|
||||||
|
path.setAttribute("fill", "none");
|
||||||
|
path.setAttribute("stroke", "currentColor");
|
||||||
|
path.setAttribute("stroke-linecap", "round");
|
||||||
|
path.setAttribute("stroke-linejoin", "round");
|
||||||
|
path.setAttribute("stroke-width", "1.7");
|
||||||
|
});
|
||||||
|
svg.append(cloud, usage);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { createCloudConsumptionButton, createCloudConsumptionIcon };
|
||||||
|
}
|
||||||
5
src/features/consumption/plans-state.js
Normal file
5
src/features/consumption/plans-state.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
function createConsumptionPlansState() {
|
||||||
|
return {
|
||||||
|
accountPlansRequestState: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
358
src/features/consumption/plans-view.js
Normal file
358
src/features/consumption/plans-view.js
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
function createConsumptionPlansView({
|
||||||
|
ACCOUNT_PLANS_MODAL_ID,
|
||||||
|
DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||||
|
MODAL_ID,
|
||||||
|
createCloseIcon,
|
||||||
|
createConsumptionTrendSection,
|
||||||
|
createSvgElement,
|
||||||
|
formatOracleResponseDate,
|
||||||
|
getNestedValue,
|
||||||
|
loadConsumptionTrendForPanel,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document, requestAnimationFrame, setTimeout, window } = environment;
|
||||||
|
function openAccountPlansModal(opportunity, plans, state = "ready", errorMessage = "") {
|
||||||
|
const overlay = document.getElementById(MODAL_ID);
|
||||||
|
const optyNumber = String(opportunity.OptyNumber || "");
|
||||||
|
const existingModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
||||||
|
|
||||||
|
if (!overlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== "loading" && (
|
||||||
|
!existingModal
|
||||||
|
|| existingModal.hidden
|
||||||
|
|| existingModal.dataset.optyNumber !== optyNumber
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
existingModal?.remove();
|
||||||
|
|
||||||
|
const modal = document.createElement("section");
|
||||||
|
modal.id = ACCOUNT_PLANS_MODAL_ID;
|
||||||
|
modal.className = "opportunities-extension-account-plans-modal";
|
||||||
|
modal.dataset.optyNumber = optyNumber;
|
||||||
|
modal.setAttribute("role", "dialog");
|
||||||
|
modal.setAttribute("aria-modal", "true");
|
||||||
|
modal.setAttribute("aria-labelledby", "opportunities-extension-account-plans-title");
|
||||||
|
modal.setAttribute("aria-busy", String(state === "loading"));
|
||||||
|
|
||||||
|
const dialog = document.createElement("div");
|
||||||
|
dialog.className = "opportunities-extension-account-plans-dialog";
|
||||||
|
|
||||||
|
const header = document.createElement("header");
|
||||||
|
header.className = "opportunities-extension-account-plans-header";
|
||||||
|
|
||||||
|
const heading = document.createElement("div");
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.id = "opportunities-extension-account-plans-title";
|
||||||
|
title.textContent = "Cloud Consumption";
|
||||||
|
|
||||||
|
const subtitle = document.createElement("p");
|
||||||
|
subtitle.textContent = getNestedValue(opportunity, ["CustomerAccount", "PartyUniqueName"]) || opportunity.Name || opportunity.OptyNumber || "Opportunity";
|
||||||
|
heading.append(title, subtitle);
|
||||||
|
|
||||||
|
const closeButton = document.createElement("button");
|
||||||
|
closeButton.type = "button";
|
||||||
|
closeButton.className = "opportunities-extension-icon-button";
|
||||||
|
closeButton.setAttribute("aria-label", "Close cloud consumption");
|
||||||
|
closeButton.title = "Close";
|
||||||
|
closeButton.append(createCloseIcon());
|
||||||
|
closeButton.addEventListener("click", closeAccountPlansModal);
|
||||||
|
header.append(heading, closeButton);
|
||||||
|
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.className = "opportunities-extension-account-plans-content";
|
||||||
|
|
||||||
|
if (state === "loading") {
|
||||||
|
content.append(createAccountPlansSkeleton());
|
||||||
|
} else if (state === "error") {
|
||||||
|
const error = document.createElement("p");
|
||||||
|
error.className = "opportunities-extension-account-plans-error";
|
||||||
|
error.setAttribute("role", "alert");
|
||||||
|
error.textContent = errorMessage || "Unable to load cloud consumption.";
|
||||||
|
content.append(error);
|
||||||
|
} else if (!Array.isArray(plans) || plans.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "opportunities-extension-account-plans-empty";
|
||||||
|
empty.textContent = "No account plans found for this customer.";
|
||||||
|
content.append(empty);
|
||||||
|
} else {
|
||||||
|
const tabList = document.createElement("div");
|
||||||
|
tabList.className = "opportunities-extension-account-plans-tabs";
|
||||||
|
tabList.setAttribute("role", "tablist");
|
||||||
|
tabList.setAttribute("aria-label", "Account plans");
|
||||||
|
|
||||||
|
const tabsNavigation = createAccountPlansTabsNavigation(tabList);
|
||||||
|
|
||||||
|
const panels = document.createElement("div");
|
||||||
|
panels.className = "opportunities-extension-account-plans-panels";
|
||||||
|
|
||||||
|
plans.forEach((plan, index) => {
|
||||||
|
const tabId = `opportunities-extension-account-plan-tab-${index}`;
|
||||||
|
const panelId = `opportunities-extension-account-plan-panel-${index}`;
|
||||||
|
const tab = document.createElement("button");
|
||||||
|
tab.id = tabId;
|
||||||
|
tab.type = "button";
|
||||||
|
tab.className = "opportunities-extension-account-plan-tab";
|
||||||
|
tab.setAttribute("role", "tab");
|
||||||
|
tab.setAttribute("aria-controls", panelId);
|
||||||
|
tab.setAttribute("aria-selected", String(index === 0));
|
||||||
|
tab.tabIndex = index === 0 ? 0 : -1;
|
||||||
|
const planLabel = [plan.planType, plan.subPlanNum]
|
||||||
|
.filter((value) => value !== null && value !== undefined && value !== "")
|
||||||
|
.join(" ") || `Plan ${index + 1}`;
|
||||||
|
const headerStatus = plan.hdrStatusCode === null || plan.hdrStatusCode === undefined || plan.hdrStatusCode === ""
|
||||||
|
? "Unknown"
|
||||||
|
: String(plan.hdrStatusCode);
|
||||||
|
tab.setAttribute("aria-label", `${planLabel}. Header Status: ${headerStatus}`);
|
||||||
|
tab.append(createAccountPlanStatusIndicator(headerStatus), document.createTextNode(planLabel));
|
||||||
|
|
||||||
|
const panel = document.createElement("section");
|
||||||
|
panel.id = panelId;
|
||||||
|
panel.className = "opportunities-extension-account-plan-panel";
|
||||||
|
panel.setAttribute("role", "tabpanel");
|
||||||
|
panel.setAttribute("aria-labelledby", tabId);
|
||||||
|
panel.hidden = index !== 0;
|
||||||
|
panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan));
|
||||||
|
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
tabList.querySelectorAll("[role='tab']").forEach((candidate) => {
|
||||||
|
const isSelected = candidate === tab;
|
||||||
|
candidate.setAttribute("aria-selected", String(isSelected));
|
||||||
|
candidate.tabIndex = isSelected ? 0 : -1;
|
||||||
|
});
|
||||||
|
panels.querySelectorAll("[role='tabpanel']").forEach((candidate) => {
|
||||||
|
candidate.hidden = candidate !== panel;
|
||||||
|
});
|
||||||
|
const trendSection = panel.querySelector(".opportunities-extension-consumption-trend");
|
||||||
|
loadConsumptionTrendForPanel(
|
||||||
|
panel,
|
||||||
|
plan,
|
||||||
|
trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD
|
||||||
|
);
|
||||||
|
});
|
||||||
|
tabList.append(tab);
|
||||||
|
panels.append(panel);
|
||||||
|
});
|
||||||
|
|
||||||
|
content.append(tabsNavigation, panels);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.append(header, content);
|
||||||
|
modal.append(dialog);
|
||||||
|
overlay.append(modal);
|
||||||
|
modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus();
|
||||||
|
|
||||||
|
if (state === "ready" && Array.isArray(plans) && plans.length > 0) {
|
||||||
|
const firstPanel = modal.querySelector("[role='tabpanel']");
|
||||||
|
|
||||||
|
if (firstPanel) {
|
||||||
|
loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAccountPlansTabsNavigation(tabList) {
|
||||||
|
const navigation = document.createElement("div");
|
||||||
|
navigation.className = "opportunities-extension-account-plans-tabs-navigation";
|
||||||
|
|
||||||
|
const previousButton = createAccountPlansTabNavigationButton("previous");
|
||||||
|
const nextButton = createAccountPlansTabNavigationButton("next");
|
||||||
|
const updateNavigation = () => {
|
||||||
|
const maximumScroll = Math.max(0, tabList.scrollWidth - tabList.clientWidth);
|
||||||
|
const tabs = Array.from(tabList.querySelectorAll("[role='tab']"));
|
||||||
|
const viewportStart = tabList.scrollLeft;
|
||||||
|
const viewportEnd = viewportStart + tabList.clientWidth;
|
||||||
|
const hasContentBefore = tabList.scrollLeft > 1
|
||||||
|
|| tabs.some((tab) => tab.offsetLeft < viewportStart - 1);
|
||||||
|
const hasContentAfter = tabList.scrollLeft < maximumScroll - 1
|
||||||
|
|| tabs.some((tab) => (tab.offsetLeft + tab.offsetWidth) > viewportEnd + 1);
|
||||||
|
previousButton.hidden = !hasContentBefore;
|
||||||
|
nextButton.hidden = !hasContentAfter;
|
||||||
|
};
|
||||||
|
const scrollTabs = (direction) => {
|
||||||
|
tabList.scrollBy({
|
||||||
|
left: direction * Math.max(160, Math.floor(tabList.clientWidth * .72)),
|
||||||
|
behavior: "smooth"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
previousButton.addEventListener("click", () => scrollTabs(-1));
|
||||||
|
nextButton.addEventListener("click", () => scrollTabs(1));
|
||||||
|
tabList.addEventListener("scroll", updateNavigation, { passive: true });
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!navigation.isConnected) {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateNavigation();
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize, { passive: true });
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(updateNavigation));
|
||||||
|
setTimeout(updateNavigation, 0);
|
||||||
|
|
||||||
|
navigation.append(previousButton, tabList, nextButton);
|
||||||
|
return navigation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAccountPlansTabNavigationButton(direction) {
|
||||||
|
const isPrevious = direction === "previous";
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-account-plans-tab-navigation-button";
|
||||||
|
button.setAttribute("aria-label", isPrevious ? "Show previous account plans" : "Show next account plans");
|
||||||
|
button.title = isPrevious ? "Previous plans" : "Next plans";
|
||||||
|
|
||||||
|
const icon = createSvgElement("svg", {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
"aria-hidden": "true",
|
||||||
|
focusable: "false"
|
||||||
|
});
|
||||||
|
icon.append(createSvgElement("path", {
|
||||||
|
d: isPrevious ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
"stroke-linecap": "round",
|
||||||
|
"stroke-linejoin": "round",
|
||||||
|
"stroke-width": "2"
|
||||||
|
}));
|
||||||
|
button.append(icon);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAccountPlansSkeleton() {
|
||||||
|
const skeleton = document.createElement("div");
|
||||||
|
skeleton.className = "opportunities-extension-account-plans-skeleton";
|
||||||
|
skeleton.setAttribute("role", "status");
|
||||||
|
|
||||||
|
const announcement = document.createElement("span");
|
||||||
|
announcement.className = "opportunities-extension-visually-hidden";
|
||||||
|
announcement.textContent = "Loading cloud consumption";
|
||||||
|
|
||||||
|
const tabs = document.createElement("div");
|
||||||
|
tabs.className = "opportunities-extension-account-plans-skeleton-tabs";
|
||||||
|
const tab = document.createElement("span");
|
||||||
|
tab.className = "opportunities-extension-account-plans-skeleton-tab";
|
||||||
|
const status = document.createElement("span");
|
||||||
|
status.className = "opportunities-extension-account-plans-skeleton-status";
|
||||||
|
const tabLabel = document.createElement("span");
|
||||||
|
tabLabel.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||||
|
tabLabel.style.width = "104px";
|
||||||
|
tab.append(status, tabLabel);
|
||||||
|
tabs.append(tab);
|
||||||
|
|
||||||
|
const details = document.createElement("div");
|
||||||
|
details.className = "opportunities-extension-account-plans-skeleton-details";
|
||||||
|
[72, 88, 64, 78, 58, 68].forEach((width) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
const label = document.createElement("span");
|
||||||
|
const value = document.createElement("span");
|
||||||
|
label.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-label";
|
||||||
|
value.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-value";
|
||||||
|
value.style.width = `${width}%`;
|
||||||
|
row.append(label, value);
|
||||||
|
details.append(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const trend = document.createElement("div");
|
||||||
|
trend.className = "opportunities-extension-account-plans-skeleton-trend";
|
||||||
|
|
||||||
|
const trendHeader = document.createElement("div");
|
||||||
|
trendHeader.className = "opportunities-extension-account-plans-skeleton-trend-header";
|
||||||
|
const periods = document.createElement("div");
|
||||||
|
periods.className = "opportunities-extension-account-plans-skeleton-periods";
|
||||||
|
[58, 52, 56, 38].forEach((width, index) => {
|
||||||
|
const period = document.createElement("span");
|
||||||
|
period.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||||
|
period.style.width = `${width}px`;
|
||||||
|
if (index === 2) period.classList.add("opportunities-extension-account-plans-skeleton-period-active");
|
||||||
|
periods.append(period);
|
||||||
|
});
|
||||||
|
trendHeader.append(periods);
|
||||||
|
|
||||||
|
const navigation = document.createElement("div");
|
||||||
|
navigation.className = "opportunities-extension-account-plans-skeleton-navigation";
|
||||||
|
[32, 124, 32].forEach((width) => {
|
||||||
|
const item = document.createElement("span");
|
||||||
|
item.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||||
|
item.style.width = `${width}px`;
|
||||||
|
navigation.append(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chart = document.createElement("div");
|
||||||
|
chart.className = "opportunities-extension-account-plans-skeleton-chart";
|
||||||
|
const plot = document.createElement("div");
|
||||||
|
plot.className = "opportunities-extension-account-plans-skeleton-plot";
|
||||||
|
[34, 42, 38, 48, 44, 58, 64, 55, 76, 68, 86, 62].forEach((height) => {
|
||||||
|
const bar = document.createElement("span");
|
||||||
|
bar.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||||
|
bar.style.height = `${height}%`;
|
||||||
|
plot.append(bar);
|
||||||
|
});
|
||||||
|
const legend = document.createElement("div");
|
||||||
|
legend.className = "opportunities-extension-account-plans-skeleton-legend";
|
||||||
|
[112, 68, 132].forEach((width) => {
|
||||||
|
const item = document.createElement("span");
|
||||||
|
item.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||||
|
item.style.width = `${width}px`;
|
||||||
|
legend.append(item);
|
||||||
|
});
|
||||||
|
chart.append(plot, legend);
|
||||||
|
trend.append(trendHeader, navigation, chart);
|
||||||
|
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.className = "opportunities-extension-account-plan-panel opportunities-extension-account-plans-skeleton-panel";
|
||||||
|
panel.append(details, trend);
|
||||||
|
|
||||||
|
skeleton.append(announcement, tabs, panel);
|
||||||
|
return skeleton;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAccountPlanStatusIndicator(statusValue) {
|
||||||
|
const indicator = document.createElement("span");
|
||||||
|
const isActive = String(statusValue).toUpperCase() === "ACTIVE";
|
||||||
|
|
||||||
|
indicator.className = "opportunities-extension-account-plan-status-indicator";
|
||||||
|
indicator.dataset.active = String(isActive);
|
||||||
|
indicator.setAttribute("aria-hidden", "true");
|
||||||
|
indicator.title = `Header Status: ${statusValue}`;
|
||||||
|
return indicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAccountPlanDetails(plan) {
|
||||||
|
const details = document.createElement("dl");
|
||||||
|
details.className = "opportunities-extension-account-plan-details";
|
||||||
|
const fields = [
|
||||||
|
{ label: "Booking Customer", value: plan.bookingCustomer },
|
||||||
|
{ label: "Sold To Party Name", value: plan.soldToPartyName },
|
||||||
|
{ label: "Contract ID", value: plan.contractId },
|
||||||
|
{ label: "Header Status", value: plan.hdrStatusCode },
|
||||||
|
{ label: "Start Date", value: formatOracleResponseDate(plan.startDate) },
|
||||||
|
{ label: "End Date", value: formatOracleResponseDate(plan.endDate) }
|
||||||
|
];
|
||||||
|
|
||||||
|
fields.forEach((field) => {
|
||||||
|
const group = document.createElement("div");
|
||||||
|
const term = document.createElement("dt");
|
||||||
|
const description = document.createElement("dd");
|
||||||
|
term.textContent = field.label;
|
||||||
|
description.textContent = field.value === null || field.value === undefined || field.value === "" ? "-" : String(field.value);
|
||||||
|
group.append(term, description);
|
||||||
|
details.append(group);
|
||||||
|
});
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAccountPlansModal() {
|
||||||
|
const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
modal.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { openAccountPlansModal, createAccountPlansTabsNavigation, createAccountPlansTabNavigationButton, createAccountPlansSkeleton, createAccountPlanStatusIndicator, createAccountPlanDetails, closeAccountPlansModal };
|
||||||
|
}
|
||||||
171
src/features/consumption/trend-controller.js
Normal file
171
src/features/consumption/trend-controller.js
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
function createConsumptionTrendController({
|
||||||
|
messageTypes,
|
||||||
|
CONSUMPTION_TREND_PERIODS,
|
||||||
|
DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||||
|
appendBackgroundRequestLog,
|
||||||
|
consumptionTrendState,
|
||||||
|
renderConsumptionTrendChart,
|
||||||
|
requestConsumerTokenServiceOnce,
|
||||||
|
sendRuntimeMessage,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document } = environment;
|
||||||
|
function createConsumptionTrendSection(plan) {
|
||||||
|
const section = document.createElement("section");
|
||||||
|
section.className = "opportunities-extension-consumption-trend";
|
||||||
|
section.dataset.subPlanNum = String(plan.subPlanNum || "");
|
||||||
|
section.dataset.period = DEFAULT_CONSUMPTION_TREND_PERIOD;
|
||||||
|
|
||||||
|
const header = document.createElement("header");
|
||||||
|
header.className = "opportunities-extension-consumption-trend-header";
|
||||||
|
|
||||||
|
const periodControls = document.createElement("div");
|
||||||
|
periodControls.className = "opportunities-extension-consumption-periods";
|
||||||
|
periodControls.setAttribute("role", "group");
|
||||||
|
periodControls.setAttribute("aria-label", "Consumption period");
|
||||||
|
|
||||||
|
CONSUMPTION_TREND_PERIODS.forEach((periodOption) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.dataset.period = periodOption.value;
|
||||||
|
button.setAttribute("aria-pressed", String(periodOption.value === DEFAULT_CONSUMPTION_TREND_PERIOD));
|
||||||
|
button.textContent = periodOption.label;
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
section.dataset.period = periodOption.value;
|
||||||
|
consumptionTrendState.consumptionTrendWindowOffsets.set(
|
||||||
|
createConsumptionTrendKey(section.dataset.subPlanNum, periodOption.value),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
loadConsumptionTrendForPanel(section.closest("[role='tabpanel']"), plan, periodOption.value);
|
||||||
|
});
|
||||||
|
periodControls.append(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chart = document.createElement("div");
|
||||||
|
chart.className = "opportunities-extension-consumption-chart";
|
||||||
|
chart.setAttribute("aria-live", "polite");
|
||||||
|
header.append(periodControls);
|
||||||
|
section.append(header, chart);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConsumptionTrendForPanel(panel, plan, period) {
|
||||||
|
if (!panel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const section = panel.querySelector(".opportunities-extension-consumption-trend");
|
||||||
|
|
||||||
|
if (!section) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subPlanNum = String(plan.subPlanNum || "").trim();
|
||||||
|
const selectedPeriod = CONSUMPTION_TREND_PERIODS.some((item) => item.value === period)
|
||||||
|
? period
|
||||||
|
: DEFAULT_CONSUMPTION_TREND_PERIOD;
|
||||||
|
const key = createConsumptionTrendKey(subPlanNum, selectedPeriod);
|
||||||
|
const chart = section.querySelector(".opportunities-extension-consumption-chart");
|
||||||
|
section.dataset.period = selectedPeriod;
|
||||||
|
chart.dataset.requestKey = key;
|
||||||
|
updateConsumptionPeriodButtons(section, selectedPeriod);
|
||||||
|
|
||||||
|
if (!subPlanNum) {
|
||||||
|
renderConsumptionTrendError(chart, "Sub plan number is unavailable.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = consumptionTrendState.consumptionTrendCache.get(key);
|
||||||
|
|
||||||
|
renderConsumptionTrendSkeleton(chart);
|
||||||
|
|
||||||
|
if (cached && cached.status === "loading") {
|
||||||
|
cached.promise
|
||||||
|
.then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key))
|
||||||
|
.catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = requestConsumerTokenServiceOnce()
|
||||||
|
.then((authorizations) => sendRuntimeMessage({
|
||||||
|
type: messageTypes.requestConsumptionTrend,
|
||||||
|
subPlanNum,
|
||||||
|
period: selectedPeriod,
|
||||||
|
authorization: authorizations && authorizations.accountPlans
|
||||||
|
}))
|
||||||
|
.then((response) => {
|
||||||
|
appendBackgroundRequestLog(response && response.debugRequests);
|
||||||
|
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
throw new Error(response && response.error ? response.error : "Unable to load consumption trend.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(response.items) ? response.items : [];
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (consumptionTrendState.consumptionTrendCache.get(key)?.promise === request) {
|
||||||
|
consumptionTrendState.consumptionTrendCache.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
consumptionTrendState.consumptionTrendCache.set(key, { status: "loading", promise: request });
|
||||||
|
request
|
||||||
|
.then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key))
|
||||||
|
.catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConsumptionTrendKey(subPlanNum, period) {
|
||||||
|
return `${subPlanNum}:${period}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConsumptionPeriodButtons(section, period) {
|
||||||
|
section.querySelectorAll(".opportunities-extension-consumption-periods button").forEach((button) => {
|
||||||
|
button.setAttribute("aria-pressed", String(button.dataset.period === period));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConsumptionTrendIfCurrent(section, chart, items, period, subPlanNum, key) {
|
||||||
|
if (chart.dataset.requestKey === key) {
|
||||||
|
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConsumptionTrendErrorIfCurrent(chart, errorMessage, key) {
|
||||||
|
if (chart.dataset.requestKey === key) {
|
||||||
|
renderConsumptionTrendError(chart, errorMessage || "Unable to load consumption trend.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConsumptionTrendSkeleton(chart) {
|
||||||
|
chart.textContent = "";
|
||||||
|
chart.setAttribute("aria-busy", "true");
|
||||||
|
const skeleton = document.createElement("div");
|
||||||
|
skeleton.className = "opportunities-extension-consumption-chart-skeleton";
|
||||||
|
const announcement = document.createElement("span");
|
||||||
|
announcement.className = "opportunities-extension-visually-hidden";
|
||||||
|
announcement.textContent = "Loading consumption trend";
|
||||||
|
const plot = document.createElement("div");
|
||||||
|
plot.className = "opportunities-extension-consumption-chart-skeleton-plot";
|
||||||
|
|
||||||
|
for (let index = 0; index < 13; index += 1) {
|
||||||
|
const bar = document.createElement("span");
|
||||||
|
bar.style.height = `${28 + ((index * 17) % 58)}%`;
|
||||||
|
plot.append(bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
skeleton.append(announcement, plot);
|
||||||
|
chart.append(skeleton);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConsumptionTrendError(chart, errorMessage) {
|
||||||
|
chart.textContent = "";
|
||||||
|
chart.setAttribute("aria-busy", "false");
|
||||||
|
const error = document.createElement("p");
|
||||||
|
error.className = "opportunities-extension-consumption-chart-error";
|
||||||
|
error.setAttribute("role", "alert");
|
||||||
|
error.textContent = errorMessage;
|
||||||
|
chart.append(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { createConsumptionTrendSection, loadConsumptionTrendForPanel, createConsumptionTrendKey, updateConsumptionPeriodButtons, renderConsumptionTrendIfCurrent, renderConsumptionTrendErrorIfCurrent, renderConsumptionTrendSkeleton, renderConsumptionTrendError };
|
||||||
|
}
|
||||||
7
src/features/consumption/trend-state.js
Normal file
7
src/features/consumption/trend-state.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
function createConsumptionTrendState() {
|
||||||
|
return {
|
||||||
|
consumptionTrendCache: new Map(),
|
||||||
|
consumptionTrendWindowOffsets: new Map(),
|
||||||
|
consumptionTrendSeriesFilters: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
198
src/features/debug/content-log.js
Normal file
198
src/features/debug/content-log.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
function createDebugContentLog({
|
||||||
|
DEBUG_BODY_LIMIT,
|
||||||
|
debugState,
|
||||||
|
renderDebugPanel
|
||||||
|
}) {
|
||||||
|
function logRequestStart(details) {
|
||||||
|
const entry = {
|
||||||
|
id: debugState.requestLog.length + 1,
|
||||||
|
name: details.name,
|
||||||
|
method: details.method,
|
||||||
|
url: details.url,
|
||||||
|
headers: details.headers,
|
||||||
|
status: "pending",
|
||||||
|
startedAt: new Date(),
|
||||||
|
completedAt: null,
|
||||||
|
durationMs: null,
|
||||||
|
httpStatus: null,
|
||||||
|
statusText: "",
|
||||||
|
requestHeaders: details.headers,
|
||||||
|
requestBody: details.body || "",
|
||||||
|
metadata: details.metadata || {},
|
||||||
|
responseHeaders: {},
|
||||||
|
responseBody: "",
|
||||||
|
responseBodyTruncated: false,
|
||||||
|
error: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
debugState.requestLog.push(entry);
|
||||||
|
renderDebugPanel();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logRequestSuccess(entry, response, responseBody) {
|
||||||
|
entry.status = response.ok ? "success" : "failed";
|
||||||
|
entry.completedAt = new Date();
|
||||||
|
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
|
||||||
|
entry.httpStatus = response.status;
|
||||||
|
entry.statusText = response.statusText || "";
|
||||||
|
entry.responseHeaders = headersToObject(response.headers);
|
||||||
|
entry.responseBody = responseBody.value;
|
||||||
|
entry.responseBodyTruncated = responseBody.truncated;
|
||||||
|
renderDebugPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function logRequestFailure(entry, errorMessage) {
|
||||||
|
entry.status = "failed";
|
||||||
|
entry.completedAt = new Date();
|
||||||
|
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
|
||||||
|
entry.error = errorMessage;
|
||||||
|
renderDebugPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendBackgroundRequestLog(entries) {
|
||||||
|
if (!Array.isArray(entries) || entries.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
debugState.requestLog.push({
|
||||||
|
id: debugState.requestLog.length + 1,
|
||||||
|
name: entry.name || "backgroundRequest",
|
||||||
|
method: entry.method || "GET",
|
||||||
|
url: entry.url || "",
|
||||||
|
headers: entry.requestHeaders || {},
|
||||||
|
status: entry.status || "failed",
|
||||||
|
startedAt: new Date(entry.startedAt),
|
||||||
|
completedAt: entry.completedAt ? new Date(entry.completedAt) : null,
|
||||||
|
durationMs: entry.durationMs,
|
||||||
|
httpStatus: entry.httpStatus,
|
||||||
|
statusText: entry.statusText || "",
|
||||||
|
requestHeaders: entry.requestHeaders || {},
|
||||||
|
requestBody: entry.requestBody || "",
|
||||||
|
metadata: entry.metadata || { executionContext: "background" },
|
||||||
|
responseHeaders: entry.responseHeaders || {},
|
||||||
|
responseBody: entry.responseBody || "",
|
||||||
|
responseBodyTruncated: Boolean(entry.responseBodyTruncated),
|
||||||
|
error: entry.error || ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
renderDebugPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readResponseBodyForDebug(response) {
|
||||||
|
try {
|
||||||
|
const text = await response.clone().text();
|
||||||
|
return createDebugBody(text);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
value: `Unable to read response body: ${error.message || "unknown error"}`,
|
||||||
|
truncated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDebugBody(text) {
|
||||||
|
if (text.length > DEBUG_BODY_LIMIT) {
|
||||||
|
return {
|
||||||
|
value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`,
|
||||||
|
truncated: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: text || "(empty response body)",
|
||||||
|
truncated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function headersToObject(headers) {
|
||||||
|
const headerMap = {};
|
||||||
|
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
headerMap[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return headerMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeaderBlock(headers) {
|
||||||
|
const entries = Object.entries(headers);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return "(none)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map(([key, value]) => `${key}: ${debugState.debugSecretsVisible || !isSensitiveDebugKey(key)
|
||||||
|
? value
|
||||||
|
: formatHiddenDebugValue(value)}`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDebugBody(body) {
|
||||||
|
const text = body || "(none)";
|
||||||
|
|
||||||
|
if (debugState.debugSecretsVisible) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(maskSensitiveDebugValue(JSON.parse(text)), null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskSensitiveDebugValue(value, parentKey = "") {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => maskSensitiveDebugValue(item, parentKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([key, child]) => {
|
||||||
|
const isSensitive = isSensitiveDebugKey(key)
|
||||||
|
|| (key === "value" && /uiaasHeader|uiassHeader/i.test(parentKey));
|
||||||
|
return [key, isSensitive
|
||||||
|
? formatHiddenDebugValue(child)
|
||||||
|
: maskSensitiveDebugValue(child, key)];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSensitiveDebugKey(key) {
|
||||||
|
return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$/i.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHiddenDebugValue(value) {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return "(missing)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `(present, ${String(value).length} characters)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetadataBlock(metadata) {
|
||||||
|
const entries = Object.entries(metadata);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return "(none)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map(([key, value]) => `${key}: ${value || "(none)"}`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskToken(token) {
|
||||||
|
if (token.length <= 10) {
|
||||||
|
return "(present)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { logRequestStart, logRequestSuccess, logRequestFailure, appendBackgroundRequestLog, readResponseBodyForDebug, createDebugBody, headersToObject, formatHeaderBlock, formatDebugBody, maskSensitiveDebugValue, isSensitiveDebugKey, formatHiddenDebugValue, formatMetadataBlock, maskToken };
|
||||||
|
}
|
||||||
174
src/features/debug/content-view.js
Normal file
174
src/features/debug/content-view.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
function createDebugContentView({
|
||||||
|
DEBUG_PANEL_ID,
|
||||||
|
MODAL_ID,
|
||||||
|
debugState,
|
||||||
|
formatDebugBody,
|
||||||
|
formatHeaderBlock,
|
||||||
|
formatMetadataBlock,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document } = environment;
|
||||||
|
function toggleDebugPanel() {
|
||||||
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
|
if (!modal || modal.hidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel = document.getElementById(DEBUG_PANEL_ID);
|
||||||
|
|
||||||
|
if (!panel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.hidden = !panel.hidden;
|
||||||
|
renderDebugPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDebugPanel() {
|
||||||
|
const panel = document.getElementById(DEBUG_PANEL_ID);
|
||||||
|
|
||||||
|
if (!panel || panel.hidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.textContent = "";
|
||||||
|
|
||||||
|
const panelHeader = document.createElement("header");
|
||||||
|
panelHeader.className = "opportunities-extension-debug-panel-header";
|
||||||
|
|
||||||
|
const heading = document.createElement("div");
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.textContent = "Debug Requests";
|
||||||
|
|
||||||
|
const summary = document.createElement("p");
|
||||||
|
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`;
|
||||||
|
heading.append(title, summary);
|
||||||
|
|
||||||
|
const visibilityButton = document.createElement("button");
|
||||||
|
visibilityButton.type = "button";
|
||||||
|
visibilityButton.className = "opportunities-extension-icon-button opportunities-extension-debug-visibility-button";
|
||||||
|
visibilityButton.setAttribute("aria-pressed", String(debugState.debugSecretsVisible));
|
||||||
|
visibilityButton.setAttribute("aria-label", debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values");
|
||||||
|
visibilityButton.title = debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values";
|
||||||
|
visibilityButton.append(createDebugVisibilityIcon(debugState.debugSecretsVisible));
|
||||||
|
visibilityButton.addEventListener("click", () => {
|
||||||
|
debugState.debugSecretsVisible = !debugState.debugSecretsVisible;
|
||||||
|
renderDebugPanel();
|
||||||
|
});
|
||||||
|
panelHeader.append(heading, visibilityButton);
|
||||||
|
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "opportunities-extension-debug-list";
|
||||||
|
|
||||||
|
if (debugState.requestLog.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "opportunities-extension-debug-empty";
|
||||||
|
empty.textContent = "No requests recorded yet.";
|
||||||
|
list.append(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugState.requestLog.forEach((entry) => {
|
||||||
|
const item = document.createElement("details");
|
||||||
|
item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`;
|
||||||
|
|
||||||
|
const heading = document.createElement("summary");
|
||||||
|
heading.className = "opportunities-extension-debug-heading";
|
||||||
|
|
||||||
|
const name = document.createElement("strong");
|
||||||
|
name.textContent = `${entry.id}. ${entry.name}`;
|
||||||
|
|
||||||
|
const httpStatus = document.createElement("span");
|
||||||
|
httpStatus.className = "opportunities-extension-debug-http-status";
|
||||||
|
httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending";
|
||||||
|
|
||||||
|
const status = document.createElement("span");
|
||||||
|
status.className = "opportunities-extension-debug-status";
|
||||||
|
status.textContent = entry.status;
|
||||||
|
|
||||||
|
const summaryMeta = document.createElement("span");
|
||||||
|
summaryMeta.className = "opportunities-extension-debug-summary-meta";
|
||||||
|
summaryMeta.append(httpStatus, status);
|
||||||
|
|
||||||
|
heading.append(name, summaryMeta);
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`${entry.method} ${entry.url}`,
|
||||||
|
`Started: ${formatDebugTime(entry.startedAt)}`,
|
||||||
|
entry.completedAt ? `Completed: ${formatDebugTime(entry.completedAt)} (${entry.durationMs}ms)` : "Completed: pending",
|
||||||
|
entry.httpStatus ? `HTTP: ${entry.httpStatus} ${entry.statusText}`.trim() : "",
|
||||||
|
entry.error ? `Error: ${entry.error}` : "",
|
||||||
|
`Response body truncated: ${entry.responseBodyTruncated ? "yes" : "no"}`
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const details = document.createElement("div");
|
||||||
|
details.className = "opportunities-extension-debug-details";
|
||||||
|
appendDebugBlock(details, "Request", lines.join("\n"));
|
||||||
|
appendDebugBlock(details, "Request Metadata", formatMetadataBlock(entry.metadata));
|
||||||
|
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
|
||||||
|
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
|
||||||
|
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
|
||||||
|
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody));
|
||||||
|
|
||||||
|
item.append(heading, details);
|
||||||
|
list.append(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.append(panelHeader, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDebugVisibilityIcon(secretsVisible) {
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
const eye = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
const pupil = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||||
|
|
||||||
|
svg.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
svg.setAttribute("focusable", "false");
|
||||||
|
eye.setAttribute("d", "M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z");
|
||||||
|
pupil.setAttribute("cx", "12");
|
||||||
|
pupil.setAttribute("cy", "12");
|
||||||
|
pupil.setAttribute("r", "2.75");
|
||||||
|
[eye, pupil].forEach((shape) => {
|
||||||
|
shape.setAttribute("fill", "none");
|
||||||
|
shape.setAttribute("stroke", "currentColor");
|
||||||
|
shape.setAttribute("stroke-linecap", "round");
|
||||||
|
shape.setAttribute("stroke-linejoin", "round");
|
||||||
|
shape.setAttribute("stroke-width", "1.7");
|
||||||
|
});
|
||||||
|
svg.append(eye, pupil);
|
||||||
|
|
||||||
|
if (secretsVisible) {
|
||||||
|
const slash = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
slash.setAttribute("d", "M4 4l16 16");
|
||||||
|
slash.setAttribute("fill", "none");
|
||||||
|
slash.setAttribute("stroke", "currentColor");
|
||||||
|
slash.setAttribute("stroke-linecap", "round");
|
||||||
|
slash.setAttribute("stroke-width", "1.9");
|
||||||
|
svg.append(slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendDebugBlock(container, label, value) {
|
||||||
|
const block = document.createElement("section");
|
||||||
|
const heading = document.createElement("h3");
|
||||||
|
const content = document.createElement("pre");
|
||||||
|
|
||||||
|
heading.textContent = label;
|
||||||
|
content.textContent = value;
|
||||||
|
block.append(heading, content);
|
||||||
|
container.append(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDebugTime(date) {
|
||||||
|
return date.toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toggleDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime };
|
||||||
|
}
|
||||||
6
src/features/debug/state.js
Normal file
6
src/features/debug/state.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
function createDebugState() {
|
||||||
|
return {
|
||||||
|
requestLog: [],
|
||||||
|
debugSecretsVisible: false
|
||||||
|
};
|
||||||
|
}
|
||||||
15
src/features/opportunities/columns.js
Normal file
15
src/features/opportunities/columns.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, formatWinProbability, getNestedValue, getOwnerName }) {
|
||||||
|
return [
|
||||||
|
{ key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name },
|
||||||
|
{ key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber },
|
||||||
|
{ key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) },
|
||||||
|
{ key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName },
|
||||||
|
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue },
|
||||||
|
{ key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) },
|
||||||
|
{ key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode },
|
||||||
|
{ key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
|
||||||
|
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c },
|
||||||
|
{ key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) },
|
||||||
|
{ key: "actions", label: "Actions", value: () => "", display: () => "", sortable: false }
|
||||||
|
];
|
||||||
|
}
|
||||||
146
src/features/opportunities/controller.js
Normal file
146
src/features/opportunities/controller.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
function createOpportunitiesController({
|
||||||
|
STAGE_OPTIONS,
|
||||||
|
STATUS_OPTIONS,
|
||||||
|
authSessionState,
|
||||||
|
consumptionPlansState,
|
||||||
|
consumptionTrendState,
|
||||||
|
getFiscalPeriodRange,
|
||||||
|
opportunitiesDashboardState,
|
||||||
|
opportunitiesFilterState,
|
||||||
|
opportunitiesQueryState,
|
||||||
|
opportunitiesTableState,
|
||||||
|
preferencesState,
|
||||||
|
productsContentState,
|
||||||
|
renderProductsProgress,
|
||||||
|
requestOpportunitiesPage,
|
||||||
|
requestOpptyProductsInBackground,
|
||||||
|
requestTokenRelayOnce,
|
||||||
|
setOpportunitiesTableState
|
||||||
|
}) {
|
||||||
|
async function requestOpportunitiesForPeriod(period) {
|
||||||
|
const dateRange = getFiscalPeriodRange(period, new Date());
|
||||||
|
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
|
||||||
|
|
||||||
|
if (preferencesState.selectedOpportunityTypeValues.size === 0) {
|
||||||
|
setOpportunitiesTableState({
|
||||||
|
items: [],
|
||||||
|
status: "empty",
|
||||||
|
message: "Select at least one Opportunity Type View in Preferences."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dateRange) {
|
||||||
|
setOpportunitiesTableState({
|
||||||
|
items: [],
|
||||||
|
status: "empty",
|
||||||
|
message: "No opportunities to display for this period."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = authSessionState.accessToken || await requestTokenRelayOnce();
|
||||||
|
|
||||||
|
if (requestVersion !== opportunitiesQueryState.periodRequestVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestOpportunities(token, period, dateRange, requestVersion);
|
||||||
|
} catch (error) {
|
||||||
|
// Authentication and request errors are recorded by their respective request handlers.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestOpportunities(token, period, dateRange, requestVersion) {
|
||||||
|
if (isCurrentPeriodRequest(requestVersion)) {
|
||||||
|
productsContentState.currentProductsRequestId = "";
|
||||||
|
productsContentState.opptyProductsByNumber = new Map();
|
||||||
|
productsContentState.expandedProductOptyNumbers = new Set();
|
||||||
|
productsContentState.productsProgress = {
|
||||||
|
state: "idle",
|
||||||
|
completed: 0,
|
||||||
|
total: 0,
|
||||||
|
failed: 0,
|
||||||
|
cacheHits: 0
|
||||||
|
};
|
||||||
|
productsContentState.productsLastCachedAt = 0;
|
||||||
|
consumptionPlansState.accountPlansRequestState = new Map();
|
||||||
|
consumptionTrendState.consumptionTrendCache = new Map();
|
||||||
|
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||||
|
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
|
||||||
|
renderProductsProgress();
|
||||||
|
setOpportunitiesTableState({
|
||||||
|
items: [],
|
||||||
|
status: "loading",
|
||||||
|
message: "Loading opportunities..."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
|
||||||
|
|
||||||
|
if (allItems && isCurrentPeriodRequest(requestVersion)) {
|
||||||
|
setOpportunitiesTableState({
|
||||||
|
items: allItems,
|
||||||
|
status: "ready",
|
||||||
|
message: ""
|
||||||
|
});
|
||||||
|
requestOpptyProductsInBackground(allItems, token);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isCurrentPeriodRequest(requestVersion)) {
|
||||||
|
setOpportunitiesTableState({
|
||||||
|
items: [],
|
||||||
|
status: "error",
|
||||||
|
message: "Unable to load opportunities."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetOpportunitiesTable() {
|
||||||
|
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
|
||||||
|
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
|
||||||
|
opportunitiesFilterState.selectedCustomerKeys = new Set();
|
||||||
|
opportunitiesFilterState.customerSearch = "";
|
||||||
|
opportunitiesFilterState.selectedOwnerKeys = new Set();
|
||||||
|
opportunitiesFilterState.ownerSearch = "";
|
||||||
|
opportunitiesFilterState.tableSearch = "";
|
||||||
|
opportunitiesFilterState.hasSavedCustomerFilter = false;
|
||||||
|
opportunitiesFilterState.hasSavedOwnerFilter = false;
|
||||||
|
opportunitiesFilterState.customerFilterInitialized = false;
|
||||||
|
opportunitiesFilterState.ownerFilterInitialized = false;
|
||||||
|
productsContentState.currentProductsRequestId = "";
|
||||||
|
productsContentState.opptyProductsByNumber = new Map();
|
||||||
|
productsContentState.expandedProductOptyNumbers = new Set();
|
||||||
|
productsContentState.productsProgress = {
|
||||||
|
state: "idle",
|
||||||
|
completed: 0,
|
||||||
|
total: 0,
|
||||||
|
failed: 0,
|
||||||
|
cacheHits: 0
|
||||||
|
};
|
||||||
|
productsContentState.productsLastCachedAt = 0;
|
||||||
|
consumptionPlansState.accountPlansRequestState = new Map();
|
||||||
|
consumptionTrendState.consumptionTrendCache = new Map();
|
||||||
|
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||||
|
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
|
||||||
|
opportunitiesDashboardState.stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]);
|
||||||
|
opportunitiesTableState.opportunitiesTableState = {
|
||||||
|
items: [],
|
||||||
|
status: "idle",
|
||||||
|
message: "",
|
||||||
|
sortKey: "",
|
||||||
|
sortDirection: "ascending"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentPeriodRequest(requestVersion) {
|
||||||
|
return requestVersion === opportunitiesQueryState.periodRequestVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestOpportunitiesForPeriod, requestOpportunities, resetOpportunitiesTable, isCurrentPeriodRequest };
|
||||||
|
}
|
||||||
5
src/features/opportunities/dashboard-state.js
Normal file
5
src/features/opportunities/dashboard-state.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
function createOpportunitiesDashboardState({ STAGE_OPTIONS } = {}) {
|
||||||
|
return {
|
||||||
|
stageDashboardAmounts: new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])])
|
||||||
|
};
|
||||||
|
}
|
||||||
119
src/features/opportunities/dashboard.js
Normal file
119
src/features/opportunities/dashboard.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
function createOpportunitiesDashboard({
|
||||||
|
STAGE_DASHBOARD_ID,
|
||||||
|
STAGE_OPTIONS,
|
||||||
|
getNestedValue,
|
||||||
|
opportunitiesDashboardState,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document, requestAnimationFrame, performance } = environment;
|
||||||
|
function renderStageDashboard(items) {
|
||||||
|
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboard.textContent = "";
|
||||||
|
|
||||||
|
const totalCard = document.createElement("section");
|
||||||
|
const totalLabel = document.createElement("span");
|
||||||
|
const totalValue = document.createElement("strong");
|
||||||
|
|
||||||
|
totalCard.className = "opportunities-extension-stage-dashboard-card opportunities-extension-total-dashboard-card";
|
||||||
|
totalCard.setAttribute("data-stage", "TOTAL");
|
||||||
|
totalLabel.textContent = "Total Opportunities";
|
||||||
|
totalValue.className = "opportunities-extension-stage-dashboard-value";
|
||||||
|
totalValue.setAttribute("aria-label", "Total opportunities");
|
||||||
|
animateStageDashboardValue(
|
||||||
|
totalValue,
|
||||||
|
"TOTAL",
|
||||||
|
items.length,
|
||||||
|
(amount) => Math.round(amount).toLocaleString()
|
||||||
|
);
|
||||||
|
totalCard.append(totalLabel, totalValue);
|
||||||
|
dashboard.append(totalCard);
|
||||||
|
|
||||||
|
STAGE_OPTIONS.forEach((stage) => {
|
||||||
|
const stageItems = items.filter((item) => item.ForecastGroup_c === stage);
|
||||||
|
const amount = stageItems.reduce((total, item) => {
|
||||||
|
const value = Number(getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]));
|
||||||
|
return Number.isFinite(value) ? total + value : total;
|
||||||
|
}, 0);
|
||||||
|
const card = document.createElement("section");
|
||||||
|
const label = document.createElement("span");
|
||||||
|
const value = document.createElement("strong");
|
||||||
|
|
||||||
|
card.className = "opportunities-extension-stage-dashboard-card";
|
||||||
|
card.setAttribute("data-stage", stage);
|
||||||
|
label.textContent = stage;
|
||||||
|
value.className = "opportunities-extension-stage-dashboard-value";
|
||||||
|
value.setAttribute("aria-label", `${stage} total amount`);
|
||||||
|
|
||||||
|
animateStageDashboardValue(
|
||||||
|
value,
|
||||||
|
stage,
|
||||||
|
amount,
|
||||||
|
createStageAmountFormatter(stageItems)
|
||||||
|
);
|
||||||
|
|
||||||
|
card.append(label, value);
|
||||||
|
dashboard.append(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStageAmountFormatter(items) {
|
||||||
|
const currencies = new Set(items
|
||||||
|
.map((item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]))
|
||||||
|
.filter(Boolean));
|
||||||
|
|
||||||
|
if (currencies.size === 1) {
|
||||||
|
const [currency] = currencies;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatter = new Intl.NumberFormat(undefined, {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
});
|
||||||
|
return (amount) => formatter.format(amount);
|
||||||
|
} catch (error) {
|
||||||
|
// Fall through to a neutral numeric total when the currency code is invalid.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (amount) => amount.toLocaleString(undefined, {
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateStageDashboardValue(element, stage, target, format) {
|
||||||
|
const current = opportunitiesDashboardState.stageDashboardAmounts.get(stage) || 0;
|
||||||
|
opportunitiesDashboardState.stageDashboardAmounts.set(stage, target);
|
||||||
|
|
||||||
|
if (current === target) {
|
||||||
|
element.textContent = format(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
const duration = 480;
|
||||||
|
|
||||||
|
function tick(now) {
|
||||||
|
const progress = Math.min((now - startTime) / duration, 1);
|
||||||
|
const easedProgress = 1 - Math.pow(1 - progress, 3);
|
||||||
|
const value = current + ((target - current) * easedProgress);
|
||||||
|
|
||||||
|
element.textContent = format(value);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
element.textContent = format(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { renderStageDashboard, createStageAmountFormatter, animateStageDashboardValue };
|
||||||
|
}
|
||||||
15
src/features/opportunities/filter-state.js
Normal file
15
src/features/opportunities/filter-state.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) {
|
||||||
|
return {
|
||||||
|
selectedStages: new Set(STAGE_OPTIONS),
|
||||||
|
selectedStatuses: new Set(STATUS_OPTIONS),
|
||||||
|
selectedCustomerKeys: new Set(),
|
||||||
|
customerSearch: "",
|
||||||
|
selectedOwnerKeys: new Set(),
|
||||||
|
ownerSearch: "",
|
||||||
|
tableSearch: "",
|
||||||
|
hasSavedCustomerFilter: false,
|
||||||
|
hasSavedOwnerFilter: false,
|
||||||
|
customerFilterInitialized: false,
|
||||||
|
ownerFilterInitialized: false
|
||||||
|
};
|
||||||
|
}
|
||||||
402
src/features/opportunities/filters.js
Normal file
402
src/features/opportunities/filters.js
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
function createOpportunitiesFilters({
|
||||||
|
CUSTOMER_FILTER_BUTTON_ID,
|
||||||
|
CUSTOMER_FILTER_LIST_ID,
|
||||||
|
CUSTOMER_FILTER_PANEL_ID,
|
||||||
|
CUSTOMER_FILTER_SEARCH_ID,
|
||||||
|
CUSTOMER_FILTER_SELECT_ALL_ID,
|
||||||
|
OPPORTUNITIES_COLUMNS,
|
||||||
|
OWNER_FILTER_BUTTON_ID,
|
||||||
|
OWNER_FILTER_LIST_ID,
|
||||||
|
OWNER_FILTER_PANEL_ID,
|
||||||
|
OWNER_FILTER_SEARCH_ID,
|
||||||
|
OWNER_FILTER_SELECT_ALL_ID,
|
||||||
|
STAGE_FILTERS_ID,
|
||||||
|
STAGE_OPTIONS,
|
||||||
|
STATUS_FILTERS_ID,
|
||||||
|
STATUS_OPTIONS,
|
||||||
|
getCustomerFilterSummary,
|
||||||
|
getCustomerKey,
|
||||||
|
getCustomerOptions,
|
||||||
|
getOwnerKey,
|
||||||
|
getOwnerOptions,
|
||||||
|
opportunitiesFilterState,
|
||||||
|
opportunitiesTableState,
|
||||||
|
renderOpportunitiesTable,
|
||||||
|
saveFilterPreferences,
|
||||||
|
stringifySearchValue,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document } = environment;
|
||||||
|
function resetCustomerAndOwnerFiltersForPeriod() {
|
||||||
|
opportunitiesFilterState.selectedCustomerKeys = new Set();
|
||||||
|
opportunitiesFilterState.selectedOwnerKeys = new Set();
|
||||||
|
opportunitiesFilterState.customerSearch = "";
|
||||||
|
opportunitiesFilterState.ownerSearch = "";
|
||||||
|
opportunitiesFilterState.hasSavedCustomerFilter = false;
|
||||||
|
opportunitiesFilterState.hasSavedOwnerFilter = false;
|
||||||
|
opportunitiesFilterState.customerFilterInitialized = false;
|
||||||
|
opportunitiesFilterState.ownerFilterInitialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStageFilterButtons() {
|
||||||
|
const controls = document.getElementById(STAGE_FILTERS_ID);
|
||||||
|
|
||||||
|
if (!controls) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.textContent = "";
|
||||||
|
|
||||||
|
STAGE_OPTIONS.forEach((stage) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
const isPressed = opportunitiesFilterState.selectedStages.has(stage);
|
||||||
|
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-stage-filter-button";
|
||||||
|
button.setAttribute("data-stage", stage);
|
||||||
|
button.setAttribute("aria-pressed", isPressed ? "true" : "false");
|
||||||
|
button.textContent = stage;
|
||||||
|
button.addEventListener("click", () => toggleStageFilter(stage));
|
||||||
|
controls.append(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStageFilter(stage) {
|
||||||
|
if (opportunitiesFilterState.selectedStages.has(stage)) {
|
||||||
|
opportunitiesFilterState.selectedStages.delete(stage);
|
||||||
|
} else {
|
||||||
|
opportunitiesFilterState.selectedStages.add(stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveFilterPreferences();
|
||||||
|
renderStageFilterButtons();
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatusFilterButtons() {
|
||||||
|
const controls = document.getElementById(STATUS_FILTERS_ID);
|
||||||
|
|
||||||
|
if (!controls) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.textContent = "";
|
||||||
|
|
||||||
|
STATUS_OPTIONS.forEach((status) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
const isPressed = opportunitiesFilterState.selectedStatuses.has(status);
|
||||||
|
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-status-filter-button";
|
||||||
|
button.setAttribute("data-status", status);
|
||||||
|
button.setAttribute("aria-pressed", isPressed ? "true" : "false");
|
||||||
|
button.textContent = status;
|
||||||
|
button.addEventListener("click", () => toggleStatusFilter(status));
|
||||||
|
controls.append(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStatusFilter(status) {
|
||||||
|
if (opportunitiesFilterState.selectedStatuses.has(status)) {
|
||||||
|
opportunitiesFilterState.selectedStatuses.delete(status);
|
||||||
|
} else {
|
||||||
|
opportunitiesFilterState.selectedStatuses.add(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveFilterPreferences();
|
||||||
|
renderStatusFilterButtons();
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCustomerFilterPanel() {
|
||||||
|
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
|
||||||
|
const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
|
||||||
|
|
||||||
|
if (!panel || !button || button.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.hidden = !panel.hidden;
|
||||||
|
button.setAttribute("aria-expanded", panel.hidden ? "false" : "true");
|
||||||
|
|
||||||
|
if (!panel.hidden) {
|
||||||
|
const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID);
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCustomerFilterPanel(restoreFocus = true) {
|
||||||
|
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
|
||||||
|
const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
|
||||||
|
|
||||||
|
if (!panel || panel.hidden) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.hidden = true;
|
||||||
|
button.setAttribute("aria-expanded", "false");
|
||||||
|
|
||||||
|
if (restoreFocus) {
|
||||||
|
button.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOwnerFilterPanel() {
|
||||||
|
const panel = document.getElementById(OWNER_FILTER_PANEL_ID);
|
||||||
|
const button = document.getElementById(OWNER_FILTER_BUTTON_ID);
|
||||||
|
|
||||||
|
if (!panel || !button || button.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.hidden = !panel.hidden;
|
||||||
|
button.setAttribute("aria-expanded", panel.hidden ? "false" : "true");
|
||||||
|
|
||||||
|
if (!panel.hidden) {
|
||||||
|
const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID);
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOwnerFilterPanel(restoreFocus = true) {
|
||||||
|
const panel = document.getElementById(OWNER_FILTER_PANEL_ID);
|
||||||
|
const button = document.getElementById(OWNER_FILTER_BUTTON_ID);
|
||||||
|
|
||||||
|
if (!panel || panel.hidden) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.hidden = true;
|
||||||
|
button.setAttribute("aria-expanded", "false");
|
||||||
|
|
||||||
|
if (restoreFocus) {
|
||||||
|
button.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCustomerFilter(items) {
|
||||||
|
const customerOptions = getCustomerOptions(items);
|
||||||
|
if (!opportunitiesFilterState.customerFilterInitialized) {
|
||||||
|
opportunitiesFilterState.selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key));
|
||||||
|
opportunitiesFilterState.customerFilterInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetOwnerFilter(items) {
|
||||||
|
const ownerOptions = getOwnerOptions(items);
|
||||||
|
if (!opportunitiesFilterState.ownerFilterInitialized) {
|
||||||
|
opportunitiesFilterState.selectedOwnerKeys = new Set(ownerOptions.map((owner) => owner.key));
|
||||||
|
opportunitiesFilterState.ownerFilterInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCustomerFilter() {
|
||||||
|
const trigger = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
|
||||||
|
const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID);
|
||||||
|
const selectAllCheckbox = document.getElementById(CUSTOMER_FILTER_SELECT_ALL_ID);
|
||||||
|
const customerOptions = getCustomerOptions();
|
||||||
|
|
||||||
|
if (!trigger || !searchInput || !selectAllCheckbox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCount = customerOptions.filter((customer) => opportunitiesFilterState.selectedCustomerKeys.has(customer.key)).length;
|
||||||
|
trigger.disabled = customerOptions.length === 0;
|
||||||
|
trigger.textContent = getCustomerFilterSummary(customerOptions.length, selectedCount);
|
||||||
|
searchInput.value = opportunitiesFilterState.customerSearch;
|
||||||
|
selectAllCheckbox.disabled = customerOptions.length === 0;
|
||||||
|
selectAllCheckbox.checked = customerOptions.length > 0 && selectedCount === customerOptions.length;
|
||||||
|
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < customerOptions.length;
|
||||||
|
renderCustomerFilterList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCustomerFilterList() {
|
||||||
|
const list = document.getElementById(CUSTOMER_FILTER_LIST_ID);
|
||||||
|
|
||||||
|
if (!list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSearch = opportunitiesFilterState.customerSearch.trim().toLocaleLowerCase();
|
||||||
|
const customerOptions = getCustomerOptions().filter((customer) => {
|
||||||
|
return customer.label.toLocaleLowerCase().includes(normalizedSearch);
|
||||||
|
});
|
||||||
|
|
||||||
|
list.textContent = "";
|
||||||
|
|
||||||
|
if (customerOptions.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "opportunities-extension-customer-filter-empty";
|
||||||
|
empty.textContent = opportunitiesFilterState.customerSearch ? "No matching customers." : "No customers available.";
|
||||||
|
list.append(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
customerOptions.forEach((customer) => {
|
||||||
|
const option = document.createElement("label");
|
||||||
|
option.className = "opportunities-extension-customer-filter-option";
|
||||||
|
|
||||||
|
const checkbox = document.createElement("input");
|
||||||
|
checkbox.type = "checkbox";
|
||||||
|
checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key);
|
||||||
|
checkbox.addEventListener("change", () => {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
|
||||||
|
} else {
|
||||||
|
opportunitiesFilterState.selectedCustomerKeys.delete(customer.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
opportunitiesFilterState.hasSavedCustomerFilter = true;
|
||||||
|
saveFilterPreferences();
|
||||||
|
renderCustomerFilter();
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = customer.label;
|
||||||
|
option.append(checkbox, label);
|
||||||
|
list.append(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOwnerFilter() {
|
||||||
|
const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID);
|
||||||
|
const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID);
|
||||||
|
const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID);
|
||||||
|
const ownerOptions = getOwnerOptions();
|
||||||
|
|
||||||
|
if (!trigger || !searchInput || !selectAllCheckbox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCount = ownerOptions.filter((owner) => opportunitiesFilterState.selectedOwnerKeys.has(owner.key)).length;
|
||||||
|
trigger.disabled = ownerOptions.length === 0;
|
||||||
|
trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount)
|
||||||
|
.replace("customer", "owner");
|
||||||
|
searchInput.value = opportunitiesFilterState.ownerSearch;
|
||||||
|
selectAllCheckbox.disabled = ownerOptions.length === 0;
|
||||||
|
selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length;
|
||||||
|
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length;
|
||||||
|
renderOwnerFilterList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOwnerFilterList() {
|
||||||
|
const list = document.getElementById(OWNER_FILTER_LIST_ID);
|
||||||
|
|
||||||
|
if (!list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSearch = opportunitiesFilterState.ownerSearch.trim().toLocaleLowerCase();
|
||||||
|
const ownerOptions = getOwnerOptions().filter((owner) => {
|
||||||
|
return owner.label.toLocaleLowerCase().includes(normalizedSearch);
|
||||||
|
});
|
||||||
|
|
||||||
|
list.textContent = "";
|
||||||
|
|
||||||
|
if (ownerOptions.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "opportunities-extension-customer-filter-empty";
|
||||||
|
empty.textContent = opportunitiesFilterState.ownerSearch ? "No matching owners." : "No owners available.";
|
||||||
|
list.append(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerOptions.forEach((owner) => {
|
||||||
|
const option = document.createElement("label");
|
||||||
|
option.className = "opportunities-extension-customer-filter-option";
|
||||||
|
|
||||||
|
const checkbox = document.createElement("input");
|
||||||
|
checkbox.type = "checkbox";
|
||||||
|
checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key);
|
||||||
|
checkbox.addEventListener("change", () => {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
|
||||||
|
} else {
|
||||||
|
opportunitiesFilterState.selectedOwnerKeys.delete(owner.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
opportunitiesFilterState.hasSavedOwnerFilter = true;
|
||||||
|
saveFilterPreferences();
|
||||||
|
renderOwnerFilter();
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = owner.label;
|
||||||
|
option.append(checkbox, label);
|
||||||
|
list.append(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortedOpportunities() {
|
||||||
|
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
|
||||||
|
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
|
||||||
|
return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c)
|
||||||
|
&& matchesSelectedStatus(item.StatusCode)
|
||||||
|
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))
|
||||||
|
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))
|
||||||
|
&& matchesTableSearch(item, normalizedSearch);
|
||||||
|
});
|
||||||
|
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
|
||||||
|
|
||||||
|
if (!column) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending" ? 1 : -1;
|
||||||
|
|
||||||
|
return items.sort((first, second) => {
|
||||||
|
const firstValue = column.value(first);
|
||||||
|
const secondValue = column.value(second);
|
||||||
|
const firstIsEmpty = firstValue === null || firstValue === undefined || firstValue === "";
|
||||||
|
const secondIsEmpty = secondValue === null || secondValue === undefined || secondValue === "";
|
||||||
|
|
||||||
|
if (firstIsEmpty || secondIsEmpty) {
|
||||||
|
if (firstIsEmpty && secondIsEmpty) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstIsEmpty ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstNumber = Number(firstValue);
|
||||||
|
const secondNumber = Number(secondValue);
|
||||||
|
|
||||||
|
if (Number.isFinite(firstNumber) && Number.isFinite(secondNumber)) {
|
||||||
|
return (firstNumber - secondNumber) * direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(firstValue).localeCompare(String(secondValue), undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: "base"
|
||||||
|
}) * direction;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesTableSearch(item, normalizedSearch) {
|
||||||
|
if (!normalizedSearch) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OPPORTUNITIES_COLUMNS.some((column) => {
|
||||||
|
const rawValue = stringifySearchValue(column.value(item));
|
||||||
|
const displayValue = stringifySearchValue(column.display(item));
|
||||||
|
return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSelectedStatus(statusCode) {
|
||||||
|
if (statusCode === "WON_PENDING") {
|
||||||
|
return opportunitiesFilterState.selectedStatuses.has("WON");
|
||||||
|
}
|
||||||
|
|
||||||
|
return opportunitiesFilterState.selectedStatuses.has(statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resetCustomerAndOwnerFiltersForPeriod, renderStageFilterButtons, toggleStageFilter, renderStatusFilterButtons, toggleStatusFilter, toggleCustomerFilterPanel, closeCustomerFilterPanel, toggleOwnerFilterPanel, closeOwnerFilterPanel, resetCustomerFilter, resetOwnerFilter, renderCustomerFilter, renderCustomerFilterList, renderOwnerFilter, renderOwnerFilterList, getSortedOpportunities, matchesTableSearch, matchesSelectedStatus };
|
||||||
|
}
|
||||||
55
src/features/opportunities/fiscal-periods.js
Normal file
55
src/features/opportunities/fiscal-periods.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
function createOpportunitiesFiscalPeriods() {
|
||||||
|
function getFiscalPeriodRange(period, currentDate) {
|
||||||
|
const currentYear = currentDate.getFullYear();
|
||||||
|
const currentMonth = currentDate.getMonth();
|
||||||
|
const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1;
|
||||||
|
const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3);
|
||||||
|
|
||||||
|
if (period === "Current Fiscal Year") {
|
||||||
|
const isBeforeFiscalYearStart = currentMonth < 5;
|
||||||
|
const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear;
|
||||||
|
const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1;
|
||||||
|
return createDateRange(startYear, 5, endYear, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
let quarterOffset;
|
||||||
|
let numberOfQuarters = 1;
|
||||||
|
|
||||||
|
if (period === "Current Quarter") {
|
||||||
|
quarterOffset = currentQuarterIndex;
|
||||||
|
} else if (period === "Next Quarter") {
|
||||||
|
quarterOffset = currentQuarterIndex + 1;
|
||||||
|
} else if (period === "Previous Quarter") {
|
||||||
|
quarterOffset = currentQuarterIndex - 1;
|
||||||
|
} else if (period === "4 Rolling Quarters (CQ + 3)") {
|
||||||
|
quarterOffset = currentQuarterIndex;
|
||||||
|
numberOfQuarters = 4;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1);
|
||||||
|
const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startCloseDate: formatOracleDate(start),
|
||||||
|
endCloseDate: formatOracleDate(end)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDateRange(startYear, startMonth, endYear, endMonth) {
|
||||||
|
return {
|
||||||
|
startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)),
|
||||||
|
endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOracleDate(date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}T00:00:00.000Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getFiscalPeriodRange, createDateRange, formatOracleDate };
|
||||||
|
}
|
||||||
174
src/features/opportunities/formatters.js
Normal file
174
src/features/opportunities/formatters.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
function createOpportunitiesFormatters({
|
||||||
|
opportunitiesTableState
|
||||||
|
}) {
|
||||||
|
function getCustomerOptions(items) {
|
||||||
|
const optionsByKey = new Map();
|
||||||
|
|
||||||
|
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
|
||||||
|
const key = getCustomerKey(item);
|
||||||
|
const label = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]);
|
||||||
|
|
||||||
|
if (key && label && !optionsByKey.has(key)) {
|
||||||
|
optionsByKey.set(key, { key, label: String(label) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(optionsByKey.values()).sort((first, second) => {
|
||||||
|
return first.label.localeCompare(second.label, undefined, {
|
||||||
|
sensitivity: "base"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomerKey(item) {
|
||||||
|
const partyId = getNestedValue(item, ["CustomerAccount", "PartyId"]);
|
||||||
|
const partyName = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]);
|
||||||
|
|
||||||
|
if (partyId !== null && partyId !== undefined && partyId !== "") {
|
||||||
|
return `party:${partyId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return partyName ? `name:${partyName}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOwnerName(item) {
|
||||||
|
const owner = item.Owner;
|
||||||
|
|
||||||
|
if (typeof owner === "string" || typeof owner === "number") {
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (owner && typeof owner === "object") {
|
||||||
|
const firstName = owner.PersonFirstName;
|
||||||
|
const lastName = owner.PersonLastName;
|
||||||
|
|
||||||
|
if (firstName || lastName) {
|
||||||
|
return [firstName, lastName].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return owner.PartyName
|
||||||
|
|| owner.PartyUniqueName
|
||||||
|
|| owner.Name
|
||||||
|
|| owner.DisplayName
|
||||||
|
|| owner.ResourceName
|
||||||
|
|| owner.OwnerName
|
||||||
|
|| "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomerFilterSummary(total, selected) {
|
||||||
|
if (total === 0) {
|
||||||
|
return "No customers";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected === total) {
|
||||||
|
return "All customers";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected === 0) {
|
||||||
|
return "No customers";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${selected} customer${selected === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOwnerOptions(items) {
|
||||||
|
const optionsByKey = new Map();
|
||||||
|
|
||||||
|
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
|
||||||
|
const key = getOwnerKey(item);
|
||||||
|
const label = getOwnerName(item);
|
||||||
|
|
||||||
|
if (key && label && !optionsByKey.has(key)) {
|
||||||
|
optionsByKey.set(key, { key, label: String(label) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(optionsByKey.values()).sort((first, second) => {
|
||||||
|
return first.label.localeCompare(second.label, undefined, {
|
||||||
|
sensitivity: "base"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOwnerKey(item) {
|
||||||
|
const owner = item.Owner;
|
||||||
|
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
|
||||||
|
const name = getOwnerName(item);
|
||||||
|
|
||||||
|
if (partyId !== null && partyId !== undefined && partyId !== "") {
|
||||||
|
return `party:${partyId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return name ? `name:${name}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifySearchValue(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object") {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayOpportunityValue(column, item) {
|
||||||
|
const value = column.display(item);
|
||||||
|
return value === null || value === undefined || value === "" ? "-" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNestedValue(value, path) {
|
||||||
|
return path.reduce((result, key) => result && result[key], value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWinProbability(item) {
|
||||||
|
const value = getNestedValue(item, ["PrimaryRevenue", "WinProb"]);
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? `${number}%` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRevenue(item) {
|
||||||
|
const amount = getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]);
|
||||||
|
const currency = getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]);
|
||||||
|
const number = Number(amount);
|
||||||
|
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currency) {
|
||||||
|
return number.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
style: "currency",
|
||||||
|
currency
|
||||||
|
}).format(number);
|
||||||
|
} catch (error) {
|
||||||
|
return `${currency} ${number.toLocaleString()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOracleResponseDate(value, includeTime) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = `${match[3]}/${match[2]}/${match[1]}`;
|
||||||
|
return includeTime && match[4] ? `${date} ${match[4]}:${match[5]}` : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getCustomerOptions, getCustomerKey, getOwnerName, getCustomerFilterSummary, getOwnerOptions, getOwnerKey, stringifySearchValue, displayOpportunityValue, getNestedValue, formatWinProbability, formatRevenue, formatOracleResponseDate };
|
||||||
|
}
|
||||||
118
src/features/opportunities/links.js
Normal file
118
src/features/opportunities/links.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
function createOpportunitiesLinks({
|
||||||
|
MODAL_ID,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document, navigator, window } = environment;
|
||||||
|
function createDetailLink(label, href) {
|
||||||
|
if (label === null || label === undefined || label === "") {
|
||||||
|
const placeholder = document.createElement("span");
|
||||||
|
placeholder.textContent = "-";
|
||||||
|
return placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!href) {
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.textContent = String(label);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = href;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
link.textContent = String(label);
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCopyOpportunityButton(optyNumber) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-copy-button";
|
||||||
|
button.setAttribute("aria-label", `Copy opportunity number ${optyNumber}`);
|
||||||
|
button.title = "Copy opportunity number";
|
||||||
|
button.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
copyOpportunityNumber(String(optyNumber));
|
||||||
|
});
|
||||||
|
|
||||||
|
icon.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
path.setAttribute("d", "M8 8V5.5A2.5 2.5 0 0 1 10.5 3h8A2.5 2.5 0 0 1 21 5.5v8a2.5 2.5 0 0 1-2.5 2.5H16v2.5a2.5 2.5 0 0 1-2.5 2.5h-8A2.5 2.5 0 0 1 3 18.5v-8A2.5 2.5 0 0 1 5.5 8H8zm2 0h3.5A2.5 2.5 0 0 1 16 10.5v3.5h2.5a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5V8zm3.5 2h-8a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5z");
|
||||||
|
icon.append(path);
|
||||||
|
button.append(icon);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyOpportunityNumber(optyNumber) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(optyNumber);
|
||||||
|
} else {
|
||||||
|
const input = document.createElement("textarea");
|
||||||
|
input.value = optyNumber;
|
||||||
|
input.setAttribute("readonly", "true");
|
||||||
|
input.style.position = "fixed";
|
||||||
|
input.style.opacity = "0";
|
||||||
|
document.body.append(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
input.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
showCopyConfirmation(`Opty Number ${optyNumber} copied.`);
|
||||||
|
} catch (error) {
|
||||||
|
showCopyConfirmation("Unable to copy opportunity number.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCopyConfirmation(message, isError = false) {
|
||||||
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
|
if (!modal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let toast = modal.querySelector(".opportunities-extension-copy-toast");
|
||||||
|
|
||||||
|
if (!toast) {
|
||||||
|
toast = document.createElement("div");
|
||||||
|
toast.className = "opportunities-extension-copy-toast";
|
||||||
|
toast.setAttribute("role", "status");
|
||||||
|
modal.append(toast);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.classList.toggle("opportunities-extension-copy-toast-error", isError);
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.hidden = false;
|
||||||
|
window.clearTimeout(toast.copyTimeout);
|
||||||
|
toast.copyTimeout = window.setTimeout(() => {
|
||||||
|
toast.hidden = true;
|
||||||
|
}, 2400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStageBadge(stage) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
const normalizedStage = typeof stage === "string" ? stage.toUpperCase() : "";
|
||||||
|
|
||||||
|
badge.className = "opportunities-extension-stage-badge";
|
||||||
|
badge.setAttribute("data-stage", normalizedStage);
|
||||||
|
badge.textContent = normalizedStage || "-";
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStatusBadge(status) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
const normalizedStatus = typeof status === "string" ? status.toUpperCase() : "";
|
||||||
|
|
||||||
|
badge.className = "opportunities-extension-status-badge";
|
||||||
|
badge.setAttribute("data-status", normalizedStatus);
|
||||||
|
badge.textContent = normalizedStatus || "-";
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
|
||||||
|
}
|
||||||
5
src/features/opportunities/query-state.js
Normal file
5
src/features/opportunities/query-state.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
function createOpportunitiesQueryState() {
|
||||||
|
return {
|
||||||
|
periodRequestVersion: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
11
src/features/opportunities/table-state.js
Normal file
11
src/features/opportunities/table-state.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
function createOpportunitiesTableState() {
|
||||||
|
return {
|
||||||
|
opportunitiesTableState: {
|
||||||
|
items: [],
|
||||||
|
status: "idle",
|
||||||
|
message: "",
|
||||||
|
sortKey: "",
|
||||||
|
sortDirection: "ascending"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
176
src/features/opportunities/table.js
Normal file
176
src/features/opportunities/table.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
function createOpportunitiesTable({
|
||||||
|
ACCOUNT_DETAIL_URL,
|
||||||
|
OPPORTUNITIES_COLUMNS,
|
||||||
|
OPPORTUNITIES_TABLE_ID,
|
||||||
|
OPPORTUNITY_DETAIL_URL,
|
||||||
|
createCloudConsumptionButton,
|
||||||
|
createCopyOpportunityButton,
|
||||||
|
createDetailLink,
|
||||||
|
createProductsCountButton,
|
||||||
|
createProductsDetailRow,
|
||||||
|
createStageBadge,
|
||||||
|
createStatusBadge,
|
||||||
|
displayOpportunityValue,
|
||||||
|
getNestedValue,
|
||||||
|
getSortedOpportunities,
|
||||||
|
opportunitiesTableState,
|
||||||
|
productsContentState,
|
||||||
|
renderCustomerFilter,
|
||||||
|
renderOwnerFilter,
|
||||||
|
renderStageDashboard,
|
||||||
|
resetCustomerFilter,
|
||||||
|
resetOwnerFilter,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document } = environment;
|
||||||
|
function setOpportunitiesTableState(nextState) {
|
||||||
|
if (nextState.status === "ready" && Array.isArray(nextState.items)) {
|
||||||
|
resetCustomerFilter(nextState.items);
|
||||||
|
resetOwnerFilter(nextState.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
opportunitiesTableState.opportunitiesTableState = {
|
||||||
|
...opportunitiesTableState.opportunitiesTableState,
|
||||||
|
...nextState,
|
||||||
|
sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey,
|
||||||
|
sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection
|
||||||
|
};
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
renderCustomerFilter();
|
||||||
|
renderOwnerFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOpportunitiesTable() {
|
||||||
|
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
|
||||||
|
|
||||||
|
if (!table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.textContent = "";
|
||||||
|
table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false");
|
||||||
|
|
||||||
|
const caption = document.createElement("caption");
|
||||||
|
caption.className = "opportunities-extension-visually-hidden";
|
||||||
|
caption.textContent = "Opportunities";
|
||||||
|
|
||||||
|
const tableHead = document.createElement("thead");
|
||||||
|
const headerRow = document.createElement("tr");
|
||||||
|
|
||||||
|
OPPORTUNITIES_COLUMNS.forEach((column) => {
|
||||||
|
const header = document.createElement("th");
|
||||||
|
const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key;
|
||||||
|
header.scope = "col";
|
||||||
|
|
||||||
|
if (column.sortable === false) {
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "opportunities-extension-table-header-label";
|
||||||
|
label.textContent = column.label;
|
||||||
|
header.append(label);
|
||||||
|
} else {
|
||||||
|
header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
|
||||||
|
|
||||||
|
const sortButton = document.createElement("button");
|
||||||
|
sortButton.type = "button";
|
||||||
|
sortButton.className = "opportunities-extension-sort-button";
|
||||||
|
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
|
||||||
|
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
|
||||||
|
sortButton.textContent = column.label;
|
||||||
|
sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key));
|
||||||
|
header.append(sortButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
headerRow.append(header);
|
||||||
|
});
|
||||||
|
|
||||||
|
tableHead.append(headerRow);
|
||||||
|
|
||||||
|
const tableBody = document.createElement("tbody");
|
||||||
|
const items = getSortedOpportunities();
|
||||||
|
renderStageDashboard(items);
|
||||||
|
|
||||||
|
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.className = "opportunities-extension-table-status";
|
||||||
|
cell.colSpan = OPPORTUNITIES_COLUMNS.length;
|
||||||
|
cell.textContent = opportunitiesTableState.opportunitiesTableState.message || "No opportunities found.";
|
||||||
|
row.append(cell);
|
||||||
|
tableBody.append(row);
|
||||||
|
} else {
|
||||||
|
items.forEach((item) => {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
|
||||||
|
OPPORTUNITIES_COLUMNS.forEach((column) => {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
|
||||||
|
if (column.key === "stage") {
|
||||||
|
cell.append(createStageBadge(column.display(item)));
|
||||||
|
} else if (column.key === "status") {
|
||||||
|
cell.append(createStatusBadge(column.display(item)));
|
||||||
|
} else if (column.key === "actions") {
|
||||||
|
const actionsContent = document.createElement("span");
|
||||||
|
actionsContent.className = "opportunities-extension-actions-content";
|
||||||
|
const productsResult = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
|
||||||
|
|
||||||
|
if (productsResult) {
|
||||||
|
actionsContent.append(createProductsCountButton(item.OptyNumber, productsResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsContent.append(createCloudConsumptionButton(item));
|
||||||
|
cell.append(actionsContent);
|
||||||
|
} else if (column.key === "name" || column.key === "optyNumber") {
|
||||||
|
const opportunityLink = createDetailLink(
|
||||||
|
column.display(item),
|
||||||
|
item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : ""
|
||||||
|
);
|
||||||
|
|
||||||
|
if (column.key === "name" && item.OptyNumber) {
|
||||||
|
const nameCellContent = document.createElement("span");
|
||||||
|
nameCellContent.className = "opportunities-extension-name-content";
|
||||||
|
nameCellContent.append(opportunityLink);
|
||||||
|
cell.append(nameCellContent);
|
||||||
|
} else if (column.key === "optyNumber" && item.OptyNumber) {
|
||||||
|
const opportunityCellContent = document.createElement("span");
|
||||||
|
opportunityCellContent.className = "opportunities-extension-opty-number-content";
|
||||||
|
opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber));
|
||||||
|
cell.append(opportunityCellContent);
|
||||||
|
} else {
|
||||||
|
cell.append(opportunityLink);
|
||||||
|
}
|
||||||
|
} else if (column.key === "customer") {
|
||||||
|
cell.append(createDetailLink(
|
||||||
|
column.display(item),
|
||||||
|
getNestedValue(item, ["CustomerAccount", "PartyId"])
|
||||||
|
? `${ACCOUNT_DETAIL_URL}${encodeURIComponent(getNestedValue(item, ["CustomerAccount", "PartyId"]))}`
|
||||||
|
: ""
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
cell.textContent = displayOpportunityValue(column, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.append(cell);
|
||||||
|
});
|
||||||
|
|
||||||
|
tableBody.append(row);
|
||||||
|
|
||||||
|
if (item.OptyNumber && productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber))) {
|
||||||
|
tableBody.append(createProductsDetailRow(item));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
table.append(caption, tableHead, tableBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortOpportunitiesBy(key) {
|
||||||
|
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
|
||||||
|
opportunitiesTableState.opportunitiesTableState.sortKey = key;
|
||||||
|
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
|
||||||
|
? "descending"
|
||||||
|
: "ascending";
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { setOpportunitiesTableState, renderOpportunitiesTable, sortOpportunitiesBy };
|
||||||
|
}
|
||||||
6
src/features/preferences/state.js
Normal file
6
src/features/preferences/state.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
function createPreferencesState({ DEFAULT_OPPORTUNITY_TYPE_VALUES, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS } = {}) {
|
||||||
|
return {
|
||||||
|
selectedOpportunityTypeValues: new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES),
|
||||||
|
opptyProductsCacheHours: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS
|
||||||
|
};
|
||||||
|
}
|
||||||
124
src/features/preferences/storage.js
Normal file
124
src/features/preferences/storage.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
function createPreferencesStorage({
|
||||||
|
DEFAULT_OPPORTUNITY_TYPE_VALUES,
|
||||||
|
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
|
||||||
|
FILTER_PREFERENCES_STORAGE_KEY,
|
||||||
|
MODAL_ID,
|
||||||
|
OPPORTUNITY_TYPE_OPTIONS,
|
||||||
|
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
|
||||||
|
OPPTY_PRODUCTS_CACHE_OPTIONS,
|
||||||
|
PERIOD_OPTIONS,
|
||||||
|
STAGE_OPTIONS,
|
||||||
|
STATUS_OPTIONS,
|
||||||
|
opportunitiesFilterState,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document } = environment;
|
||||||
|
const getLocalStorage = () => environment.localStorage;
|
||||||
|
function restoreFilterPreferences(select) {
|
||||||
|
try {
|
||||||
|
const rawPreferences = getLocalStorage().getItem(FILTER_PREFERENCES_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!rawPreferences) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = JSON.parse(rawPreferences);
|
||||||
|
const savedStages = Array.isArray(preferences.stages)
|
||||||
|
? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage))
|
||||||
|
: null;
|
||||||
|
const savedStatuses = Array.isArray(preferences.statuses)
|
||||||
|
? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (savedStages) {
|
||||||
|
opportunitiesFilterState.selectedStages = new Set(savedStages);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedStatuses) {
|
||||||
|
opportunitiesFilterState.selectedStatuses = new Set(savedStatuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(preferences.customerKeys)) {
|
||||||
|
opportunitiesFilterState.selectedCustomerKeys = new Set(preferences.customerKeys.map(String));
|
||||||
|
opportunitiesFilterState.hasSavedCustomerFilter = true;
|
||||||
|
opportunitiesFilterState.customerFilterInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(preferences.ownerKeys)) {
|
||||||
|
opportunitiesFilterState.selectedOwnerKeys = new Set(preferences.ownerKeys.map(String));
|
||||||
|
opportunitiesFilterState.hasSavedOwnerFilter = true;
|
||||||
|
opportunitiesFilterState.ownerFilterInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
opportunitiesFilterState.customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : "";
|
||||||
|
opportunitiesFilterState.ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : "";
|
||||||
|
opportunitiesFilterState.tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : "";
|
||||||
|
|
||||||
|
if (PERIOD_OPTIONS.includes(preferences.period)) {
|
||||||
|
select.value = preferences.period;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore malformed or unavailable browser storage and use default filters.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFilterPreferences(period) {
|
||||||
|
try {
|
||||||
|
const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0];
|
||||||
|
getLocalStorage().setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({
|
||||||
|
period: currentPeriod,
|
||||||
|
stages: Array.from(opportunitiesFilterState.selectedStages),
|
||||||
|
statuses: Array.from(opportunitiesFilterState.selectedStatuses),
|
||||||
|
customerKeys: opportunitiesFilterState.hasSavedCustomerFilter ? Array.from(opportunitiesFilterState.selectedCustomerKeys) : null,
|
||||||
|
ownerKeys: opportunitiesFilterState.hasSavedOwnerFilter ? Array.from(opportunitiesFilterState.selectedOwnerKeys) : null,
|
||||||
|
customerSearch: opportunitiesFilterState.customerSearch,
|
||||||
|
ownerSearch: opportunitiesFilterState.ownerSearch,
|
||||||
|
tableSearch: opportunitiesFilterState.tableSearch
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore unavailable browser storage; filters still work for the current modal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSavedOpportunityTypeValues() {
|
||||||
|
try {
|
||||||
|
const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!rawPreferences) {
|
||||||
|
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = JSON.parse(rawPreferences);
|
||||||
|
const values = Array.isArray(preferences.opportunityTypeValues) ? preferences.opportunityTypeValues : null;
|
||||||
|
|
||||||
|
if (!values) {
|
||||||
|
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedValues = new Set(OPPORTUNITY_TYPE_OPTIONS.map((option) => option.value));
|
||||||
|
return values.filter((value) => allowedValues.has(value));
|
||||||
|
} catch (error) {
|
||||||
|
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSavedOpptyProductsCacheHours() {
|
||||||
|
try {
|
||||||
|
const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!rawPreferences) {
|
||||||
|
return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = JSON.parse(rawPreferences);
|
||||||
|
const cacheHours = Number(preferences.opptyProductsCacheHours);
|
||||||
|
return OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === cacheHours)
|
||||||
|
? cacheHours
|
||||||
|
: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
|
||||||
|
} catch (error) {
|
||||||
|
return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { restoreFilterPreferences, saveFilterPreferences, getSavedOpportunityTypeValues, getSavedOpptyProductsCacheHours };
|
||||||
|
}
|
||||||
165
src/features/preferences/view.js
Normal file
165
src/features/preferences/view.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
function createPreferencesView({
|
||||||
|
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
|
||||||
|
MODAL_ID,
|
||||||
|
OPPORTUNITY_TYPE_OPTIONS,
|
||||||
|
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
|
||||||
|
OPPTY_PRODUCTS_CACHE_OPTIONS,
|
||||||
|
PERIOD_OPTIONS,
|
||||||
|
PREFERENCES_MODAL_ID,
|
||||||
|
createCloseIcon,
|
||||||
|
getSavedOpportunityTypeValues,
|
||||||
|
getSavedOpptyProductsCacheHours,
|
||||||
|
preferencesState,
|
||||||
|
requestOpportunitiesForPeriod,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document } = environment;
|
||||||
|
const getLocalStorage = () => environment.localStorage;
|
||||||
|
function openPreferencesModal() {
|
||||||
|
const overlay = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
|
if (!overlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
|
||||||
|
|
||||||
|
if (existingModal) {
|
||||||
|
const savedValues = new Set(getSavedOpportunityTypeValues());
|
||||||
|
existingModal.querySelectorAll("input[name='opportunityTypeView']").forEach((checkbox) => {
|
||||||
|
checkbox.checked = savedValues.has(checkbox.value);
|
||||||
|
});
|
||||||
|
const cacheSelect = existingModal.querySelector("select[name='opptyProductsCacheHours']");
|
||||||
|
|
||||||
|
if (cacheSelect) {
|
||||||
|
cacheSelect.value = String(getSavedOpptyProductsCacheHours());
|
||||||
|
}
|
||||||
|
|
||||||
|
existingModal.hidden = false;
|
||||||
|
existingModal.querySelector("input")?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferencesModal = document.createElement("section");
|
||||||
|
preferencesModal.id = PREFERENCES_MODAL_ID;
|
||||||
|
preferencesModal.className = "opportunities-extension-preferences-modal";
|
||||||
|
preferencesModal.setAttribute("role", "dialog");
|
||||||
|
preferencesModal.setAttribute("aria-modal", "true");
|
||||||
|
preferencesModal.setAttribute("aria-labelledby", "opportunities-extension-preferences-title");
|
||||||
|
|
||||||
|
const dialog = document.createElement("div");
|
||||||
|
dialog.className = "opportunities-extension-preferences-dialog";
|
||||||
|
|
||||||
|
const header = document.createElement("header");
|
||||||
|
header.className = "opportunities-extension-preferences-header";
|
||||||
|
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.id = "opportunities-extension-preferences-title";
|
||||||
|
title.textContent = "Preferences";
|
||||||
|
|
||||||
|
const closeButton = document.createElement("button");
|
||||||
|
closeButton.type = "button";
|
||||||
|
closeButton.className = "opportunities-extension-icon-button";
|
||||||
|
closeButton.setAttribute("aria-label", "Fechar preferências");
|
||||||
|
closeButton.title = "Close";
|
||||||
|
closeButton.append(createCloseIcon());
|
||||||
|
closeButton.addEventListener("click", closePreferencesModal);
|
||||||
|
header.append(title, closeButton);
|
||||||
|
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.className = "opportunities-extension-preferences-content";
|
||||||
|
|
||||||
|
const fieldset = document.createElement("fieldset");
|
||||||
|
fieldset.className = "opportunities-extension-preferences-fieldset";
|
||||||
|
|
||||||
|
const legend = document.createElement("legend");
|
||||||
|
legend.textContent = "Opportunity Type View";
|
||||||
|
fieldset.append(legend);
|
||||||
|
|
||||||
|
const selectedValues = new Set(getSavedOpportunityTypeValues());
|
||||||
|
const options = document.createElement("div");
|
||||||
|
options.className = "opportunities-extension-preferences-options";
|
||||||
|
|
||||||
|
OPPORTUNITY_TYPE_OPTIONS.forEach((option) => {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "opportunities-extension-preferences-option";
|
||||||
|
|
||||||
|
const checkbox = document.createElement("input");
|
||||||
|
checkbox.type = "checkbox";
|
||||||
|
checkbox.name = "opportunityTypeView";
|
||||||
|
checkbox.value = option.value;
|
||||||
|
checkbox.checked = selectedValues.has(option.value);
|
||||||
|
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.textContent = option.label;
|
||||||
|
label.append(checkbox, text);
|
||||||
|
options.append(label);
|
||||||
|
});
|
||||||
|
|
||||||
|
fieldset.append(options);
|
||||||
|
|
||||||
|
const cacheField = document.createElement("label");
|
||||||
|
cacheField.className = "opportunities-extension-preferences-cache-field";
|
||||||
|
|
||||||
|
const cacheLabel = document.createElement("span");
|
||||||
|
cacheLabel.textContent = "Product cache duration";
|
||||||
|
|
||||||
|
const cacheSelect = document.createElement("select");
|
||||||
|
cacheSelect.name = "opptyProductsCacheHours";
|
||||||
|
cacheSelect.setAttribute("aria-label", "Product cache duration");
|
||||||
|
OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => {
|
||||||
|
const optionElement = document.createElement("option");
|
||||||
|
optionElement.value = String(option.value);
|
||||||
|
optionElement.textContent = option.label;
|
||||||
|
optionElement.selected = option.value === getSavedOpptyProductsCacheHours();
|
||||||
|
cacheSelect.append(optionElement);
|
||||||
|
});
|
||||||
|
cacheField.append(cacheLabel, cacheSelect);
|
||||||
|
content.append(fieldset, cacheField);
|
||||||
|
|
||||||
|
const footer = document.createElement("footer");
|
||||||
|
footer.className = "opportunities-extension-preferences-footer";
|
||||||
|
|
||||||
|
const saveButton = document.createElement("button");
|
||||||
|
saveButton.type = "button";
|
||||||
|
saveButton.className = "opportunities-extension-button opportunities-extension-button-primary";
|
||||||
|
saveButton.textContent = "Save";
|
||||||
|
saveButton.addEventListener("click", () => {
|
||||||
|
const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value);
|
||||||
|
const selectedCacheHours = Number(preferencesModal.querySelector("select[name='opptyProductsCacheHours']")?.value);
|
||||||
|
preferencesState.selectedOpportunityTypeValues = new Set(selectedValues);
|
||||||
|
preferencesState.opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours)
|
||||||
|
? selectedCacheHours
|
||||||
|
: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
getLocalStorage().setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
|
||||||
|
opportunityTypeValues: selectedValues,
|
||||||
|
opptyProductsCacheHours: preferencesState.opptyProductsCacheHours
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
// Continue with the saved selection for the active modal when storage is unavailable.
|
||||||
|
}
|
||||||
|
|
||||||
|
closePreferencesModal();
|
||||||
|
const periodSelect = overlay.querySelector("select[name='opportunityPeriod']");
|
||||||
|
requestOpportunitiesForPeriod(periodSelect?.value || PERIOD_OPTIONS[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
footer.append(saveButton);
|
||||||
|
dialog.append(header, content, footer);
|
||||||
|
preferencesModal.append(dialog);
|
||||||
|
overlay.append(preferencesModal);
|
||||||
|
preferencesModal.querySelector("input")?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePreferencesModal() {
|
||||||
|
const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID);
|
||||||
|
|
||||||
|
if (preferencesModal) {
|
||||||
|
preferencesModal.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { openPreferencesModal, closePreferencesModal };
|
||||||
|
}
|
||||||
107
src/features/products/background-jobs.js
Normal file
107
src/features/products/background-jobs.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
function createProductsBackgroundJobs({
|
||||||
|
messageTypes,
|
||||||
|
OPPTY_PRODUCTS_CONCURRENCY,
|
||||||
|
getOpptyProducts,
|
||||||
|
normalizeCacheMaxAge,
|
||||||
|
tabsSendMessage
|
||||||
|
}) {
|
||||||
|
async function requestOpptyProducts(message, sender) {
|
||||||
|
const requestId = typeof message.requestId === "string" ? message.requestId : "";
|
||||||
|
const accessToken = typeof message.accessToken === "string" ? message.accessToken : "";
|
||||||
|
const cacheMaxAgeMs = normalizeCacheMaxAge(message.cacheMaxAgeMs);
|
||||||
|
const optyNumbers = Array.from(new Set(
|
||||||
|
(Array.isArray(message.optyNumbers) ? message.optyNumbers : [])
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(String)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!requestId || !accessToken) {
|
||||||
|
throw new Error("A request id and access token are required for requestOpptyProducts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextIndex = 0;
|
||||||
|
let completed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let cacheHits = 0;
|
||||||
|
|
||||||
|
const runWorker = async () => {
|
||||||
|
while (nextIndex < optyNumbers.length) {
|
||||||
|
const index = nextIndex;
|
||||||
|
nextIndex += 1;
|
||||||
|
const optyNumber = optyNumbers[index];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs);
|
||||||
|
completed += 1;
|
||||||
|
|
||||||
|
if (result.fromCache) {
|
||||||
|
cacheHits += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendProductsProgress(sender, {
|
||||||
|
type: messageTypes.opptyProductsProgress,
|
||||||
|
requestId,
|
||||||
|
state: "running",
|
||||||
|
optyNumber,
|
||||||
|
items: result.items,
|
||||||
|
fromCache: result.fromCache,
|
||||||
|
cachedAt: result.cachedAt,
|
||||||
|
completed,
|
||||||
|
total: optyNumbers.length,
|
||||||
|
failed,
|
||||||
|
cacheHits
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
completed += 1;
|
||||||
|
failed += 1;
|
||||||
|
await sendProductsProgress(sender, {
|
||||||
|
type: messageTypes.opptyProductsProgress,
|
||||||
|
requestId,
|
||||||
|
state: "running",
|
||||||
|
optyNumber,
|
||||||
|
items: [],
|
||||||
|
error: error.message || "Unable to load products.",
|
||||||
|
completed,
|
||||||
|
total: optyNumbers.length,
|
||||||
|
failed,
|
||||||
|
cacheHits
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
|
||||||
|
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
||||||
|
|
||||||
|
const state = failed > 0 ? "complete-with-errors" : "complete";
|
||||||
|
await sendProductsProgress(sender, {
|
||||||
|
type: messageTypes.opptyProductsProgress,
|
||||||
|
requestId,
|
||||||
|
state,
|
||||||
|
completed,
|
||||||
|
total: optyNumbers.length,
|
||||||
|
failed,
|
||||||
|
cacheHits
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: failed === 0,
|
||||||
|
requestId,
|
||||||
|
completed,
|
||||||
|
total: optyNumbers.length,
|
||||||
|
failed,
|
||||||
|
cacheHits
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendProductsProgress(sender, message) {
|
||||||
|
if (!sender || !sender.tab || !Number.isInteger(sender.tab.id)) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = Number.isInteger(sender.frameId) ? { frameId: sender.frameId } : undefined;
|
||||||
|
return tabsSendMessage(sender.tab.id, message, options).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestOpptyProducts, sendProductsProgress };
|
||||||
|
}
|
||||||
7
src/features/products/cache-state.js
Normal file
7
src/features/products/cache-state.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
function createProductsCacheState() {
|
||||||
|
return {
|
||||||
|
productsCachePromise: null,
|
||||||
|
productsCacheWritePromise: Promise.resolve(),
|
||||||
|
inFlightProducts: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
70
src/features/products/cache.js
Normal file
70
src/features/products/cache.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
function createProductsCache({
|
||||||
|
OPPTY_PRODUCTS_CACHE_KEY,
|
||||||
|
fetchAllOpptyProducts,
|
||||||
|
productsCacheState,
|
||||||
|
storageLocalGet,
|
||||||
|
storageLocalSet
|
||||||
|
}) {
|
||||||
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) {
|
||||||
|
const cache = await getProductsCache();
|
||||||
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
|
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||||
|
return { items: cachedEntry.items, fromCache: true, cachedAt: Number(cachedEntry.fetchedAt) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!productsCacheState.inFlightProducts.has(optyNumber)) {
|
||||||
|
const request = fetchAllOpptyProducts(optyNumber, accessToken)
|
||||||
|
.then(async (items) => {
|
||||||
|
const fetchedAt = Date.now();
|
||||||
|
cache[optyNumber] = {
|
||||||
|
fetchedAt,
|
||||||
|
items
|
||||||
|
};
|
||||||
|
await saveProductsCache(cache).catch(() => {});
|
||||||
|
return { items, fetchedAt };
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
productsCacheState.inFlightProducts.delete(optyNumber);
|
||||||
|
});
|
||||||
|
productsCacheState.inFlightProducts.set(optyNumber, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber);
|
||||||
|
return {
|
||||||
|
items: fetchedResult.items,
|
||||||
|
cachedAt: fetchedResult.fetchedAt,
|
||||||
|
fromCache: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCacheMaxAge(value) {
|
||||||
|
const maxAge = Number(value);
|
||||||
|
const defaultMaxAge = 24 * 60 * 60 * 1000;
|
||||||
|
const maximumMaxAge = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
return Number.isFinite(maxAge) && maxAge > 0
|
||||||
|
? Math.min(maxAge, maximumMaxAge)
|
||||||
|
: defaultMaxAge;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProductsCache() {
|
||||||
|
if (!productsCacheState.productsCachePromise) {
|
||||||
|
productsCacheState.productsCachePromise = storageLocalGet(OPPTY_PRODUCTS_CACHE_KEY)
|
||||||
|
.then((result) => result && typeof result[OPPTY_PRODUCTS_CACHE_KEY] === "object"
|
||||||
|
? result[OPPTY_PRODUCTS_CACHE_KEY]
|
||||||
|
: {})
|
||||||
|
.catch(() => ({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return productsCacheState.productsCachePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProductsCache(cache) {
|
||||||
|
productsCacheState.productsCacheWritePromise = productsCacheState.productsCacheWritePromise
|
||||||
|
.catch(() => {})
|
||||||
|
.then(() => storageLocalSet({ [OPPTY_PRODUCTS_CACHE_KEY]: cache }));
|
||||||
|
return productsCacheState.productsCacheWritePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||||
|
}
|
||||||
163
src/features/products/content-jobs.js
Normal file
163
src/features/products/content-jobs.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
function createProductsContentJobs({
|
||||||
|
messageTypes,
|
||||||
|
PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||||
|
PRODUCTS_MESSAGE_LISTENER_KEY,
|
||||||
|
PRODUCTS_PROGRESS_ID,
|
||||||
|
preferencesState,
|
||||||
|
productsContentState,
|
||||||
|
renderOpportunitiesTable,
|
||||||
|
sendRuntimeMessage,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { browser, chrome, document, window } = environment;
|
||||||
|
function requestOpptyProductsInBackground(opportunities, token) {
|
||||||
|
const optyNumbers = Array.from(new Set(opportunities
|
||||||
|
.map((item) => item && item.OptyNumber)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(String)));
|
||||||
|
|
||||||
|
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
productsContentState.currentProductsRequestId = requestId;
|
||||||
|
productsContentState.opptyProductsByNumber = new Map();
|
||||||
|
productsContentState.expandedProductOptyNumbers = new Set();
|
||||||
|
productsContentState.productsProgress = {
|
||||||
|
state: optyNumbers.length ? "running" : "complete",
|
||||||
|
completed: 0,
|
||||||
|
total: optyNumbers.length,
|
||||||
|
failed: 0,
|
||||||
|
cacheHits: 0
|
||||||
|
};
|
||||||
|
renderProductsProgress();
|
||||||
|
|
||||||
|
if (optyNumbers.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendRuntimeMessage({
|
||||||
|
type: messageTypes.requestOpptyProducts,
|
||||||
|
requestId,
|
||||||
|
accessToken: token,
|
||||||
|
optyNumbers,
|
||||||
|
cacheMaxAgeMs: preferencesState.opptyProductsCacheHours * 60 * 60 * 1000
|
||||||
|
}).then((response) => {
|
||||||
|
if (response && response.ok === false && response.requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") {
|
||||||
|
productsContentState.productsProgress.state = "error";
|
||||||
|
productsContentState.productsProgress.failed = productsContentState.productsProgress.total;
|
||||||
|
renderProductsProgress();
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
if (requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") {
|
||||||
|
productsContentState.productsProgress.state = "error";
|
||||||
|
productsContentState.productsProgress.failed = productsContentState.productsProgress.total;
|
||||||
|
renderProductsProgress();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureProductsBackgroundMessageHandler() {
|
||||||
|
if (window[PRODUCTS_MESSAGE_LISTENER_KEY]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
|
||||||
|
|
||||||
|
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.onMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window[PRODUCTS_MESSAGE_LISTENER_KEY] = true;
|
||||||
|
runtimeApi.runtime.onMessage.addListener((message) => {
|
||||||
|
if (!message || message.type !== messageTypes.opptyProductsProgress || message.requestId !== productsContentState.currentProductsRequestId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.optyNumber) {
|
||||||
|
productsContentState.opptyProductsByNumber.set(String(message.optyNumber), {
|
||||||
|
items: Array.isArray(message.items) ? message.items : [],
|
||||||
|
fromCache: Boolean(message.fromCache),
|
||||||
|
cachedAt: Number(message.cachedAt) || 0,
|
||||||
|
error: message.error || ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) {
|
||||||
|
productsContentState.productsLastCachedAt = Number(message.cachedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
productsContentState.productsProgress = {
|
||||||
|
state: message.state || productsContentState.productsProgress.state,
|
||||||
|
completed: Number(message.completed) || 0,
|
||||||
|
total: Number(message.total) || productsContentState.productsProgress.total,
|
||||||
|
failed: Number(message.failed) || 0,
|
||||||
|
cacheHits: Number(message.cacheHits) || 0
|
||||||
|
};
|
||||||
|
renderProductsProgress();
|
||||||
|
queueProductsTableRender();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProductsProgress() {
|
||||||
|
const badge = document.getElementById(PRODUCTS_PROGRESS_ID);
|
||||||
|
|
||||||
|
if (!badge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
badge.hidden = productsContentState.productsProgress.state === "idle";
|
||||||
|
badge.setAttribute("data-state", productsContentState.productsProgress.state);
|
||||||
|
|
||||||
|
if (productsContentState.productsProgress.state === "running") {
|
||||||
|
badge.textContent = `Products ${productsContentState.productsProgress.completed}/${productsContentState.productsProgress.total}`;
|
||||||
|
badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`;
|
||||||
|
} else if (productsContentState.productsProgress.state === "complete") {
|
||||||
|
badge.textContent = `Products ready ${productsContentState.productsProgress.total}`;
|
||||||
|
badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`;
|
||||||
|
} else if (productsContentState.productsProgress.state === "complete-with-errors") {
|
||||||
|
badge.textContent = `Products ${productsContentState.productsProgress.total - productsContentState.productsProgress.failed}/${productsContentState.productsProgress.total}`;
|
||||||
|
badge.title = `${productsContentState.productsProgress.failed} product requests failed`;
|
||||||
|
} else if (productsContentState.productsProgress.state === "error") {
|
||||||
|
badge.textContent = "Products unavailable";
|
||||||
|
badge.title = "Unable to load opportunity products";
|
||||||
|
} else {
|
||||||
|
badge.textContent = "Products ready 0";
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID);
|
||||||
|
|
||||||
|
if (timestamp) {
|
||||||
|
timestamp.hidden = !productsContentState.productsLastCachedAt;
|
||||||
|
timestamp.textContent = productsContentState.productsLastCachedAt
|
||||||
|
? `Last update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}`
|
||||||
|
: "";
|
||||||
|
timestamp.title = productsContentState.productsLastCachedAt
|
||||||
|
? `Latest product update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}`
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProductsCacheTimestamp(timestamp) {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short"
|
||||||
|
}).format(new Date(timestamp));
|
||||||
|
} catch (error) {
|
||||||
|
return new Date(timestamp).toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueProductsTableRender() {
|
||||||
|
if (productsContentState.productsRenderQueued) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
productsContentState.productsRenderQueued = true;
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
productsContentState.productsRenderQueued = false;
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestOpptyProductsInBackground, ensureProductsBackgroundMessageHandler, renderProductsProgress, formatProductsCacheTimestamp, queueProductsTableRender };
|
||||||
|
}
|
||||||
16
src/features/products/content-state.js
Normal file
16
src/features/products/content-state.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
function createProductsContentState() {
|
||||||
|
return {
|
||||||
|
currentProductsRequestId: "",
|
||||||
|
productsRenderQueued: false,
|
||||||
|
opptyProductsByNumber: new Map(),
|
||||||
|
expandedProductOptyNumbers: new Set(),
|
||||||
|
productsProgress: {
|
||||||
|
state: "idle",
|
||||||
|
completed: 0,
|
||||||
|
total: 0,
|
||||||
|
failed: 0,
|
||||||
|
cacheHits: 0
|
||||||
|
},
|
||||||
|
productsLastCachedAt: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
152
src/features/products/details.js
Normal file
152
src/features/products/details.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
function createProductsDetails({
|
||||||
|
OPPORTUNITIES_COLUMNS,
|
||||||
|
createAiSummaryAlert,
|
||||||
|
formatOracleResponseDate,
|
||||||
|
productsContentState,
|
||||||
|
renderOpportunitiesTable,
|
||||||
|
requestGenerateAiSummary,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document } = environment;
|
||||||
|
function createProductsCountButton(optyNumber, productsResult) {
|
||||||
|
const normalizedOptyNumber = String(optyNumber);
|
||||||
|
const button = document.createElement("button");
|
||||||
|
const count = Array.isArray(productsResult.items) ? productsResult.items.length : 0;
|
||||||
|
const isExpanded = productsContentState.expandedProductOptyNumbers.has(normalizedOptyNumber);
|
||||||
|
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-products-count";
|
||||||
|
button.textContent = String(count);
|
||||||
|
button.setAttribute("aria-expanded", String(isExpanded));
|
||||||
|
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
|
||||||
|
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;
|
||||||
|
button.classList.toggle("opportunities-extension-products-count-error", Boolean(productsResult.error));
|
||||||
|
button.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
|
||||||
|
} else {
|
||||||
|
productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber);
|
||||||
|
requestGenerateAiSummary(normalizedOptyNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProductsDetailRow(opportunity) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
const optyNumber = String(opportunity.OptyNumber);
|
||||||
|
const productsResult = productsContentState.opptyProductsByNumber.get(optyNumber);
|
||||||
|
const products = productsResult && Array.isArray(productsResult.items) ? productsResult.items : [];
|
||||||
|
|
||||||
|
row.className = "opportunities-extension-products-detail-row";
|
||||||
|
cell.colSpan = OPPORTUNITIES_COLUMNS.length;
|
||||||
|
cell.className = "opportunities-extension-products-detail-cell";
|
||||||
|
|
||||||
|
const panel = document.createElement("section");
|
||||||
|
panel.className = "opportunities-extension-products-detail";
|
||||||
|
|
||||||
|
const heading = document.createElement("div");
|
||||||
|
heading.className = "opportunities-extension-products-detail-heading";
|
||||||
|
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = `Products for ${optyNumber}`;
|
||||||
|
|
||||||
|
const summary = document.createElement("span");
|
||||||
|
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
|
||||||
|
heading.append(title, summary);
|
||||||
|
panel.append(heading);
|
||||||
|
panel.append(createAiSummaryAlert(optyNumber));
|
||||||
|
|
||||||
|
if (productsResult && productsResult.error) {
|
||||||
|
const error = document.createElement("p");
|
||||||
|
error.className = "opportunities-extension-products-empty";
|
||||||
|
error.textContent = productsResult.error;
|
||||||
|
panel.append(error);
|
||||||
|
} else if (products.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "opportunities-extension-products-empty";
|
||||||
|
empty.textContent = "No products found for this opportunity.";
|
||||||
|
panel.append(empty);
|
||||||
|
} else {
|
||||||
|
const table = document.createElement("table");
|
||||||
|
table.className = "opportunities-extension-products-table";
|
||||||
|
table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ label: "Product Group", value: (item) => item.ProdGroupName || "-" },
|
||||||
|
{ label: "Workload", value: (item) => item.WorkloadName_c || "-" },
|
||||||
|
{ label: "Currency", value: (item) => item.RevnAmountCurcyCode || "-" },
|
||||||
|
{ label: "Amount", value: (item) => formatProductAmount(item.RevnAmount, item.RevnAmountCurcyCode) },
|
||||||
|
{ label: "Type", value: (item) => item.TypeCode || "-" },
|
||||||
|
{ label: "Status", value: (item) => item.StatusCode || "-" },
|
||||||
|
{ label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) },
|
||||||
|
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
|
||||||
|
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
|
||||||
|
{ label: "Ramp Months", value: (item) => formatProductNumber(item.RampMonths_c) }
|
||||||
|
];
|
||||||
|
|
||||||
|
const head = document.createElement("thead");
|
||||||
|
const headerRow = document.createElement("tr");
|
||||||
|
columns.forEach((column) => {
|
||||||
|
const header = document.createElement("th");
|
||||||
|
header.scope = "col";
|
||||||
|
header.textContent = column.label;
|
||||||
|
headerRow.append(header);
|
||||||
|
});
|
||||||
|
head.append(headerRow);
|
||||||
|
|
||||||
|
const body = document.createElement("tbody");
|
||||||
|
products.forEach((product) => {
|
||||||
|
const productRow = document.createElement("tr");
|
||||||
|
columns.forEach((column) => {
|
||||||
|
const productCell = document.createElement("td");
|
||||||
|
productCell.textContent = String(column.value(product));
|
||||||
|
productRow.append(productCell);
|
||||||
|
});
|
||||||
|
body.append(productRow);
|
||||||
|
});
|
||||||
|
|
||||||
|
table.append(head, body);
|
||||||
|
panel.append(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.append(panel);
|
||||||
|
row.append(cell);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProductNumber(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(number) : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProductAmount(value, currency) {
|
||||||
|
const amount = Number(value);
|
||||||
|
|
||||||
|
if (!Number.isFinite(amount)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency || "USD"
|
||||||
|
}).format(amount);
|
||||||
|
} catch (error) {
|
||||||
|
return `${currency || ""} ${formatProductNumber(amount)}`.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProductWinProbability(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? `${number}%` : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
|
||||||
|
}
|
||||||
190
src/platform/background-api.js
Normal file
190
src/platform/background-api.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
function createPlatformBackgroundApi({
|
||||||
|
runtimeApi,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { browser, setTimeout } = environment;
|
||||||
|
function supportsFirefoxCookieStoreTabs() {
|
||||||
|
return Boolean(
|
||||||
|
runtimeApi.runtime
|
||||||
|
&& typeof runtimeApi.runtime.getBrowserInfo === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabsCreate(details) {
|
||||||
|
if (runtimeApi.tabs.create.length <= 1) {
|
||||||
|
return runtimeApi.tabs.create(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
runtimeApi.tabs.create(details, (tab) => {
|
||||||
|
const lastError = runtimeApi.runtime.lastError;
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
reject(new Error(lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabsRemove(tabId) {
|
||||||
|
if (runtimeApi.tabs.remove.length <= 1) {
|
||||||
|
return runtimeApi.tabs.remove(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
runtimeApi.tabs.remove(tabId, () => {
|
||||||
|
const lastError = runtimeApi.runtime.lastError;
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
reject(new Error(lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabsSendMessage(tabId, message, options) {
|
||||||
|
if (typeof browser !== "undefined") {
|
||||||
|
return options
|
||||||
|
? runtimeApi.tabs.sendMessage(tabId, message, options)
|
||||||
|
: runtimeApi.tabs.sendMessage(tabId, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const callback = (response) => {
|
||||||
|
const lastError = runtimeApi.runtime.lastError;
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
reject(new Error(lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options) {
|
||||||
|
runtimeApi.tabs.sendMessage(tabId, message, options, callback);
|
||||||
|
} else {
|
||||||
|
runtimeApi.tabs.sendMessage(tabId, message, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageLocalGet(key) {
|
||||||
|
if (typeof browser !== "undefined") {
|
||||||
|
return runtimeApi.storage.local.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
runtimeApi.storage.local.get(key, (result) => {
|
||||||
|
const lastError = runtimeApi.runtime.lastError;
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
reject(new Error(lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageLocalSet(value) {
|
||||||
|
if (typeof browser !== "undefined") {
|
||||||
|
return runtimeApi.storage.local.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
runtimeApi.storage.local.set(value, () => {
|
||||||
|
const lastError = runtimeApi.runtime.lastError;
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
reject(new Error(lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(durationMs) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookiesGetAll(details) {
|
||||||
|
if (runtimeApi.cookies.getAll.length <= 1) {
|
||||||
|
return runtimeApi.cookies.getAll(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
runtimeApi.cookies.getAll(details, (cookies) => {
|
||||||
|
const lastError = runtimeApi.runtime.lastError;
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
reject(new Error(lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(cookies);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookiesGet(details) {
|
||||||
|
const cleanDetails = removeUndefinedValues(details);
|
||||||
|
|
||||||
|
if (runtimeApi.cookies.get.length <= 1) {
|
||||||
|
return runtimeApi.cookies.get(cleanDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
runtimeApi.cookies.get(cleanDetails, (cookie) => {
|
||||||
|
const lastError = runtimeApi.runtime.lastError;
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
reject(new Error(lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(cookie);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookiesGetAllCookieStores() {
|
||||||
|
if (!runtimeApi.cookies.getAllCookieStores) {
|
||||||
|
return Promise.resolve([{ id: undefined }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeApi.cookies.getAllCookieStores.length === 0) {
|
||||||
|
return runtimeApi.cookies.getAllCookieStores();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
runtimeApi.cookies.getAllCookieStores((stores) => {
|
||||||
|
const lastError = runtimeApi.runtime.lastError;
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
reject(new Error(lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(stores);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUndefinedValues(details) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(details).filter((entry) => entry[1] !== undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues };
|
||||||
|
}
|
||||||
120
src/platform/content-messages.js
Normal file
120
src/platform/content-messages.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
function createPlatformContentMessages({
|
||||||
|
messageTypes,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { browser, chrome } = environment;
|
||||||
|
function sendRuntimeMessage(message) {
|
||||||
|
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
|
||||||
|
|
||||||
|
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
|
||||||
|
return Promise.reject(new Error("Runtime messaging API unavailable."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof browser !== "undefined") {
|
||||||
|
return runtimeApi.runtime.sendMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
runtimeApi.runtime.sendMessage(message, (response) => {
|
||||||
|
const lastError = runtimeApi.runtime.lastError;
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
reject(new Error(lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getXsrfTokenFromExtensionCookies() {
|
||||||
|
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
|
||||||
|
|
||||||
|
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
|
||||||
|
return Promise.resolve({
|
||||||
|
token: "",
|
||||||
|
cookieName: "",
|
||||||
|
source: "unavailable cookies API",
|
||||||
|
error: "Runtime messaging API unavailable."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
runtimeApi.runtime.sendMessage({
|
||||||
|
type: messageTypes.getXsrfToken
|
||||||
|
}, (response) => {
|
||||||
|
const lastError = runtimeApi.runtime.lastError;
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
resolve({
|
||||||
|
token: "",
|
||||||
|
cookieName: "",
|
||||||
|
source: "cookies API",
|
||||||
|
matchedCookieNames: [],
|
||||||
|
lookupDetails: [],
|
||||||
|
error: lastError.message
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
token: response && response.ok ? response.token : "",
|
||||||
|
cookieName: response && response.ok ? response.cookieName : "",
|
||||||
|
source: "cookies API",
|
||||||
|
matchedCookieNames: response && response.matchedCookieNames ? response.matchedCookieNames : [],
|
||||||
|
lookupDetails: response && response.lookupDetails ? response.lookupDetails : [],
|
||||||
|
error: response && response.error ? response.error : ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshXsrfCookie(reason) {
|
||||||
|
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
|
||||||
|
|
||||||
|
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
refreshed: false,
|
||||||
|
cookieName: "",
|
||||||
|
durationMs: 0,
|
||||||
|
reason,
|
||||||
|
error: "Runtime messaging API unavailable."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
runtimeApi.runtime.sendMessage({
|
||||||
|
type: messageTypes.refreshXsrfCookie
|
||||||
|
}, (response) => {
|
||||||
|
const lastError = runtimeApi.runtime.lastError;
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
refreshed: false,
|
||||||
|
cookieName: "",
|
||||||
|
durationMs: 0,
|
||||||
|
reason,
|
||||||
|
error: lastError.message
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
...(response || {
|
||||||
|
ok: false,
|
||||||
|
refreshed: false,
|
||||||
|
cookieName: "",
|
||||||
|
durationMs: 0,
|
||||||
|
error: "Empty refresh response."
|
||||||
|
}),
|
||||||
|
reason
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sendRuntimeMessage, getXsrfTokenFromExtensionCookies, refreshXsrfCookie };
|
||||||
|
}
|
||||||
166
src/services/fusion/opportunities.js
Normal file
166
src/services/fusion/opportunities.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
function createFusionOpportunities({
|
||||||
|
OPPORTUNITIES_PAGE_LIMIT,
|
||||||
|
OPPORTUNITIES_QUERY_URL,
|
||||||
|
isCurrentPeriodRequest,
|
||||||
|
logRequestFailure,
|
||||||
|
logRequestStart,
|
||||||
|
logRequestSuccess,
|
||||||
|
maskToken,
|
||||||
|
getOpportunityTypeValues,
|
||||||
|
readResponseBodyForDebug,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { fetch } = environment;
|
||||||
|
async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page) {
|
||||||
|
if (!isCurrentPeriodRequest(requestVersion)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = createOpportunitiesQueryPayload(dateRange, offset);
|
||||||
|
const requestBody = JSON.stringify(payload);
|
||||||
|
const requestEntry = logRequestStart({
|
||||||
|
name: "requestOpportunities",
|
||||||
|
method: "POST",
|
||||||
|
url: OPPORTUNITIES_QUERY_URL,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${maskToken(token)}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Origin: "https://eeho.fa.us2.oraclecloud.com",
|
||||||
|
Preference: "transient"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload, null, 2),
|
||||||
|
metadata: {
|
||||||
|
period,
|
||||||
|
page,
|
||||||
|
offset,
|
||||||
|
startCloseDate: dateRange.startCloseDate,
|
||||||
|
endCloseDate: dateRange.endCloseDate
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(OPPORTUNITIES_QUERY_URL, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Origin: "https://eeho.fa.us2.oraclecloud.com",
|
||||||
|
Preference: "transient"
|
||||||
|
},
|
||||||
|
body: requestBody
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody = await readResponseBodyForDebug(response);
|
||||||
|
logRequestSuccess(requestEntry, response, responseBody);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Opportunities query failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
|
||||||
|
const allItems = accumulatedItems.concat(pageItems);
|
||||||
|
|
||||||
|
if (!responseData.hasMore) {
|
||||||
|
return allItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseOffset = Number(responseData.offset);
|
||||||
|
const responseCount = Number(responseData.count);
|
||||||
|
const receivedCount = Number.isFinite(responseCount) ? responseCount : pageItems.length;
|
||||||
|
const currentOffset = Number.isFinite(responseOffset) ? responseOffset : offset;
|
||||||
|
|
||||||
|
if (receivedCount <= 0) {
|
||||||
|
throw new Error("Opportunities query returned hasMore without additional results.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestOpportunitiesPage(
|
||||||
|
token,
|
||||||
|
period,
|
||||||
|
dateRange,
|
||||||
|
currentOffset + receivedCount,
|
||||||
|
allItems,
|
||||||
|
requestVersion,
|
||||||
|
page + 1
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logRequestFailure(requestEntry, error.message || "Request failed.");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOpportunitiesQueryPayload(dateRange, offset) {
|
||||||
|
return {
|
||||||
|
aggregationResults: false,
|
||||||
|
applications: ["ORACLE-ISS-APP"],
|
||||||
|
onlyData: true,
|
||||||
|
entity: "Opportunity",
|
||||||
|
q: {
|
||||||
|
op: "$and",
|
||||||
|
criteria: [
|
||||||
|
{
|
||||||
|
op: "$or",
|
||||||
|
criteria: Array.from(getOpportunityTypeValues(), (value) => ({
|
||||||
|
op: "$eq",
|
||||||
|
attribute: "RecordSet",
|
||||||
|
value
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: "$wi",
|
||||||
|
attribute: "EffectiveDate",
|
||||||
|
value1: dateRange.startCloseDate,
|
||||||
|
value2: dateRange.endCloseDate,
|
||||||
|
dynamicDate: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
keywords: null,
|
||||||
|
keywordsFields: [
|
||||||
|
"Name",
|
||||||
|
"OptyNumber",
|
||||||
|
"PrimaryRevenue.WinProb",
|
||||||
|
"CustomerAccount",
|
||||||
|
"PrimaryRevenue.RevnAmount",
|
||||||
|
"EffectiveDate",
|
||||||
|
"StatusCode",
|
||||||
|
"DealRisk_c",
|
||||||
|
"Owner",
|
||||||
|
"ForecastGroup_c",
|
||||||
|
"LastUpdateDate",
|
||||||
|
"PrimaryRevenue.RevnAmountCurcyCode",
|
||||||
|
"OptyId"
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
"Name",
|
||||||
|
"OptyNumber",
|
||||||
|
"PrimaryRevenue.WinProb",
|
||||||
|
"CustomerAccount",
|
||||||
|
"PrimaryRevenue.RevnAmount",
|
||||||
|
"EffectiveDate",
|
||||||
|
"StatusCode",
|
||||||
|
"DealRisk_c",
|
||||||
|
"Owner",
|
||||||
|
"ForecastGroup_c",
|
||||||
|
"LastUpdateDate",
|
||||||
|
"PrimaryRevenue.RevnAmountCurcyCode",
|
||||||
|
"OptyId",
|
||||||
|
"CustomerAccount.PartyUniqueName",
|
||||||
|
"CustomerAccount.PartyId",
|
||||||
|
"CustomerAccount.PartyNumber"
|
||||||
|
],
|
||||||
|
sort: [],
|
||||||
|
language: "en",
|
||||||
|
skipInValidFields: true,
|
||||||
|
skipHiddenFromUIFields: true,
|
||||||
|
copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162",
|
||||||
|
limit: OPPORTUNITIES_PAGE_LIMIT,
|
||||||
|
offset
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestOpportunitiesPage, createOpportunitiesQueryPayload };
|
||||||
|
}
|
||||||
65
src/services/fusion/products.js
Normal file
65
src/services/fusion/products.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
function createFusionProducts({
|
||||||
|
OPPTY_PRODUCTS_FIELDS,
|
||||||
|
OPPTY_PRODUCTS_MAX_PAGES,
|
||||||
|
OPPTY_PRODUCTS_PAGE_LIMIT,
|
||||||
|
ORACLE_DOMAIN,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { fetch } = environment;
|
||||||
|
async function fetchAllOpptyProducts(optyNumber, accessToken) {
|
||||||
|
let offset = 0;
|
||||||
|
let page = 0;
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
while (page < OPPTY_PRODUCTS_MAX_PAGES) {
|
||||||
|
const response = await fetch(createOpptyProductsUrl(optyNumber, offset), {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${accessToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
|
||||||
|
items.push(...pageItems);
|
||||||
|
page += 1;
|
||||||
|
|
||||||
|
if (!responseData.hasMore) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseOffset = Number(responseData.offset);
|
||||||
|
const responseCount = Number(responseData.count);
|
||||||
|
const receivedCount = Number.isFinite(responseCount) ? responseCount : pageItems.length;
|
||||||
|
const currentOffset = Number.isFinite(responseOffset) ? responseOffset : offset;
|
||||||
|
|
||||||
|
if (receivedCount <= 0) {
|
||||||
|
throw new Error("requestOpptyProducts returned hasMore without additional results.");
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = currentOffset + receivedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("requestOpptyProducts exceeded the pagination safety limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOpptyProductsUrl(optyNumber, offset) {
|
||||||
|
const url = new URL(`https://${ORACLE_DOMAIN}/crmRestApi/rest/rv:be91c002-2e5d-4ed2-a37e-e0c837bf141f/en/11.13.18.05:9/opportunities/${encodeURIComponent(optyNumber)}/child/ChildRevenue`);
|
||||||
|
url.searchParams.set("onlyData", "true");
|
||||||
|
url.searchParams.set("q", "(SplitTypeCode!='DETAILCHILDSPLIT') AND (RecurTypeCode!='CHILDRECUR')");
|
||||||
|
url.searchParams.set("totalResults", "false");
|
||||||
|
url.searchParams.set("fields", OPPTY_PRODUCTS_FIELDS.join(","));
|
||||||
|
url.searchParams.set("orderBy", "CreationDate:desc");
|
||||||
|
url.searchParams.set("limit", String(OPPTY_PRODUCTS_PAGE_LIMIT));
|
||||||
|
url.searchParams.set("offset", String(offset));
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fetchAllOpptyProducts, createOpptyProductsUrl };
|
||||||
|
}
|
||||||
169
src/services/http/background-json.js
Normal file
169
src/services/http/background-json.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
function createHttpBackgroundJson({
|
||||||
|
DEBUG_BODY_LIMIT,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { fetch } = environment;
|
||||||
|
async function fetchJson(url, options, requestName, debugRequests = null) {
|
||||||
|
const startedAt = new Date();
|
||||||
|
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
||||||
|
let response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(url, options);
|
||||||
|
} catch (error) {
|
||||||
|
completeBackgroundDebugEntry(debugEntry, null, "", error.message || "Network request failed.");
|
||||||
|
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsePayload = await readResponsePayload(response);
|
||||||
|
const responseBody = createDebugBody(responsePayload.text);
|
||||||
|
completeBackgroundDebugEntry(debugEntry, response, responseBody, "");
|
||||||
|
debugEntry.metadata.responseContentType = response.headers.get("content-type") || "(not provided)";
|
||||||
|
debugEntry.metadata.responseLength = String(responsePayload.text.length);
|
||||||
|
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${requestName} failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responsePayload.error) {
|
||||||
|
throw new Error(`${requestName} response body could not be read.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return parseJsonResponse(responsePayload.text, requestName);
|
||||||
|
} catch (error) {
|
||||||
|
error.httpStatus = response.status;
|
||||||
|
debugEntry.status = "failed";
|
||||||
|
debugEntry.error = error.message;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBackgroundDebugEntry(requestName, url, options, startedAt) {
|
||||||
|
return {
|
||||||
|
name: requestName,
|
||||||
|
method: options.method || "GET",
|
||||||
|
url,
|
||||||
|
status: "pending",
|
||||||
|
startedAt: startedAt.toISOString(),
|
||||||
|
completedAt: null,
|
||||||
|
durationMs: null,
|
||||||
|
httpStatus: null,
|
||||||
|
statusText: "",
|
||||||
|
requestHeaders: normalizeDebugHeaders(options.headers),
|
||||||
|
requestBody: formatDebugRequestBody(options.body),
|
||||||
|
metadata: {
|
||||||
|
executionContext: "background"
|
||||||
|
},
|
||||||
|
responseHeaders: {},
|
||||||
|
responseBody: "",
|
||||||
|
responseBodyTruncated: false,
|
||||||
|
error: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeBackgroundDebugEntry(entry, response, responseBody, errorMessage) {
|
||||||
|
const completedAt = new Date();
|
||||||
|
entry.completedAt = completedAt.toISOString();
|
||||||
|
entry.durationMs = completedAt.getTime() - new Date(entry.startedAt).getTime();
|
||||||
|
entry.error = errorMessage;
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
entry.status = "failed";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.status = response.ok ? "success" : "failed";
|
||||||
|
entry.httpStatus = response.status;
|
||||||
|
entry.statusText = response.statusText || "";
|
||||||
|
entry.responseHeaders = headersToObject(response.headers);
|
||||||
|
entry.responseBody = responseBody.value;
|
||||||
|
entry.responseBodyTruncated = responseBody.truncated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendBackgroundDebugEntry(debugRequests, entry) {
|
||||||
|
if (Array.isArray(debugRequests)) {
|
||||||
|
debugRequests.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readResponsePayload(response) {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
text: await response.text(),
|
||||||
|
error: ""
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
text: `Unable to read response body: ${error.message || "unknown error"}`,
|
||||||
|
error: error.message || "unknown error"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonResponse(text, requestName) {
|
||||||
|
const normalizedText = String(text)
|
||||||
|
.replace(/^\uFEFF/, "")
|
||||||
|
.trimStart()
|
||||||
|
.replace(/^\)\]\}',?\s*/, "")
|
||||||
|
.replace(/^while\s*\(1\);\s*/, "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(normalizedText);
|
||||||
|
} catch (error) {
|
||||||
|
const invalidJsonError = new Error(`${requestName} returned an invalid JSON response.`);
|
||||||
|
invalidJsonError.code = "INVALID_JSON_RESPONSE";
|
||||||
|
throw invalidJsonError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDebugBody(text) {
|
||||||
|
if (text.length > DEBUG_BODY_LIMIT) {
|
||||||
|
return {
|
||||||
|
value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`,
|
||||||
|
truncated: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: text || "(empty response body)",
|
||||||
|
truncated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDebugHeaders(headers) {
|
||||||
|
const normalized = {};
|
||||||
|
|
||||||
|
Object.entries(headers || {}).forEach(([key, value]) => {
|
||||||
|
normalized[key] = String(value);
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headersToObject(headers) {
|
||||||
|
const values = {};
|
||||||
|
|
||||||
|
if (headers && typeof headers.forEach === "function") {
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
values[key] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDebugRequestBody(body) {
|
||||||
|
if (!body) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(body), null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
return String(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fetchJson, createBackgroundDebugEntry, completeBackgroundDebugEntry, appendBackgroundDebugEntry, readResponsePayload, parseJsonResponse, createDebugBody, normalizeDebugHeaders, headersToObject, formatDebugRequestBody };
|
||||||
|
}
|
||||||
75
src/services/spa/accounts.js
Normal file
75
src/services/spa/accounts.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
function createSpaAccounts({
|
||||||
|
ACCOUNT_PLANS_PAYLOAD,
|
||||||
|
ACCOUNT_PLANS_URL,
|
||||||
|
ACCOUNT_SUMMARY_URL,
|
||||||
|
extractAccountPlans,
|
||||||
|
fetchJson,
|
||||||
|
findAccountPlanId,
|
||||||
|
normalizeMessageAuthorization
|
||||||
|
}) {
|
||||||
|
async function requestAccountPlans(message) {
|
||||||
|
const partyNumber = typeof message.partyNumber === "string" ? message.partyNumber.trim() : "";
|
||||||
|
const accountSummaryAuthorization = normalizeMessageAuthorization(
|
||||||
|
message.authorizations && message.authorizations.accountSummary,
|
||||||
|
"account-ti-authorization"
|
||||||
|
);
|
||||||
|
const accountPlansAuthorization = normalizeMessageAuthorization(
|
||||||
|
message.authorizations && message.authorizations.accountPlans,
|
||||||
|
"g2m-authorization"
|
||||||
|
);
|
||||||
|
const debugRequests = [];
|
||||||
|
|
||||||
|
if (!partyNumber) {
|
||||||
|
throw new Error("Customer Party Number is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accountSummaryAuthorization.value) {
|
||||||
|
throw new Error("ACCP-TS authorization is unavailable. requestConsumerTokenService must complete first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accountPlansAuthorization.value) {
|
||||||
|
throw new Error("OCICONS-TS authorization is unavailable. requestConsumerTokenService must complete first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountSummary = await fetchJson(ACCOUNT_SUMMARY_URL, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
[accountSummaryAuthorization.name]: accountSummaryAuthorization.value,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify([{ key: "regId", value: partyNumber }])
|
||||||
|
}, "requestAccountSummary", debugRequests);
|
||||||
|
const accountPlanId = findAccountPlanId(accountSummary);
|
||||||
|
|
||||||
|
if (!accountPlanId) {
|
||||||
|
throw new Error("Account plan id was not found in requestAccountSummary.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountPlansResponse = await fetchJson(`${ACCOUNT_PLANS_URL}/${encodeURIComponent(String(accountPlanId))}`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
[accountPlansAuthorization.name]: accountPlansAuthorization.value,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(ACCOUNT_PLANS_PAYLOAD)
|
||||||
|
}, "requestAccountPlans", debugRequests);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
accountPlanId: String(accountPlanId),
|
||||||
|
plans: extractAccountPlans(accountPlansResponse),
|
||||||
|
debugRequests
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestAccountPlans };
|
||||||
|
}
|
||||||
58
src/services/spa/consumption.js
Normal file
58
src/services/spa/consumption.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
function createSpaConsumption({
|
||||||
|
CONSUMPTION_TREND_REQUESTS,
|
||||||
|
CONSUMPTION_TREND_URL,
|
||||||
|
fetchJson,
|
||||||
|
normalizeMessageAuthorization
|
||||||
|
}) {
|
||||||
|
async function requestConsumptionTrend(message) {
|
||||||
|
const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : "";
|
||||||
|
const period = typeof message.period === "string" ? message.period.toLowerCase() : "";
|
||||||
|
const requestName = CONSUMPTION_TREND_REQUESTS[period];
|
||||||
|
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
|
||||||
|
const debugRequests = [];
|
||||||
|
|
||||||
|
if (!subPlanNum) {
|
||||||
|
throw new Error("Sub plan number is required for the consumption trend.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestName) {
|
||||||
|
throw new Error(`Unsupported consumption trend period: ${period || "(missing)"}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authorization.value) {
|
||||||
|
throw new Error("OCICONS-TS authorization is unavailable for the consumption trend.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${CONSUMPTION_TREND_URL}/${encodeURIComponent(subPlanNum)}?quarter=All&timePeriod=${period}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchJson(url, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"G2m-Authorization": authorization.value
|
||||||
|
}
|
||||||
|
}, requestName, debugRequests);
|
||||||
|
const items = Array.isArray(response)
|
||||||
|
? response
|
||||||
|
: Array.isArray(response && response.items)
|
||||||
|
? response.items
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
subPlanNum,
|
||||||
|
period,
|
||||||
|
items,
|
||||||
|
debugRequests
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestConsumptionTrend };
|
||||||
|
}
|
||||||
61
src/services/spa/response-shapes.js
Normal file
61
src/services/spa/response-shapes.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
function createSpaResponseShapes() {
|
||||||
|
function findNestedObject(value, predicate, visited = new Set()) {
|
||||||
|
if (!value || typeof value !== "object" || visited.has(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(value);
|
||||||
|
|
||||||
|
if (!Array.isArray(value) && predicate(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(value)) {
|
||||||
|
const match = findNestedObject(child, predicate, visited);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAccountPlanId(response) {
|
||||||
|
const account = findNestedObject(response, (value) => (
|
||||||
|
Object.prototype.hasOwnProperty.call(value, "id")
|
||||||
|
&& value.id !== null
|
||||||
|
&& value.id !== undefined
|
||||||
|
&& value.id !== ""
|
||||||
|
));
|
||||||
|
return account ? account.id : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAccountPlans(response) {
|
||||||
|
const plans = [];
|
||||||
|
const visited = new Set();
|
||||||
|
|
||||||
|
const visit = (value) => {
|
||||||
|
if (!value || typeof value !== "object" || visited.has(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(value);
|
||||||
|
|
||||||
|
if (!Array.isArray(value) && (
|
||||||
|
Object.prototype.hasOwnProperty.call(value, "planType")
|
||||||
|
|| Object.prototype.hasOwnProperty.call(value, "subPlanNum")
|
||||||
|
)) {
|
||||||
|
plans.push(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.values(value).forEach(visit);
|
||||||
|
};
|
||||||
|
|
||||||
|
visit(response);
|
||||||
|
return plans;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { findNestedObject, findAccountPlanId, extractAccountPlans };
|
||||||
|
}
|
||||||
12
src/shared/messages.js
Normal file
12
src/shared/messages.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||||
|
function createMessageTypes() {
|
||||||
|
return Object.freeze({
|
||||||
|
getXsrfToken: "opportunitiesExtension.getXsrfToken",
|
||||||
|
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
|
||||||
|
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
|
||||||
|
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
|
||||||
|
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
||||||
|
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||||
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const fs = require("node:fs");
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
const path = require("node:path");
|
|
||||||
const vm = require("node:vm");
|
|
||||||
|
|
||||||
class Element {
|
class Element {
|
||||||
constructor(tag) {
|
constructor(tag) {
|
||||||
@@ -28,24 +26,6 @@ function setup(mockSalesToken = true) {
|
|||||||
static now() { return now; }
|
static now() { return now; }
|
||||||
}
|
}
|
||||||
let visibleAlerts = [];
|
let visibleAlerts = [];
|
||||||
const source = fs.readFileSync(path.join(__dirname, "../src/content.js"), "utf8");
|
|
||||||
const marker = " if (!isAllowedPage()) {";
|
|
||||||
assert.ok(source.includes(marker));
|
|
||||||
const instrumented = source.replace(marker, `
|
|
||||||
accessToken = "test-bearer-secret";
|
|
||||||
getXsrfToken = async () => ({ token: "test-xsrf-cookie", cookieName: "XSRF-TOKEN-DYNAMIC", source: "test" });
|
|
||||||
refreshXsrfCookie = async () => ({ refreshed: false });
|
|
||||||
${mockSalesToken ? 'requestTokenRelaySalesCommon = async () => "test-sales-common-secret";' : ''}
|
|
||||||
renderDebugPanel = () => {};
|
|
||||||
renderOpportunitiesTable = () => {};
|
|
||||||
globalThis.helpers = {
|
|
||||||
requestGenerateAiSummary, createProductsCountButton, createProductsDetailRow,
|
|
||||||
createAiSummaryAlert, aiSummariesByOptyNumber, expandedProductOptyNumbers,
|
|
||||||
opptyProductsByNumber, requestLog, formatHeaderBlock, requestTokenRelay,
|
|
||||||
getAccessToken: () => accessToken, requestTokenRelaySalesCommon
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
${marker}`);
|
|
||||||
const context = {
|
const context = {
|
||||||
document: {
|
document: {
|
||||||
createElement: (tag) => new Element(tag),
|
createElement: (tag) => new Element(tag),
|
||||||
@@ -54,8 +34,21 @@ ${marker}`);
|
|||||||
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),
|
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),
|
||||||
Intl, Date: TestDate, Map, Set
|
Intl, Date: TestDate, Map, Set
|
||||||
};
|
};
|
||||||
vm.runInNewContext(instrumented, context);
|
const app = loadApplication("content", context);
|
||||||
return { ...context.helpers, calls, advance: (ms) => { now += ms; }, show: (alerts) => { visibleAlerts = alerts; } };
|
app.authSessionState.accessToken = "test-bearer-secret";
|
||||||
|
app.authXsrfContent.getXsrfToken = async () => ({ token: "test-xsrf-cookie", cookieName: "XSRF-TOKEN-DYNAMIC", source: "test" });
|
||||||
|
app.platformContentMessages.refreshXsrfCookie = async () => ({ refreshed: false });
|
||||||
|
if (mockSalesToken) app.authSalesCommon.requestTokenRelaySalesCommon = async () => "test-sales-common-secret";
|
||||||
|
app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable = () => {};
|
||||||
|
return {
|
||||||
|
...app.aiSummaryContent, ...app.productsDetails, ...app.aiSummaryState,
|
||||||
|
...app.productsContentState, ...app.debugContentLog, ...app.debugState,
|
||||||
|
requestTokenRelay: app.authTokenRelay.requestTokenRelay,
|
||||||
|
requestTokenRelaySalesCommon: app.authSalesCommon.requestTokenRelaySalesCommon,
|
||||||
|
getAccessToken: () => app.authSessionState.accessToken,
|
||||||
|
calls, advance: (ms) => { now += ms; }, show: (alerts) => { visibleAlerts = alerts; }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function json(data, status = 200) {
|
function json(data, status = 200) {
|
||||||
|
|||||||
75
tests/architecture.test.cjs
Normal file
75
tests/architecture.test.cjs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const vm = require("node:vm");
|
||||||
|
const { createHash } = require("node:crypto");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { sources, composeScript } = require("../scripts/compose.cjs");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..");
|
||||||
|
const read = file => fs.readFileSync(path.join(root, file), "utf8");
|
||||||
|
const json = value => JSON.parse(JSON.stringify(value));
|
||||||
|
|
||||||
|
test("every source belongs to an explicit runtime composition, with no duplicate factories", () => {
|
||||||
|
const actual = fs.readdirSync(path.join(root, "src"), { recursive: true })
|
||||||
|
.filter(file => file.endsWith(".js")).map(file => `src/${file.replaceAll(path.sep, "/")}`).sort();
|
||||||
|
assert.deepEqual([...new Set(Object.values(sources).flat())].sort(), actual);
|
||||||
|
for (const [target, files] of Object.entries(sources)) {
|
||||||
|
assert.equal(new Set(files).size, files.length);
|
||||||
|
const factories = files.flatMap(file => [...read(file).matchAll(/^function (\w+)\(/gm)].map(match => match[1]));
|
||||||
|
assert.equal(new Set(factories).size, factories.length, target);
|
||||||
|
assert.equal(composeScript(target), composeScript(target));
|
||||||
|
assert.doesNotThrow(() => new vm.Script(composeScript(target)));
|
||||||
|
for (const file of files) {
|
||||||
|
const source = read(file);
|
||||||
|
assert.doesNotMatch(source, /\beval\s*\(|\bnew\s+Function\s*\(|\bimportScripts\s*\(|\brequire\s*\(|^\s*(?:import|export)\s/m, file);
|
||||||
|
if (file.endsWith("/application.js") || file.endsWith("/main.js")) continue;
|
||||||
|
const declaration = source.match(/^function (\w+)\(/m)[1];
|
||||||
|
for (const factory of factories.filter(name => name !== declaration)) {
|
||||||
|
assert.doesNotMatch(source, new RegExp(`\\b${factory}\\s*\\(`), `${file} must receive dependencies, not construct ${factory}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/shared/messages.js"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Redwood cascade is byte-identical to the characterized original", () => {
|
||||||
|
const hash = createHash("sha256").update(read("src/content/styles/redwood.css")).digest("hex");
|
||||||
|
assert.equal(hash, "eaeb7e53757d48a3095f9111c5029d2c923d0dcab92af23454ccd14bdcfc81c4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fiscal module can run alone without DOM, browser APIs or network", () => {
|
||||||
|
const factory = new vm.Script(`${read("src/features/opportunities/fiscal-periods.js")}\ncreateOpportunitiesFiscalPeriods;`).runInNewContext();
|
||||||
|
assert.deepEqual(json(factory().getFiscalPeriodRange("Current Fiscal Year", new Date(2027, 0, 15))), {
|
||||||
|
startCloseDate: "2026-06-01T00:00:00.000Z", endCloseDate: "2027-05-31T00:00:00.000Z"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("per-frame state and credentials do not leak between application instances", () => {
|
||||||
|
const a = loadApplication("content");
|
||||||
|
const b = loadApplication("content");
|
||||||
|
for (const key of Object.keys(a).filter(key => key.endsWith("State"))) assert.notEqual(a[key], b[key], key);
|
||||||
|
a.authSessionState.accessToken = "synthetic-general";
|
||||||
|
a.authSalesCommonState.salesCommonToken = { access_token: "synthetic-scoped", expiresAt: 123 };
|
||||||
|
a.productsContentState.opptyProductsByNumber.set("TEST", { items: [] });
|
||||||
|
a.aiSummaryState.aiSummariesByOptyNumber.set("TEST", { summaryText: "Synthetic" });
|
||||||
|
assert.equal(b.authSessionState.accessToken, "");
|
||||||
|
assert.equal(b.authSalesCommonState.salesCommonToken, null);
|
||||||
|
assert.equal(b.productsContentState.opptyProductsByNumber.size, 0);
|
||||||
|
assert.equal(b.aiSummaryState.aiSummariesByOptyNumber.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocked localStorage access remains lazy and handled by preference fallbacks", () => {
|
||||||
|
const context = {};
|
||||||
|
Object.defineProperty(context, "localStorage", { get() { throw new Error("Storage blocked"); } });
|
||||||
|
const app = loadApplication("content", context);
|
||||||
|
assert.doesNotThrow(() => app.preferencesStorage.getSavedOpportunityTypeValues());
|
||||||
|
assert.equal(app.preferencesStorage.getSavedOpptyProductsCacheHours(), 24);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("visual fixtures load modules without eval or rewriting source markers", () => {
|
||||||
|
for (const file of ["tests/fixtures/consumption-trend.html", "tests/fixtures/extension.js"]) {
|
||||||
|
assert.doesNotMatch(read(file), /\beval\s*\(|testableSource|src\/content\.js/);
|
||||||
|
}
|
||||||
|
});
|
||||||
344
tests/characterization.test.cjs
Normal file
344
tests/characterization.test.cjs
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const vm = require("node:vm");
|
||||||
|
const { root, plain, response, loadContent, loadBackground } = require("./support/characterization.cjs");
|
||||||
|
const { composeScript } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
const PREFIX = "opportunitiesExtension.";
|
||||||
|
const FILTER_KEY = "opportunities-extension-filter-preferences";
|
||||||
|
const TYPE_KEY = "opportunities-extension-opportunity-type-preferences";
|
||||||
|
const CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1";
|
||||||
|
const DAY = 86400000;
|
||||||
|
const range = { startCloseDate: "2026-06-01T00:00:00.000Z", endCloseDate: "2026-08-31T00:00:00.000Z" };
|
||||||
|
const drain = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
const productRequest = (requestId, optyNumbers = ["SYNTH-1"]) => ({
|
||||||
|
type: PREFIX + "requestOpptyProducts", requestId, optyNumbers, accessToken: "synthetic-products-token"
|
||||||
|
});
|
||||||
|
|
||||||
|
test("content bootstrap ignores other origins and preserves the existing tile/listener on reinjection", () => {
|
||||||
|
const source = composeScript("content");
|
||||||
|
const script = new vm.Script(source);
|
||||||
|
assert.doesNotThrow(() => script.runInNewContext({
|
||||||
|
window: { location: { hostname: "unrelated.example", pathname: "/" } },
|
||||||
|
document: { getElementById() { assert.fail("DOM touched on unrelated origin"); } }
|
||||||
|
}));
|
||||||
|
let listeners = 0;
|
||||||
|
const context = vm.createContext({
|
||||||
|
window: { location: { hostname: "eeho.fa.us2.oraclecloud.com", pathname: "/fscmUI/faces/FuseWelcome" } },
|
||||||
|
document: { getElementById: () => ({}) },
|
||||||
|
chrome: { runtime: { onMessage: { addListener() { listeners++; } } } },
|
||||||
|
MutationObserver: class { constructor() { assert.fail("Observer unnecessary when tile already exists"); } }
|
||||||
|
});
|
||||||
|
script.runInContext(context);
|
||||||
|
script.runInContext(context);
|
||||||
|
assert.equal(listeners, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("distribution contracts: manifests, execution boundaries and deterministic composition", () => {
|
||||||
|
const base = {
|
||||||
|
manifest_version: 3, name: "Opportunities Extension",
|
||||||
|
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
|
version: "1.0.1", permissions: ["cookies", "storage"],
|
||||||
|
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*"],
|
||||||
|
content_scripts: [{
|
||||||
|
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
|
||||||
|
js: ["content.js"], all_frames: true, run_at: "document_idle"
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
for (const target of ["chromium", "firefox"]) {
|
||||||
|
const expected = target === "chromium"
|
||||||
|
? { ...base, background: { service_worker: "background.js" } }
|
||||||
|
: { ...base, background: { scripts: ["background.js"] }, browser_specific_settings: { gecko: { id: "opportunities-extension@local.dev" } } };
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, "dist", target, "manifest.json"))), expected);
|
||||||
|
for (const file of ["content.js", "background.js"]) {
|
||||||
|
const output = fs.readFileSync(path.join(root, "dist", target, file), "utf8");
|
||||||
|
assert.equal(output, composeScript(file.replace(".js", "")));
|
||||||
|
assert.doesNotThrow(() => new vm.Script(output, { filename: `${target}/${file}` }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fiscal dates preserve quarter/year boundaries, leap year and unsupported renewals", () => {
|
||||||
|
const h = loadContent();
|
||||||
|
const cases = [
|
||||||
|
["Current Quarter", [2026, 5, 1], "2026-06-01", "2026-08-31"],
|
||||||
|
["Current Quarter", [2026, 7, 31], "2026-06-01", "2026-08-31"],
|
||||||
|
["Current Quarter", [2026, 8, 1], "2026-09-01", "2026-11-30"],
|
||||||
|
["Current Quarter", [2028, 1, 29], "2027-12-01", "2028-02-29"],
|
||||||
|
["Next Quarter", [2026, 10, 30], "2026-12-01", "2027-02-28"],
|
||||||
|
["Previous Quarter", [2026, 7, 24], "2026-03-01", "2026-05-31"],
|
||||||
|
["Current Fiscal Year", [2026, 4, 31], "2025-06-01", "2026-05-31"],
|
||||||
|
["Current Fiscal Year", [2026, 5, 1], "2026-06-01", "2027-05-31"],
|
||||||
|
["Current Fiscal Year", [2027, 0, 1], "2026-06-01", "2027-05-31"],
|
||||||
|
["4 Rolling Quarters (CQ + 3)", [2026, 8, 16], "2026-09-01", "2027-08-31"]
|
||||||
|
];
|
||||||
|
for (const [period, date, start, end] of cases) {
|
||||||
|
assert.deepEqual(plain(h.getFiscalPeriodRange(period, new Date(...date))), {
|
||||||
|
startCloseDate: start + "T00:00:00.000Z", endCloseDate: end + "T00:00:00.000Z"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.equal(h.getFiscalPeriodRange("RENEWALS (Current + Past Due)", new Date()), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opportunity query preserves fields, record-set OR, date AND, and pagination payload", () => {
|
||||||
|
const h = loadContent();
|
||||||
|
const types = ["ORA_MYSALESTEAMOPTIES", "ORA_MYASSGTERROPTIES", "ORA_CREDITRECEIVER_ISME"];
|
||||||
|
h.setTypes(types);
|
||||||
|
const fields = ["Name", "OptyNumber", "PrimaryRevenue.WinProb", "CustomerAccount", "PrimaryRevenue.RevnAmount", "EffectiveDate", "StatusCode", "DealRisk_c", "Owner", "ForecastGroup_c", "LastUpdateDate", "PrimaryRevenue.RevnAmountCurcyCode", "OptyId"];
|
||||||
|
assert.deepEqual(plain(h.createOpportunitiesQueryPayload(range, 15)), {
|
||||||
|
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "Opportunity",
|
||||||
|
q: { op: "$and", criteria: [
|
||||||
|
{ op: "$or", criteria: types.map(value => ({ op: "$eq", attribute: "RecordSet", value })) },
|
||||||
|
{ op: "$wi", attribute: "EffectiveDate", value1: range.startCloseDate, value2: range.endCloseDate, dynamicDate: false }
|
||||||
|
] }, keywords: null, keywordsFields: fields,
|
||||||
|
fields: [...fields, "CustomerAccount.PartyUniqueName", "CustomerAccount.PartyId", "CustomerAccount.PartyNumber"],
|
||||||
|
sort: [], language: "en", skipInValidFields: true, skipHiddenFromUIFields: true,
|
||||||
|
copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", limit: 15, offset: 15
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opportunity pagination uses response offsets/count, ignores stale versions and detects stalled pages", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadContent({ fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return response(calls.length === 1
|
||||||
|
? { items: [{ OptyNumber: "S1" }], offset: 0, count: 1, hasMore: true }
|
||||||
|
: { items: [{ OptyNumber: "S2" }], offset: 1, count: 1, hasMore: false });
|
||||||
|
} });
|
||||||
|
const items = await h.requestOpportunitiesPage("synthetic-token", "Current Quarter", range, 0, [], 0, 1);
|
||||||
|
assert.deepEqual(plain(items), [{ OptyNumber: "S1" }, { OptyNumber: "S2" }]);
|
||||||
|
assert.deepEqual(calls.map(call => JSON.parse(call.options.body).offset), [0, 1]);
|
||||||
|
assert.equal(calls[0].url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries");
|
||||||
|
assert.deepEqual(plain(calls[0].options.headers), {
|
||||||
|
Accept: "application/json", Authorization: "Bearer synthetic-token", "Content-Type": "application/json",
|
||||||
|
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
|
||||||
|
});
|
||||||
|
assert.equal(calls[0].options.method, "POST");
|
||||||
|
assert.equal(calls[0].options.credentials, "include");
|
||||||
|
h.setVersion(1);
|
||||||
|
assert.equal(await h.requestOpportunitiesPage("unused", "Current Quarter", range, 0, [], 0, 1), null);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
const stalled = loadContent({ fetch: async () => response({ items: [], count: 0, hasMore: true }) });
|
||||||
|
await assert.rejects(stalled.requestOpportunitiesPage("synthetic", "Current Quarter", range, 0, [], 0, 1), /without additional results/);
|
||||||
|
});
|
||||||
|
|
||||||
|
const opportunity = (number, status, amount) => ({
|
||||||
|
Name: `Synthetic cloud ${number}`, OptyNumber: number, StatusCode: status, ForecastGroup_c: "PIPELINE",
|
||||||
|
CustomerAccount: { PartyId: "customer-1", PartyUniqueName: "Synthetic Customer", PartyNumber: "party-1" },
|
||||||
|
Owner: { PartyId: "owner-1", PersonFirstName: "Test", PersonLastName: "Owner", PartyName: "Long Test Owner Name" },
|
||||||
|
PrimaryRevenue: { RevnAmount: amount, RevnAmountCurcyCode: "USD", WinProb: 60 },
|
||||||
|
EffectiveDate: "2026-08-28", LastUpdateDate: "2026-08-28T13:40:00"
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filter storage round-trip, WON_PENDING membership, search, sorting and reference resets", () => {
|
||||||
|
const preferences = { period: "Next Quarter", stages: ["PIPELINE"], statuses: ["WON"],
|
||||||
|
customerKeys: ["party:customer-1"], ownerKeys: ["party:owner-1"],
|
||||||
|
customerSearch: "Synthetic", ownerSearch: "Test", tableSearch: "cloud" };
|
||||||
|
const storage = new Map([[FILTER_KEY, JSON.stringify(preferences)]]);
|
||||||
|
const h = loadContent({ storage });
|
||||||
|
const select = { value: "Current Quarter" };
|
||||||
|
h.restoreFilterPreferences(select);
|
||||||
|
assert.equal(select.value, "Next Quarter");
|
||||||
|
const items = [opportunity("S1", "WON", "100"), opportunity("S2", "WON_PENDING", "20"), opportunity("S3", "OPEN", "5")];
|
||||||
|
h.setTable(items, "revenue");
|
||||||
|
assert.deepEqual(plain(h.getSortedOpportunities()).map(item => item.OptyNumber), ["S2", "S1"]);
|
||||||
|
assert.equal(h.getSortedOpportunities()[0].StatusCode, "WON_PENDING");
|
||||||
|
assert.equal(h.getOwnerName(items[0]), "Test Owner");
|
||||||
|
assert.equal(h.matchesTableSearch(items[0], "28/08/2026"), true);
|
||||||
|
assert.equal(h.matchesTableSearch(items[0], "test owner"), true);
|
||||||
|
h.saveFilterPreferences(select.value);
|
||||||
|
assert.deepEqual(JSON.parse(storage.get(FILTER_KEY)), preferences);
|
||||||
|
const reopened = loadContent({ storage });
|
||||||
|
reopened.restoreFilterPreferences({ value: "Current Quarter" });
|
||||||
|
assert.equal(reopened.filters().tableSearch, "cloud");
|
||||||
|
reopened.resetCustomerAndOwnerFiltersForPeriod();
|
||||||
|
const nextItems = [...items, { ...opportunity("S4", "WON", "30"), CustomerAccount: { PartyId: "customer-2", PartyUniqueName: "Another Customer" }, Owner: { PartyId: "owner-2", PartyName: "Another Owner" } }];
|
||||||
|
reopened.setTable(nextItems);
|
||||||
|
assert.deepEqual(plain(reopened.filters().customerKeys).sort(), ["party:customer-1", "party:customer-2"]);
|
||||||
|
assert.deepEqual(plain(reopened.filters().ownerKeys).sort(), ["party:owner-1", "party:owner-2"]);
|
||||||
|
assert.equal(reopened.filters().tableSearch, "cloud");
|
||||||
|
assert.equal(reopened.filters().customerSearch, "");
|
||||||
|
assert.equal(reopened.filters().ownerSearch, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed preferences fall back, while theme/type/cache keys retain their schema", () => {
|
||||||
|
const storage = new Map([[FILTER_KEY, "not JSON"], ["opportunities-extension-theme", "dark"],
|
||||||
|
[TYPE_KEY, JSON.stringify({ opportunityTypeValues: ["ORA_MYOPTIES", "UNKNOWN"], opptyProductsCacheHours: 24 })]]);
|
||||||
|
const h = loadContent({ storage });
|
||||||
|
assert.doesNotThrow(() => h.restoreFilterPreferences({ value: "Current Quarter" }));
|
||||||
|
assert.equal(h.getSavedTheme(), "dark");
|
||||||
|
assert.deepEqual(plain(h.getSavedOpportunityTypeValues()), ["ORA_MYOPTIES"]);
|
||||||
|
assert.equal(h.getSavedOpptyProductsCacheHours(), 24);
|
||||||
|
assert.equal(h.filters().tableSearch, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tokenrelay missing-cookie recovery and HTTP failure do not change the request contract", async () => {
|
||||||
|
let reads = 0;
|
||||||
|
const calls = [];
|
||||||
|
const h = loadContent({ xsrf: async () => ({ token: ++reads === 1 ? "" : "synthetic-xsrf", cookieName: "XSRF-TOKEN-SYNTHETIC" }),
|
||||||
|
fetch: async (url, options) => { calls.push({ url, options }); return response({ access_token: "synthetic-access" }); } });
|
||||||
|
assert.equal(await h.requestTokenRelay(), "synthetic-access");
|
||||||
|
assert.equal(h.refreshes.length, 1);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].options.headers["x-xsrf-token"], "synthetic-xsrf");
|
||||||
|
assert.equal(calls[0].options.method, "GET");
|
||||||
|
for (const status of [400, 401, 403]) {
|
||||||
|
let attempts = 0;
|
||||||
|
const rejected = loadContent({ xsrf: async () => ({ token: "synthetic-xsrf" }),
|
||||||
|
fetch: async () => { attempts++; return response({}, status); } });
|
||||||
|
await assert.rejects(rejected.requestTokenRelay(), /failed with status/);
|
||||||
|
assert.equal(attempts, 2);
|
||||||
|
assert.equal(rejected.refreshes.length, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: products paginate, route progress to sender frame and reuse persisted cache after restart`, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const storage = {};
|
||||||
|
const h = loadBackground({ family, storage, fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
const offset = Number(new URL(url).searchParams.get("offset"));
|
||||||
|
return response({ items: [{ RevnNumber: `SYNTH-${offset}` }], count: 1, offset, hasMore: offset === 0 });
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch(productRequest("job-1", ["SYNTH-1", "SYNTH-1"]));
|
||||||
|
assert.deepEqual(result, { ok: true, requestId: "job-1", completed: 1, total: 1, failed: 0, cacheHits: 0 });
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(new URL(calls[0].url).pathname, "/crmRestApi/rest/rv:be91c002-2e5d-4ed2-a37e-e0c837bf141f/en/11.13.18.05:9/opportunities/SYNTH-1/child/ChildRevenue");
|
||||||
|
assert.equal(new URL(calls[0].url).searchParams.get("q"), "(SplitTypeCode!='DETAILCHILDSPLIT') AND (RecurTypeCode!='CHILDRECUR')");
|
||||||
|
assert.equal(new URL(calls[0].url).searchParams.get("limit"), "15");
|
||||||
|
assert.equal(new URL(calls[1].url).searchParams.get("offset"), "1");
|
||||||
|
assert.equal(calls[0].options.headers.Authorization, "Bearer synthetic-products-token");
|
||||||
|
assert.ok(h.progress.every(event => event.tabId === 7 && event.options.frameId === 2));
|
||||||
|
assert.equal(h.progress[0].message.type, PREFIX + "opptyProductsProgress");
|
||||||
|
assert.equal(h.progress[0].message.items.length, 2);
|
||||||
|
assert.equal(h.progress.at(-1).message.state, "complete");
|
||||||
|
assert.deepEqual(Object.keys(storage[CACHE_KEY]["SYNTH-1"]).sort(), ["fetchedAt", "items"]);
|
||||||
|
const restarted = loadBackground({ family, storage });
|
||||||
|
assert.equal((await restarted.dispatch(productRequest("job-2"))).cacheHits, 1);
|
||||||
|
h.advance(DAY);
|
||||||
|
await h.dispatch(productRequest("job-3"));
|
||||||
|
assert.equal(calls.length, 4, "default TTL expires at 24 hours");
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${family}: product queue has four workers, shares in-flight results and reports partial failures`, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ family, fetch: (url) => new Promise(resolve => calls.push({ url, resolve })) });
|
||||||
|
const first = h.dispatch(productRequest("concurrent-1", ["S1", "S2", "S3", "S4", "S5"]));
|
||||||
|
await drain();
|
||||||
|
assert.equal(calls.length, 4);
|
||||||
|
const duplicate = h.dispatch(productRequest("concurrent-2", ["S1"]));
|
||||||
|
await drain();
|
||||||
|
assert.equal(calls.length, 4);
|
||||||
|
calls[0].resolve(response({ items: [{ RevnNumber: "R1" }], hasMore: false }));
|
||||||
|
await drain();
|
||||||
|
assert.equal(calls.length, 5);
|
||||||
|
calls[1].resolve(response({}, 500));
|
||||||
|
for (const call of calls.slice(2)) call.resolve(response({ items: [], hasMore: false }));
|
||||||
|
assert.equal((await duplicate).ok, true);
|
||||||
|
const result = await first;
|
||||||
|
assert.equal(result.failed, 1);
|
||||||
|
assert.equal(result.completed, 5);
|
||||||
|
assert.equal(h.progress.filter(event => event.message.requestId === "concurrent-1").at(-1).message.state, "complete-with-errors");
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${family}: custom product TTL and zero-count pagination failures`, async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const h = loadBackground({ family, fetch: async () => { calls++; return response({ items: [], hasMore: false }); } });
|
||||||
|
const message = { ...productRequest("custom-ttl"), cacheMaxAgeMs: 1000 };
|
||||||
|
await h.dispatch(message);
|
||||||
|
h.advance(999);
|
||||||
|
assert.equal((await h.dispatch(message)).cacheHits, 1);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
h.advance(1);
|
||||||
|
assert.equal((await h.dispatch(message)).cacheHits, 0);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
const stalled = loadBackground({ family, fetch: async () => response({ items: [], count: 0, hasMore: true }) });
|
||||||
|
assert.equal((await stalled.dispatch(productRequest("stalled"))).failed, 1);
|
||||||
|
assert.match(stalled.progress[0].message.error, /without additional results/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${family}: dynamic XSRF cookie lookup, missing-cookie result and temporary-tab cleanup`, async () => {
|
||||||
|
const cookie = { name: "XSRF-TOKEN-SYNTHETIC", value: "synthetic-xsrf", domain: "eeho.fa.us2.oraclecloud.com", path: "/", storeId: "test-store" };
|
||||||
|
const h = loadBackground({ family, cookies: () => [cookie] });
|
||||||
|
const result = await h.dispatch({ type: PREFIX + "getXsrfToken" });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.cookieName, cookie.name);
|
||||||
|
assert.equal(result.token, cookie.value);
|
||||||
|
assert.ok(h.cookieQueries.some(query => query.storeId === "test-store"));
|
||||||
|
const missing = loadBackground({ family });
|
||||||
|
assert.equal((await missing.dispatch({ type: PREFIX + "getXsrfToken" })).token, "");
|
||||||
|
const refresh = await missing.dispatch({ type: PREFIX + "refreshXsrfCookie" });
|
||||||
|
assert.equal(refresh.refreshed, false);
|
||||||
|
assert.equal(refresh.durationMs, 15000);
|
||||||
|
assert.deepEqual(missing.removedTabs, [91]);
|
||||||
|
assert.equal(missing.tabs[0].active, false);
|
||||||
|
assert.equal(missing.tabs[0].windowId, 3);
|
||||||
|
assert.equal(missing.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
|
||||||
|
let refreshHarness;
|
||||||
|
refreshHarness = loadBackground({ family, cookies: () => refreshHarness.tabs.length ? [cookie] : [] });
|
||||||
|
assert.equal((await refreshHarness.dispatch({ type: PREFIX + "refreshXsrfCookie" })).refreshed, true);
|
||||||
|
assert.deepEqual(refreshHarness.removedTabs, [91]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${family}: SPA token selection, account POST contracts and debug envelope`, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ family, fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
if (calls.length === 1) return response([
|
||||||
|
{ tokenService: "UNRELATED", uiaasHeader: { value: "synthetic-unused" } },
|
||||||
|
{ tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-account" } },
|
||||||
|
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption" } }
|
||||||
|
]);
|
||||||
|
if (calls.length === 2) return response({ items: [{ id: "synthetic/id" }] });
|
||||||
|
return response({ items: [{ planType: "Synthetic", subPlanNum: "plan-1" }] });
|
||||||
|
} });
|
||||||
|
const tokens = await h.dispatch({ type: PREFIX + "requestConsumerTokenService" });
|
||||||
|
const plans = await h.dispatch({ type: PREFIX + "requestAccountPlans", partyNumber: "synthetic-party", authorizations: tokens.authorizations });
|
||||||
|
assert.equal(plans.ok, true);
|
||||||
|
assert.equal(plans.accountPlanId, "synthetic/id");
|
||||||
|
assert.deepEqual(plans.plans, [{ planType: "Synthetic", subPlanNum: "plan-1" }]);
|
||||||
|
assert.equal(calls[1].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-account/service/accountsummary");
|
||||||
|
assert.deepEqual(JSON.parse(calls[1].options.body), [{ key: "regId", value: "synthetic-party" }]);
|
||||||
|
assert.equal(calls[1].options.headers["account-ti-authorization"], "synthetic-account");
|
||||||
|
assert.equal(calls[2].options.headers["g2m-authorization"], "synthetic-consumption");
|
||||||
|
assert.equal(calls[2].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/account/accountPlans/synthetic%2Fid");
|
||||||
|
assert.deepEqual(JSON.parse(calls[2].options.body), {
|
||||||
|
hierarchy: "single", products: "all", language: "english",
|
||||||
|
planStatus: ["active", "awaiting_provisioning", "awaiting_provisioning_error", "draft", "expired", "final_billing", "onhold", "provisioned", "signed", "superseded", "superseded_pending_final_billing", "suspended", "terminated", "terminated_pending_final_billing"]
|
||||||
|
});
|
||||||
|
assert.ok(calls.slice(1).every(call => call.options.method === "POST" && call.options.credentials === "include"));
|
||||||
|
assert.deepEqual(plans.debugRequests.map(entry => entry.name), ["requestAccountSummary", "requestAccountPlans"]);
|
||||||
|
assert.ok(plans.debugRequests.every(entry => entry.httpStatus === 200 && entry.status === "success" && entry.durationMs >= 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${family}: unknown/invalid messages fail without network; storage failure does not discard fetched products`, async () => {
|
||||||
|
const h = loadBackground({ family });
|
||||||
|
assert.equal(h.listener({ type: "unknown" }, {}, () => assert.fail("Unexpected reply")), false);
|
||||||
|
assert.equal((await h.dispatch({ type: PREFIX + "requestOpptyProducts" })).ok, false);
|
||||||
|
assert.equal((await h.dispatch({ type: PREFIX + "requestAccountPlans" })).ok, false);
|
||||||
|
assert.equal((await h.dispatch({ type: PREFIX + "requestConsumptionTrend", subPlanNum: "S1", period: "unsupported" })).ok, false);
|
||||||
|
const noStorage = loadBackground({ family, storageError: true, fetch: async () => response({ items: [{ RevnNumber: "R1" }], hasMore: false }) });
|
||||||
|
assert.equal((await noStorage.dispatch(productRequest("no-storage"))).ok, true);
|
||||||
|
assert.equal(noStorage.progress[0].message.items.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${family}: consumption network/HTTP/JSON failures retain the response and debug contracts`, async () => {
|
||||||
|
for (const failure of ["network", "http", "json"]) {
|
||||||
|
const h = loadBackground({ family, fetch: async () => {
|
||||||
|
if (failure === "network") throw new Error("Synthetic network error");
|
||||||
|
return failure === "http" ? response({}, 401) : new Response("<html>Synthetic fallback</html>");
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch({ type: PREFIX + "requestConsumptionTrend", subPlanNum: "synthetic-plan", period: "weekly",
|
||||||
|
authorization: { name: "g2m-authorization", value: "synthetic-token" } });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.debugRequests.length, 1);
|
||||||
|
assert.equal(result.debugRequests[0].name, "requestConsumptionServiceWeekly");
|
||||||
|
assert.equal(result.debugRequests[0].status, "failed");
|
||||||
|
assert.equal(result.debugRequests[0].httpStatus, failure === "network" ? null : failure === "http" ? 401 : 200);
|
||||||
|
assert.match(result.error, failure === "network" ? /Synthetic network error/ : failure === "http" ? /status 401/ : /invalid JSON/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
const vm = require("node:vm");
|
const vm = require("node:vm");
|
||||||
|
const { composeScript } = require("./support/application.cjs");
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..");
|
|
||||||
|
|
||||||
function createResponse(body, contentType) {
|
function createResponse(body, contentType) {
|
||||||
return {
|
return {
|
||||||
@@ -24,7 +22,7 @@ function createResponse(body, contentType) {
|
|||||||
|
|
||||||
function loadBackground(responses, createdTabs, removedTabs, options = {}) {
|
function loadBackground(responses, createdTabs, removedTabs, options = {}) {
|
||||||
let listener;
|
let listener;
|
||||||
const source = fs.readFileSync(path.join(root, "src", "background.js"), "utf8");
|
const source = composeScript("background");
|
||||||
const context = {
|
const context = {
|
||||||
chrome: {
|
chrome: {
|
||||||
runtime: {
|
runtime: {
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
const vm = require("node:vm");
|
const vm = require("node:vm");
|
||||||
|
const { loadApplication, composeScript } = require("./support/application.cjs");
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..");
|
|
||||||
|
|
||||||
function loadContentHelpers() {
|
function loadContentHelpers() {
|
||||||
const source = fs.readFileSync(path.join(root, "src", "content.js"), "utf8");
|
|
||||||
const marker = "\n if (insertTile()) {";
|
|
||||||
const instrumented = source.replace(marker, `
|
|
||||||
globalThis.__consumptionTrendTest = {
|
|
||||||
normalizeConsumptionTrendItems,
|
|
||||||
formatConsumptionPointLabel,
|
|
||||||
createNiceConsumptionMaximum,
|
|
||||||
calculateAverageConsumption
|
|
||||||
};
|
|
||||||
${marker}`);
|
|
||||||
const context = {
|
const context = {
|
||||||
chrome: { runtime: { onMessage: { addListener() {} } } },
|
chrome: { runtime: { onMessage: { addListener() {} } } },
|
||||||
document: { getElementById: () => ({}) },
|
document: { getElementById: () => ({}) },
|
||||||
@@ -36,13 +24,12 @@ ${marker}`);
|
|||||||
Math
|
Math
|
||||||
};
|
};
|
||||||
|
|
||||||
vm.runInNewContext(instrumented, context);
|
return loadApplication("content", context).consumptionModel;
|
||||||
return context.__consumptionTrendTest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadBackground(fetchCalls) {
|
function loadBackground(fetchCalls) {
|
||||||
let listener;
|
let listener;
|
||||||
const source = fs.readFileSync(path.join(root, "src", "background.js"), "utf8");
|
const source = composeScript("background");
|
||||||
const headers = {
|
const headers = {
|
||||||
get(name) {
|
get(name) {
|
||||||
return name.toLowerCase() === "content-type" ? "application/json" : null;
|
return name.toLowerCase() === "content-type" ? "application/json" : null;
|
||||||
|
|||||||
36
tests/fixtures/consumption-trend.html
vendored
36
tests/fixtures/consumption-trend.html
vendored
@@ -12,25 +12,21 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="opportunities-extension-modal" class="opportunities-extension-modal" data-theme="light"></div>
|
<div id="opportunities-extension-modal" class="opportunities-extension-modal" data-theme="light"></div>
|
||||||
|
<script src="/fixture-runtime.js"></script>
|
||||||
<script>
|
<script>
|
||||||
window.chrome = { runtime: { onMessage: { addListener() {} } } };
|
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
const source = await fetch("/src/content.js").then((response) => response.text());
|
const app = window.createFixtureApplication({
|
||||||
const marker = /\r?\n if \(insertTile\(\)\) \{/;
|
fetch: () => Promise.reject(new Error("Network disabled in fixture")),
|
||||||
const exposure = `
|
browser: undefined,
|
||||||
window.__trendHarness = {
|
chrome: undefined
|
||||||
ensureStyles: ensureExtensionStyles,
|
});
|
||||||
createAccountPlanDetails,
|
const harness = {
|
||||||
createConsumptionTrendSection,
|
ensureStyles: app.contentStyles.ensureExtensionStyles,
|
||||||
renderConsumptionTrendChart
|
createAccountPlanDetails: app.consumptionPlansView.createAccountPlanDetails,
|
||||||
};
|
createConsumptionTrendSection: app.consumptionTrendController.createConsumptionTrendSection,
|
||||||
return;
|
renderConsumptionTrendChart: app.consumptionChart.renderConsumptionTrendChart
|
||||||
`;
|
};
|
||||||
const testableSource = source
|
|
||||||
.replace(" if (!isAllowedPage()) {", " if (false) {")
|
|
||||||
.replace(marker, `${exposure}\n if (insertTile()) {`);
|
|
||||||
window.eval(testableSource);
|
|
||||||
|
|
||||||
const plan = {
|
const plan = {
|
||||||
subPlanNum: "11276098",
|
subPlanNum: "11276098",
|
||||||
@@ -63,7 +59,7 @@
|
|||||||
return rows;
|
return rows;
|
||||||
});
|
});
|
||||||
|
|
||||||
window.__trendHarness.ensureStyles();
|
harness.ensureStyles();
|
||||||
const overlay = document.getElementById("opportunities-extension-modal");
|
const overlay = document.getElementById("opportunities-extension-modal");
|
||||||
overlay.dataset.theme = new URLSearchParams(window.location.search).get("theme") === "dark"
|
overlay.dataset.theme = new URLSearchParams(window.location.search).get("theme") === "dark"
|
||||||
? "dark"
|
? "dark"
|
||||||
@@ -82,13 +78,13 @@
|
|||||||
tabs.innerHTML = '<button class="opportunities-extension-account-plan-tab" aria-selected="true"><span class="opportunities-extension-account-plan-status-indicator" data-active="true"></span>Universal Credits 11276098</button>';
|
tabs.innerHTML = '<button class="opportunities-extension-account-plan-tab" aria-selected="true"><span class="opportunities-extension-account-plan-status-indicator" data-active="true"></span>Universal Credits 11276098</button>';
|
||||||
const panel = document.createElement("section");
|
const panel = document.createElement("section");
|
||||||
panel.className = "opportunities-extension-account-plan-panel";
|
panel.className = "opportunities-extension-account-plan-panel";
|
||||||
const trend = window.__trendHarness.createConsumptionTrendSection(plan);
|
const trend = harness.createConsumptionTrendSection(plan);
|
||||||
panel.append(window.__trendHarness.createAccountPlanDetails(plan), trend);
|
panel.append(harness.createAccountPlanDetails(plan), trend);
|
||||||
content.append(tabs, panel);
|
content.append(tabs, panel);
|
||||||
dialog.append(header, content);
|
dialog.append(header, content);
|
||||||
modal.append(dialog);
|
modal.append(dialog);
|
||||||
overlay.append(modal);
|
overlay.append(modal);
|
||||||
window.__trendHarness.renderConsumptionTrendChart(
|
harness.renderConsumptionTrendChart(
|
||||||
trend,
|
trend,
|
||||||
trend.querySelector(".opportunities-extension-consumption-chart"),
|
trend.querySelector(".opportunities-extension-consumption-chart"),
|
||||||
items,
|
items,
|
||||||
|
|||||||
13
tests/fixtures/extension.html
vendored
Normal file
13
tests/fixtures/extension.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Extension Synthetic Integration Fixture</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button id="open" type="button">Open Opportunities</button>
|
||||||
|
<script src="/fixture-runtime.js"></script>
|
||||||
|
<script src="/extension.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
77
tests/fixtures/extension.js
vendored
Normal file
77
tests/fixtures/extension.js
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
(() => {
|
||||||
|
const listeners = [];
|
||||||
|
const storage = new Map();
|
||||||
|
const opportunities = Array.from({ length: 25 }, (_, index) => ({
|
||||||
|
OptyNumber: `TEST${index + 1}`,
|
||||||
|
Name: `Synthetic opportunity ${index + 1} - Database migration`,
|
||||||
|
CustomerAccount: { PartyUniqueName: "Example public sector customer (Example City, BR)", PartyId: "test-party", PartyNumber: "test-account" },
|
||||||
|
Owner: { PersonFirstName: "Example", PersonLastName: "Owner" },
|
||||||
|
PrimaryRevenue: { RevnAmountCurcyCode: "USD", RevnAmount: "25000", WinProb: 60 },
|
||||||
|
EffectiveDate: "2026-11-15T00:00:00Z",
|
||||||
|
LastUpdateDate: "2026-09-15T17:00:00Z",
|
||||||
|
ForecastGroup_c: ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"][index % 5],
|
||||||
|
StatusCode: ["OPEN", "CLOSED", "LOST", "WON", "WON_PENDING"][index % 5]
|
||||||
|
}));
|
||||||
|
const products = [{ ProdGroupName: "Migrate databases to cloud", WorkloadName_c: "Example workload", RevnAmountCurcyCode: "USD", RevnAmount: "25000", TypeCode: "NEWCOMMIT", StatusCode: "OPEN", WinProb: 60, EffectiveDate: "2026-11-15", ConsumptionStartDate_c: "2026-11-15", RampMonths_c: 3 }];
|
||||||
|
const trend = Array.from({ length: 30 }, (_, index) => ({
|
||||||
|
fyDate: `2026-09-${String((index % 28) + 1).padStart(2, "0")}`,
|
||||||
|
fiscalTime: [`${String((index % 28) + 1).padStart(2, "0")}-Sep-26`],
|
||||||
|
fiscalQtr: "FY27-Q2", currencyCode: "USD", arrCd: 0,
|
||||||
|
usageCategory: index % 4 ? "Funded Allocation" : "OVERAGE",
|
||||||
|
usedAmount: 1000 + index * 100
|
||||||
|
}));
|
||||||
|
const authorizations = {
|
||||||
|
accountSummary: { label: "Account-Ti-Authorization", value: "synthetic-accp" },
|
||||||
|
accountPlans: { label: "G2m-Authorization", value: "synthetic-ocicons" }
|
||||||
|
};
|
||||||
|
const runtime = {
|
||||||
|
onMessage: { addListener: listener => listeners.push(listener) },
|
||||||
|
async sendMessage(message) {
|
||||||
|
const operation = message.type.split(".").at(-1);
|
||||||
|
if (operation === "getXsrfToken") return { ok: true, token: "synthetic-xsrf", cookieName: "XSRF-TOKEN-TEST" };
|
||||||
|
if (operation === "requestConsumerTokenService") return { ok: true, authorizations, debugRequests: [] };
|
||||||
|
if (operation === "requestOpptyProducts") {
|
||||||
|
message.optyNumbers.forEach((optyNumber, index) => listeners.forEach(listener => listener({
|
||||||
|
type: "opportunitiesExtension.opptyProductsProgress", requestId: message.requestId,
|
||||||
|
optyNumber, items: products, cachedAt: Date.now(), completed: index + 1,
|
||||||
|
total: message.optyNumbers.length, state: index + 1 === message.optyNumbers.length ? "complete" : "running"
|
||||||
|
})));
|
||||||
|
return { ok: true, requestId: message.requestId };
|
||||||
|
}
|
||||||
|
if (operation === "requestAccountPlans") return {
|
||||||
|
ok: true, debugRequests: [], plans: ["ACTIVE", "EXPIRED"].map((hdrStatusCode, index) => ({
|
||||||
|
subPlanNum: `PLAN${index + 1}`, planType: "Universal Credits", hdrStatusCode,
|
||||||
|
bookingCustomer: "Example Customer", soldToPartyName: "Example Sold To",
|
||||||
|
contractId: "synthetic-contract", startDate: "2026-06-01", endDate: "2027-05-31"
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
if (operation === "requestConsumptionTrend") return { ok: true, items: trend, debugRequests: [] };
|
||||||
|
throw new Error(`Unexpected synthetic message: ${operation}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const application = window.createFixtureApplication({
|
||||||
|
browser: { runtime: {
|
||||||
|
onMessage: runtime.onMessage,
|
||||||
|
sendMessage(message, callback) {
|
||||||
|
const result = runtime.sendMessage(message);
|
||||||
|
if (callback) result.then(callback);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} }, chrome: undefined,
|
||||||
|
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
|
||||||
|
async fetch(input, options) {
|
||||||
|
const url = new URL(input);
|
||||||
|
let body;
|
||||||
|
if (url.pathname === "/fscmRestApi/tokenrelay") body = { access_token: "synthetic-fusion-token", expires_in: 3600 };
|
||||||
|
else if (url.pathname.endsWith("custom-actions/queries")) {
|
||||||
|
const { offset, limit } = JSON.parse(options.body);
|
||||||
|
body = { items: opportunities.slice(offset, offset + limit), totalResults: opportunities.length, count: Math.min(limit, opportunities.length - offset), hasMore: offset + limit < opportunities.length, offset, limit };
|
||||||
|
} else if (url.pathname.endsWith("summarizations/generate")) body = { summaryText: "Synthetic AI summary.\nText is displayed safely: <script>never executed</script>." };
|
||||||
|
else throw new Error(`Unexpected synthetic fetch: ${url.pathname}`);
|
||||||
|
return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
application.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
||||||
|
document.getElementById("open").addEventListener("click", () => application.contentModal.openOpportunitiesModal());
|
||||||
|
application.contentModal.openOpportunitiesModal();
|
||||||
|
})();
|
||||||
11
tests/support/application.cjs
Normal file
11
tests/support/application.cjs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
const vm = require("node:vm");
|
||||||
|
const { composeScript } = require("../../scripts/compose.cjs");
|
||||||
|
|
||||||
|
function loadApplication(target, globals = {}) {
|
||||||
|
const factory = new vm.Script(composeScript(target, { bootstrap: false }), {
|
||||||
|
filename: `${target}.test-bundle.js`
|
||||||
|
}).runInNewContext(globals);
|
||||||
|
return factory();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { loadApplication, composeScript };
|
||||||
134
tests/support/characterization.cjs
Normal file
134
tests/support/characterization.cjs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const vm = require("node:vm");
|
||||||
|
const { loadApplication, composeScript } = require("./application.cjs");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "../..");
|
||||||
|
const plain = (value) => JSON.parse(JSON.stringify(value));
|
||||||
|
const response = (body, status = 200) => new Response(JSON.stringify(body), {
|
||||||
|
status, headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
const unexpectedFetch = async () => { throw new Error("Unexpected network request in isolated test"); };
|
||||||
|
|
||||||
|
function clock() {
|
||||||
|
let now = Date.UTC(2026, 8, 16, 12);
|
||||||
|
class ClockDate extends Date {
|
||||||
|
constructor(...args) { super(...(args.length ? args : [now])); }
|
||||||
|
static now() { return now; }
|
||||||
|
}
|
||||||
|
return { Date: ClockDate, advance: (ms) => { now += ms; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadContent({ fetch = unexpectedFetch, storage = new Map(), xsrf = null } = {}) {
|
||||||
|
const refreshes = [];
|
||||||
|
const context = {
|
||||||
|
fetch, testXsrf: xsrf, refreshes,
|
||||||
|
document: { querySelector: () => ({ value: "Current Quarter" }) },
|
||||||
|
localStorage: {
|
||||||
|
getItem: key => storage.get(key) ?? null,
|
||||||
|
setItem: (key, value) => storage.set(key, value)
|
||||||
|
},
|
||||||
|
Date, Intl, URL
|
||||||
|
};
|
||||||
|
const app = loadApplication("content", context);
|
||||||
|
app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
if (xsrf) app.authXsrfContent.getXsrfToken = xsrf;
|
||||||
|
app.platformContentMessages.refreshXsrfCookie = async () => { refreshes.push(true); return { refreshed: true }; };
|
||||||
|
return {
|
||||||
|
...app.opportunitiesFiscalPeriods, ...app.fusionOpportunities, ...app.authTokenRelay,
|
||||||
|
...app.authSalesCommon, ...app.preferencesStorage, ...app.opportunitiesFilters,
|
||||||
|
...app.opportunitiesFormatters, ...app.contentTheme, ...app.debugState,
|
||||||
|
setVersion: value => { app.opportunitiesQueryState.periodRequestVersion = value; },
|
||||||
|
setTypes: values => { app.preferencesState.selectedOpportunityTypeValues = new Set(values); },
|
||||||
|
setTable: (items, sortKey = "", sortDirection = "ascending") => {
|
||||||
|
app.opportunitiesTableState.opportunitiesTableState = { items, sortKey, sortDirection };
|
||||||
|
app.opportunitiesFilters.resetCustomerFilter(items);
|
||||||
|
app.opportunitiesFilters.resetOwnerFilter(items);
|
||||||
|
},
|
||||||
|
filters: () => {
|
||||||
|
const state = app.opportunitiesFilterState;
|
||||||
|
return {
|
||||||
|
stages: Array.from(state.selectedStages), statuses: Array.from(state.selectedStatuses),
|
||||||
|
customerKeys: Array.from(state.selectedCustomerKeys), ownerKeys: Array.from(state.selectedOwnerKeys),
|
||||||
|
customerSearch: state.customerSearch, ownerSearch: state.ownerSearch, tableSearch: state.tableSearch
|
||||||
|
};
|
||||||
|
},
|
||||||
|
storage, refreshes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = {}, cookies = () => [], storageError = false } = {}) {
|
||||||
|
const time = clock();
|
||||||
|
const listeners = [];
|
||||||
|
const progress = [];
|
||||||
|
const tabs = [];
|
||||||
|
const removedTabs = [];
|
||||||
|
const cookieQueries = [];
|
||||||
|
const api = { runtime: { onMessage: { addListener: fn => listeners.push(fn) } } };
|
||||||
|
const readCookies = details => { cookieQueries.push(plain(details)); return cookies(details); };
|
||||||
|
const getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) });
|
||||||
|
const store = value => Object.assign(storage, plain(value));
|
||||||
|
const openTab = details => { tabs.push(plain(details)); return { id: 91 }; };
|
||||||
|
const send = (tabId, message, options) => progress.push({ tabId, message: plain(message), options: plain(options || {}) });
|
||||||
|
if (family === "firefox") {
|
||||||
|
api.runtime.getBrowserInfo = async () => ({ name: "Firefox" });
|
||||||
|
api.storage = { local: {
|
||||||
|
get: async key => { if (storageError) throw new Error("Storage unavailable"); return getStored(key); },
|
||||||
|
set: async value => { if (storageError) throw new Error("Storage unavailable"); store(value); }
|
||||||
|
} };
|
||||||
|
api.tabs = { create: async details => openTab(details), remove: async id => removedTabs.push(id),
|
||||||
|
sendMessage: async (tabId, message, options) => send(tabId, message, options) };
|
||||||
|
api.cookies = {
|
||||||
|
get: async details => readCookies(details).find(cookie => cookie.name === details.name) || null,
|
||||||
|
getAll: async details => readCookies(details),
|
||||||
|
getAllCookieStores: async () => [{ id: "test-store" }]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const withStorageError = callback => {
|
||||||
|
api.runtime.lastError = storageError ? { message: "Storage unavailable" } : undefined;
|
||||||
|
try { callback(); } finally { delete api.runtime.lastError; }
|
||||||
|
};
|
||||||
|
api.storage = { local: {
|
||||||
|
get(key, callback) { withStorageError(() => callback(getStored(key))); },
|
||||||
|
set(value, callback) { withStorageError(() => { if (!storageError) store(value); callback(); }); }
|
||||||
|
} };
|
||||||
|
api.tabs = {
|
||||||
|
create(details, callback) { callback(openTab(details)); },
|
||||||
|
remove(id, callback) { removedTabs.push(id); callback(); },
|
||||||
|
sendMessage(tabId, message, options, callback) {
|
||||||
|
if (typeof options === "function") { callback = options; options = undefined; }
|
||||||
|
send(tabId, message, options); callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
api.cookies = {
|
||||||
|
get(details, callback) { callback(readCookies(details).find(cookie => cookie.name === details.name) || null); },
|
||||||
|
getAll(details, callback) { callback(readCookies(details)); },
|
||||||
|
getAllCookieStores(callback) { callback([{ id: "test-store" }]); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const context = {
|
||||||
|
[family === "firefox" ? "browser" : "chrome"]: api,
|
||||||
|
fetch, URL, Date: time.Date,
|
||||||
|
setTimeout: (callback, ms) => { time.advance(ms); return setTimeout(callback, 0); }
|
||||||
|
};
|
||||||
|
new vm.Script(composeScript("background"), { filename: "background.js" })
|
||||||
|
.runInNewContext(context);
|
||||||
|
assert.equal(listeners.length, 1, "Background must register one listener synchronously");
|
||||||
|
const sender = { id: "synthetic-extension", url: "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome",
|
||||||
|
tab: { id: 7, windowId: 3, cookieStoreId: "test-store" }, frameId: 2 };
|
||||||
|
function dispatch(message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let replies = 0;
|
||||||
|
const timer = setTimeout(() => reject(new Error("Message did not receive a response")), 2000);
|
||||||
|
const pending = listeners[0](message, sender, result => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
assert.equal(++replies, 1, "Message must receive exactly one response");
|
||||||
|
resolve(plain(result));
|
||||||
|
});
|
||||||
|
if (pending !== true) { clearTimeout(timer); reject(new Error("Expected asynchronous response contract")); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { dispatch, listener: listeners[0], progress, tabs, removedTabs, cookieQueries, storage, advance: time.advance };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { root, plain, response, loadContent, loadBackground };
|
||||||
Reference in New Issue
Block a user