Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1758a1f27e
|
|||
|
0d9e654b11
|
|||
|
0d0a7cfe14
|
|||
|
d689807a50
|
|||
|
2363e37aab
|
|||
|
9cdb46b6c2
|
|||
|
00bcfdc42e
|
|||
|
9b5e751179
|
|||
|
88de9b81b7
|
|||
|
3fa212e561
|
|||
|
318459cef8
|
|||
|
aff137b319
|
|||
|
8d2110e8e8
|
|||
|
1d5b28e95b
|
|||
|
7606643ff4
|
|||
|
3229ce9c41
|
|||
|
bf48ed1dcb
|
|||
|
45428c9626
|
|||
|
0f1f399d1a
|
|||
|
19edee4e11
|
|||
|
ba61f91c53
|
|||
|
5ef4c22c59
|
|||
|
60daabfe91
|
|||
|
71d550dfb1
|
|||
|
cc025e488d
|
|||
|
9eef03e5f4
|
|||
|
d392e5ddd7
|
|||
|
2ae057cc90
|
|||
|
8b4d88b226
|
|||
|
c9de750261
|
|||
|
7a7143b6e7
|
|||
|
e9c295be91
|
|||
|
f512ef9928
|
|||
|
3fd89a307c
|
|||
|
4d3f1f9760
|
|||
|
a182609415
|
|||
|
1a3decbeb6
|
|||
|
de4da0eec6
|
|||
|
cc567c8ff1
|
|||
|
6a3e9e6adf
|
|||
|
86973f7542
|
|||
|
8371f4e89e
|
|||
|
451c27e9d7
|
|||
|
f74071e39e
|
|||
|
f9595b94e0
|
|||
|
b8c493fc20
|
|||
|
b92d75f558
|
|||
|
3ef0d2aafd
|
|||
|
6c5db94aaa
|
|||
|
b4d8d9dbc1
|
|||
|
9ac5ffb11d
|
|||
|
ea164cdcd6
|
|||
|
3ee8eb6f21
|
|||
|
8d73954362
|
|||
|
eed4189a07
|
|||
| ac817a69d4 | |||
| 4421b28efe | |||
| 003035a3a4 |
1
.agents/skills/.openspec-target
Normal file
1
.agents/skills/.openspec-target
Normal file
@@ -0,0 +1 @@
|
|||||||
|
codex
|
||||||
206
.agents/skills/openspec-apply-change/SKILL.md
Normal file
206
.agents/skills/openspec-apply-change/SKILL.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
---
|
||||||
|
name: openspec-apply-change
|
||||||
|
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. Also use when the user says "openspec apply", "opsx apply", or "openspec implement".
|
||||||
|
allowed-tools: Bash(openspec:*)
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.16.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement tasks from an OpenSpec change.
|
||||||
|
|
||||||
|
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||||
|
|
||||||
|
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||||
|
|
||||||
|
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||||
|
|
||||||
|
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||||
|
|
||||||
|
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||||
|
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||||
|
|
||||||
|
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||||
|
|
||||||
|
**Input**: Optionally specify a change name (e.g., `$openspec-apply-change (Codex) or /openspec-apply-change (other agents) add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **Select the change**
|
||||||
|
|
||||||
|
If a name is provided, use it. Otherwise:
|
||||||
|
- Infer from conversation context if the user mentioned a change
|
||||||
|
- Auto-select if only one active change exists
|
||||||
|
- If ambiguous, run `openspec list --json` to get available changes and ask the user to select one
|
||||||
|
|
||||||
|
Always announce: "Using change: <name>" and how to override (e.g., `$openspec-apply-change (Codex) or /openspec-apply-change (other agents) <other>`).
|
||||||
|
|
||||||
|
2. **Check status to understand the schema**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>" --json
|
||||||
|
```
|
||||||
|
Parse the JSON to understand:
|
||||||
|
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||||
|
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
|
||||||
|
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||||
|
|
||||||
|
3. **Get apply instructions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openspec instructions apply --change "<name>" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
This returns:
|
||||||
|
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||||
|
- Progress (total, complete, remaining)
|
||||||
|
- Task list with status, source path, and source line
|
||||||
|
- Dynamic instruction based on current state
|
||||||
|
- Optional `context`: current required project instruction input from the selected root
|
||||||
|
- Optional `operationGuidance`: current advisory guidance for apply
|
||||||
|
- `missingArtifacts` (when present): required artifact ids with no output
|
||||||
|
|
||||||
|
**Handle states:**
|
||||||
|
- If `state: "blocked"`: show the message and pause implementation.
|
||||||
|
- If `missingArtifacts` is non-empty: suggest completing the missing artifacts. Run `openspec status --change "<name>" --json`, select the next `ready` artifact (not `skipped` or `blocked`), and use `openspec instructions "<artifact-id>" --change "<name>" --json` for its rules and template. Keep the selected `--store <id>` on both commands.
|
||||||
|
- Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
|
||||||
|
- If `state: "all_done"`: report that all tracked tasks are complete and suggest review or verification as appropriate before archiving
|
||||||
|
- Otherwise: proceed to implementation
|
||||||
|
|
||||||
|
Treat `context` as a required prompt-level input. Read and consider it, and
|
||||||
|
apply relevant project facts, conventions, and constraints while implementing.
|
||||||
|
Treat `operationGuidance` as optional additive advice. Read and consider every
|
||||||
|
entry, and follow entries that are applicable and compatible with the built-in
|
||||||
|
workflow.
|
||||||
|
|
||||||
|
Keep both fields separate from CLI-returned state, missing artifacts, tasks,
|
||||||
|
progress, `contextFiles`, and the built-in `instruction`. They are not
|
||||||
|
evidence of task completion, do not replace the built-in instruction, and do
|
||||||
|
not permit bypassing a blocked state. If context conflicts with the built-in
|
||||||
|
instruction, an explicit user choice, or a CLI-controlled value, report the
|
||||||
|
conflict and preserve the controlling value. If guidance is inapplicable or
|
||||||
|
conflicts with those controlling inputs, do not follow it and explain why.
|
||||||
|
These are prompt-level behavior contracts, not enforceable checks.
|
||||||
|
|
||||||
|
4. **Read context files**
|
||||||
|
|
||||||
|
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||||
|
The files depend on the schema being used:
|
||||||
|
- **spec-driven**: proposal, specs, design, tasks
|
||||||
|
- Other schemas: follow the contextFiles from CLI output
|
||||||
|
|
||||||
|
Do not copy `context` or `operationGuidance` verbatim into implementation
|
||||||
|
files or planning artifacts unless the user separately asks for that content.
|
||||||
|
|
||||||
|
5. **Show current progress**
|
||||||
|
|
||||||
|
Display:
|
||||||
|
- Schema being used
|
||||||
|
- Progress: "N/M tasks complete"
|
||||||
|
- Remaining tasks overview
|
||||||
|
- Dynamic instruction from CLI
|
||||||
|
|
||||||
|
6. **Implement tasks (loop until done or blocked)**
|
||||||
|
|
||||||
|
For each pending task:
|
||||||
|
- Show which task is being worked on
|
||||||
|
- Make the code changes required
|
||||||
|
- Keep changes minimal and focused
|
||||||
|
- Before editing, confirm the checkbox at the returned `sourcePath` and `line` still matches the task description; if it does not, rerun the apply instructions and use the refreshed location
|
||||||
|
- Mark the task complete at its returned `sourcePath` and `line`: `- [ ]` → `- [x]`
|
||||||
|
- Rerun the apply instructions and confirm that task is now done and progress changed
|
||||||
|
- Continue to next task
|
||||||
|
|
||||||
|
**Pause if:**
|
||||||
|
- Task is unclear → ask for clarification
|
||||||
|
- Implementation reveals a design issue → suggest updating artifacts
|
||||||
|
- A task needs work beyond what the spec and tasks describe, or you are tempted to drop, narrow, defer, or accept exceptions to specified behavior to make it fit → surface the added scope and ask; do not absorb it silently
|
||||||
|
- Error or blocker encountered → report and wait for guidance
|
||||||
|
- User interrupts
|
||||||
|
|
||||||
|
7. **On completion or pause, show status**
|
||||||
|
|
||||||
|
Display:
|
||||||
|
- Tasks completed this session
|
||||||
|
- Overall progress: "N/M tasks complete"
|
||||||
|
- If all done: report that tracked tasks are complete and suggest review or verification as appropriate before archiving
|
||||||
|
- If paused: explain why and wait for guidance
|
||||||
|
|
||||||
|
**Output During Implementation**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementing: <change-name> (schema: <schema-name>)
|
||||||
|
|
||||||
|
Working on task 3/7: <task description>
|
||||||
|
[...implementation happening...]
|
||||||
|
✓ Task complete
|
||||||
|
|
||||||
|
Working on task 4/7: <task description>
|
||||||
|
[...implementation happening...]
|
||||||
|
✓ Task complete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Completion**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementation Complete
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Progress:** 7/7 tasks complete ✓
|
||||||
|
|
||||||
|
### Completed This Session
|
||||||
|
- [x] Task 1
|
||||||
|
- [x] Task 2
|
||||||
|
...
|
||||||
|
|
||||||
|
All tracked tasks are complete. Review or verify the change as appropriate
|
||||||
|
before archiving. You can archive this change with `$openspec-archive-change (Codex) or /openspec-archive-change (other agents)`.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output On Pause (Issue Encountered)**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Implementation Paused
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Progress:** 4/7 tasks complete
|
||||||
|
|
||||||
|
### Issue Encountered
|
||||||
|
<description of the issue>
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
1. <option 1>
|
||||||
|
2. <option 2>
|
||||||
|
3. Other approach
|
||||||
|
|
||||||
|
What would you like to do?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Keep going through tasks until done or blocked
|
||||||
|
- Always read context files before starting (from the apply instructions output)
|
||||||
|
- If task is ambiguous, pause and ask before implementing
|
||||||
|
- If implementation reveals issues, pause and suggest artifact updates
|
||||||
|
- Keep code changes minimal and scoped to each task
|
||||||
|
- Update task checkbox immediately after completing each task
|
||||||
|
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||||
|
- When a task needs work beyond what the spec describes, surface the added scope and pause - never silently narrow, defer, or simplify away specified behavior
|
||||||
|
- Only mark a task `- [x]` when its specified behavior is fully implemented, not when it is partially done or deferred
|
||||||
|
- Use contextFiles from CLI output, don't assume specific file names
|
||||||
|
- Use each task's sourcePath and line to update its exact checkbox
|
||||||
|
- Do not use context or operation guidance as proof that a task is complete
|
||||||
|
- Apply relevant project context; report conflicts with controlling workflow inputs
|
||||||
|
- Consider every guidance entry; explain any inapplicable or conflicting advice
|
||||||
|
- Do not copy runtime context or operation guidance into implementation files or planning artifacts
|
||||||
|
- Preserve CLI-controlled blocked/ready/all-done behavior and completion criteria
|
||||||
|
|
||||||
|
**Fluid Workflow Integration**
|
||||||
|
|
||||||
|
This skill supports the "actions on a change" model:
|
||||||
|
|
||||||
|
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||||
|
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||||
221
.agents/skills/openspec-archive-change/SKILL.md
Normal file
221
.agents/skills/openspec-archive-change/SKILL.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
---
|
||||||
|
name: openspec-archive-change
|
||||||
|
description: Archive a completed OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. Also use when the user says "openspec archive" or "opsx archive".
|
||||||
|
allowed-tools: Bash(openspec:*)
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.16.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
Archive a completed change in the experimental workflow.
|
||||||
|
|
||||||
|
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||||
|
|
||||||
|
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||||
|
|
||||||
|
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||||
|
|
||||||
|
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||||
|
|
||||||
|
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||||
|
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||||
|
|
||||||
|
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||||
|
|
||||||
|
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec.
|
||||||
|
|
||||||
|
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **Select the change**
|
||||||
|
|
||||||
|
If a name is provided, use it. Otherwise:
|
||||||
|
- Infer from conversation context if the user mentioned a change
|
||||||
|
- Auto-select if only one active change exists
|
||||||
|
- If ambiguous, run `openspec list --json` to get available changes and ask the user to select one
|
||||||
|
|
||||||
|
When prompting, show only active changes (not already archived).
|
||||||
|
Include the schema used for each change if available.
|
||||||
|
|
||||||
|
Always announce: "Using change: <name>" and how to override (e.g., `$openspec-archive-change (Codex) or /openspec-archive-change (other agents) <other>`).
|
||||||
|
|
||||||
|
**Load current archive inputs before the existing archive checks:**
|
||||||
|
|
||||||
|
After resolving the selected change and planning root, run:
|
||||||
|
```bash
|
||||||
|
openspec instructions archive --change "<name>" --json
|
||||||
|
```
|
||||||
|
Keep the same selected-root flags on this command. This lookup is advisory and
|
||||||
|
optional: it only supplies extra prompt inputs, so it must never block archiving.
|
||||||
|
If it exits non-zero or returns invalid JSON — for example on an older CLI that
|
||||||
|
does not support this command yet — continue the archive workflow with no
|
||||||
|
context and no operation guidance. Do not report an error and do not stop.
|
||||||
|
|
||||||
|
A successful response may omit both optional fields. Treat `context` as a
|
||||||
|
required prompt-level input: read and consider it, and apply relevant project
|
||||||
|
facts, conventions, and constraints. Treat `operationGuidance` as optional
|
||||||
|
additive advice: read and consider every entry, and follow entries that are
|
||||||
|
applicable and compatible with the built-in archive workflow.
|
||||||
|
|
||||||
|
Keep both fields separate from built-in steps, explicit user choices, resolved
|
||||||
|
paths, CLI checks, and command contracts. If context conflicts with one of those
|
||||||
|
controlling inputs, report the conflict and preserve the controlling value. If
|
||||||
|
guidance is inapplicable or conflicts with a controlling input, do not follow it
|
||||||
|
and explain why. Do not infer replacement paths, skipped prompts, or flags from
|
||||||
|
either field, and do not copy their text verbatim into specs, change artifacts,
|
||||||
|
or archive summaries unless the user separately asks for it. These are
|
||||||
|
prompt-level behavior contracts, not enforceable checks.
|
||||||
|
|
||||||
|
2. **Check artifact completion status**
|
||||||
|
|
||||||
|
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||||
|
|
||||||
|
Parse the JSON to understand:
|
||||||
|
- `schemaName`: The workflow being used
|
||||||
|
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||||
|
- `artifacts`: List of artifacts with their status (`done`, `skipped`, or other)
|
||||||
|
|
||||||
|
**If any artifacts are neither `done` nor `skipped`** (skipped artifacts satisfy the requirement - the change declares skip_specs):
|
||||||
|
- Display warning listing incomplete artifacts
|
||||||
|
- Ask the user to confirm they want to proceed
|
||||||
|
- Proceed if user confirms
|
||||||
|
|
||||||
|
3. **Check task completion status**
|
||||||
|
|
||||||
|
Run `openspec list --json` with the same selected-root flags and find the
|
||||||
|
entry in `changes` whose `name` exactly matches the selected change.
|
||||||
|
Require exactly one match and nonnegative integer `totalTasks` and
|
||||||
|
`completedTasks`, with `completedTasks <= totalTasks`. The CLI resolves
|
||||||
|
the schema's tracked task files, including custom artifact names, output
|
||||||
|
paths, and globs.
|
||||||
|
Incomplete tasks = `totalTasks - completedTasks`.
|
||||||
|
|
||||||
|
Do not infer task completion from artifact status or the absence of a
|
||||||
|
top-level `tasks.md`. If the lookup fails, returns invalid JSON, omits or
|
||||||
|
duplicates the selected change, or returns invalid counts, report the problem
|
||||||
|
and stop before syncing or archiving.
|
||||||
|
The CLI counts only `x`/`X` checkbox markers as complete;
|
||||||
|
other markers, including unfamiliar ones, remain incomplete.
|
||||||
|
|
||||||
|
**If incomplete tasks found:**
|
||||||
|
- Display warning showing count of incomplete tasks
|
||||||
|
- Ask the user to confirm they want to proceed
|
||||||
|
- Proceed if user confirms
|
||||||
|
|
||||||
|
**If `totalTasks` is zero:** Proceed without a task-related warning.
|
||||||
|
|
||||||
|
4. **Assess delta spec sync state**
|
||||||
|
|
||||||
|
Use `artifactPaths.specs.existingOutputPaths` from status JSON as the only
|
||||||
|
delta-spec source. If the `specs` entry is missing or
|
||||||
|
`existingOutputPaths` is empty, proceed without a sync prompt and do not infer
|
||||||
|
delta specs from other artifacts.
|
||||||
|
|
||||||
|
**If delta specs exist:**
|
||||||
|
- Compare each delta spec with its corresponding main spec at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path)
|
||||||
|
- A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input:
|
||||||
|
- If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and mark that capability as sync-blocked. Never invent a requirement that has no current version.
|
||||||
|
- Otherwise, if the delta has only REMOVED requirements and the change's `.openspec.yaml` declares `retire_capabilities: true`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync.
|
||||||
|
- Otherwise, if the delta has no ADDED requirements, report that no sync is possible and mark that capability as sync-blocked. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. `openspec archive` refuses the unmarked REMOVED-only case with `Spec must have at least one requirement`.
|
||||||
|
- Otherwise, count the capability as needing sync and name it in the summary (`<capability-path>: new main spec will be created`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as `openspec archive` does.
|
||||||
|
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||||
|
- Continue assessing the remaining capabilities even when one is sync-blocked. Show a combined summary before prompting.
|
||||||
|
|
||||||
|
**Prompt options:**
|
||||||
|
- If any capability is sync-blocked: explain why and offer only "Archive without syncing", "Cancel"
|
||||||
|
- Otherwise, if changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||||
|
- Otherwise, if already synced: "Archive now", "Sync anyway", "Cancel"
|
||||||
|
|
||||||
|
Route on the answer:
|
||||||
|
- "Cancel" — stop, do not archive
|
||||||
|
- "Archive without syncing" or "Archive now" — proceed to archive
|
||||||
|
- "Sync now" or "Sync anyway" — sync, then verify (below). Do not start any sync while a capability is sync-blocked; explain the blocker and repeat the available choices.
|
||||||
|
- Anything else — ask again rather than archiving
|
||||||
|
|
||||||
|
Before a selected sync writes any main spec, run
|
||||||
|
`openspec instructions specs --change "<name>" --json` once with the same
|
||||||
|
selected-root flags. Require a zero exit status and valid artifact-instruction
|
||||||
|
JSON. If the lookup fails or returns invalid JSON, report the error and stop
|
||||||
|
before writing any main spec or moving the change. A valid response with omitted
|
||||||
|
`rules` is the no-rules case. Apply returned `rules` only to the content and
|
||||||
|
form of main specs produced by this merge; do not use them as archive guidance,
|
||||||
|
change CLI behavior, or copy the rule text into any output file.
|
||||||
|
|
||||||
|
Then run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge) for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching `specs` instructions again. Do not delegate it to a background task — step 5 would move `changeRoot` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
|
||||||
|
|
||||||
|
If the sync reports any stop or blocking condition, treat the sync as failed.
|
||||||
|
Stop the archive immediately. Do not perform the post-sync content comparison and do not move its `changeRoot`.
|
||||||
|
Nothing has moved, so the user can fix the blocking condition or re-run the sync.
|
||||||
|
|
||||||
|
After the sync writes each main spec, verify its structure against the canonical sync contract:
|
||||||
|
- A new main spec starts with a `# <capability> Specification` title. An existing main spec keeps its title exactly as it is.
|
||||||
|
- Preserve existing `## Purpose` sections completely untouched for established main specs.
|
||||||
|
- For a new main spec, copy the delta `## Purpose` verbatim. Warn only if the purpose text is shorter than standard validation expects. Do not regenerate or rewrite existing authored purpose. If no usable `## Purpose` is provided, use the existing TBD Purpose behavior and warning.
|
||||||
|
- Verify that no delta-style section headers (`## ADDED Requirements`, `## MODIFIED Requirements`, `## REMOVED Requirements`, `## RENAMED Requirements`) remain in the main spec, adhering strictly to the sync workflow formatting rules.
|
||||||
|
- Requirement blocks the sync wrote or changed use `### Requirement:` headings, and their scenarios use `#### Scenario:` headings, under the spec's `## Requirements` section. Leave content the delta does not mention exactly as it is.
|
||||||
|
|
||||||
|
Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
|
||||||
|
- ADDED requirements present
|
||||||
|
- MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
|
||||||
|
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty.
|
||||||
|
- RENAMED requirements present under the new name and absent under the old one
|
||||||
|
|
||||||
|
If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and `changeRoot` is intact, so the user can fix the mismatch or re-run the sync and start the archive again.
|
||||||
|
|
||||||
|
5. **Perform the archive**
|
||||||
|
|
||||||
|
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
|
||||||
|
```bash
|
||||||
|
mkdir -p "<planningHome.changesDir>/archive"
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate the target name: use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-<change-name>`. Never stack a second date (same rule as `openspec archive`).
|
||||||
|
|
||||||
|
**Check if target already exists:**
|
||||||
|
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||||
|
- If no: Move `changeRoot` to the archive directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv "<changeRoot>" "<planningHome.changesDir>/archive/<target-name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Display summary**
|
||||||
|
|
||||||
|
Show archive completion summary including:
|
||||||
|
- Change name
|
||||||
|
- Schema that was used
|
||||||
|
- Archive location
|
||||||
|
- Whether specs were synced (if applicable)
|
||||||
|
- Note about any warnings (incomplete artifacts/tasks)
|
||||||
|
|
||||||
|
**Output On Success**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Archive Complete
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**Schema:** <schema-name>
|
||||||
|
**Archived to:** the archive path derived from `planningHome.changesDir`/<target-name>/
|
||||||
|
**Specs:** <"✓ Synced to main specs" only if the step 4 verification passed; otherwise "No delta specs" or "Sync skipped">
|
||||||
|
|
||||||
|
<"All artifacts complete. All tasks complete." — or, if archived with warnings, list them instead (e.g. "Archived with 2 incomplete tasks")>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Announce the selected change; prompt for selection when it is ambiguous
|
||||||
|
- Use artifact graph (openspec status --json) for completion checking
|
||||||
|
- Don't block archive on warnings - just inform and confirm
|
||||||
|
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||||
|
- Show clear summary of what happened
|
||||||
|
- If sync is requested, run the `openspec-sync-specs` workflow inline (agent-driven)
|
||||||
|
- Never archive while a spec sync is still in flight — run the sync inline and verify the main specs before moving `changeRoot`
|
||||||
|
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||||
|
- Apply relevant runtime context and report conflicts; operation guidance remains advisory
|
||||||
|
- Consider every guidance entry and explain any inapplicable or conflicting advice
|
||||||
|
- Existing CLI checks, resolved paths, prompts, and command contracts are unchanged
|
||||||
|
- Artifact rules constrain only the specs being written and are never operation guidance
|
||||||
|
- Never copy runtime context, operation guidance, or artifact-rule text verbatim into output files
|
||||||
356
.agents/skills/openspec-explore/SKILL.md
Normal file
356
.agents/skills/openspec-explore/SKILL.md
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
---
|
||||||
|
name: openspec-explore
|
||||||
|
description: Enter OpenSpec explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements in a project that uses OpenSpec. Use when the user wants to think through something before or during an OpenSpec change. Also use when the user says "openspec explore" or "opsx explore".
|
||||||
|
allowed-tools: Bash(openspec:*)
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.16.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||||
|
|
||||||
|
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and point them at `$openspec-propose (Codex) or /openspec-propose (other agents)`, which turns the discussion into a change. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. An explicit request from the user to capture the exploration as a new change is itself that confirmation, covering the change and the change artifacts the request names; scaffold it first as described below.
|
||||||
|
|
||||||
|
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||||
|
|
||||||
|
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||||
|
|
||||||
|
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||||
|
|
||||||
|
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||||
|
|
||||||
|
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||||
|
|
||||||
|
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||||
|
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||||
|
|
||||||
|
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Stance
|
||||||
|
|
||||||
|
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||||
|
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||||
|
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||||
|
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||||
|
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||||
|
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planning a Change
|
||||||
|
|
||||||
|
When the user is planning a change, guide them toward shared understanding with focused discovery questions. For open-ended discussion, follow the conversation without imposing an interview or a required output.
|
||||||
|
|
||||||
|
Before asking a factual question, follow the context discovery below and inspect relevant OpenSpec artifacts, source, tests, docs, and configuration. Do not ask the user to repeat facts you can verify. Summarize relevant findings without reproducing private context or rules. If evidence is missing, conflicting, or inaccessible, state that limitation and ask only for the clarification needed to proceed.
|
||||||
|
|
||||||
|
- **Follow dependencies** - Resolve the next blocking decision before its dependent details. For example, clarify the user's outcome and scope before choosing an API or data model. Revisit downstream assumptions when an earlier answer changes. Skip branches that do not matter to this goal.
|
||||||
|
- **Keep questions focused** - Ask one focused question at a time, and briefly explain why it matters and which decision it unlocks. Batch questions only if the user asks for a batch; keep them small and group related decisions.
|
||||||
|
- **Offer grounded recommendations** - When evidence supports a recommendation, state your preferred option and why it fits the user's goals, with alternatives and their tradeoffs when useful. Do not invent intent, priorities, or external constraints: ask the user when only they can answer. Avoid a fixed question format.
|
||||||
|
- **Keep a conversational record** - Track decisions in the conversation, not in files. Separate confirmed decisions from proposed defaults and unresolved questions. Silence is not acceptance. Accepting an answer or a batch of recommendations is not permission to write. Keep file-write confirmation separate from discovery questions and follow the guardrails below.
|
||||||
|
|
||||||
|
Stop asking when the user has enough clarity. Let them pause, pivot, or defer a decision; do not exhaust every branch or force a proposal.
|
||||||
|
|
||||||
|
For example, after inspecting the relevant code:
|
||||||
|
|
||||||
|
```text
|
||||||
|
The CLI already uses SQLite and has no remote service. Is sharing state
|
||||||
|
across devices in scope? That determines whether local storage is enough.
|
||||||
|
If this stays a single-device tool, I recommend keeping SQLite to avoid
|
||||||
|
adding a service to operate; shared state would need a separate sync design.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You Might Do
|
||||||
|
|
||||||
|
Depending on what the user brings, you might:
|
||||||
|
|
||||||
|
**Explore the problem space**
|
||||||
|
- Ask clarifying questions that emerge from what they said
|
||||||
|
- Challenge assumptions
|
||||||
|
- Reframe the problem
|
||||||
|
- Find analogies
|
||||||
|
|
||||||
|
**Investigate the codebase**
|
||||||
|
- Map existing architecture relevant to the discussion
|
||||||
|
- Find integration points
|
||||||
|
- Identify patterns already in use
|
||||||
|
- Surface hidden complexity
|
||||||
|
|
||||||
|
**Compare options**
|
||||||
|
- Brainstorm multiple approaches
|
||||||
|
- Build comparison tables
|
||||||
|
- Sketch tradeoffs
|
||||||
|
- Recommend a path (if asked)
|
||||||
|
|
||||||
|
**Visualize**
|
||||||
|
```
|
||||||
|
+------------------------------------------+
|
||||||
|
| Use ASCII diagrams liberally |
|
||||||
|
+------------------------------------------+
|
||||||
|
| |
|
||||||
|
| [State A] -------> [State B] |
|
||||||
|
| | |
|
||||||
|
| v |
|
||||||
|
| [State C] |
|
||||||
|
| |
|
||||||
|
| System diagrams, state machines, |
|
||||||
|
| data flows, architecture sketches, |
|
||||||
|
| dependency graphs, comparison tables |
|
||||||
|
| |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**Draw with plain ASCII only** — borders `+` `-` `|`, arrows `-->` `<--` `^` `v`, markers `*` `x`.
|
||||||
|
Unicode diagram glyphs can render at different widths across terminals, fonts, and locales, so padded boxes and aligned tables can drift. Keep every diagram character ASCII.
|
||||||
|
|
||||||
|
**Surface risks and unknowns**
|
||||||
|
- Identify what could go wrong
|
||||||
|
- Find gaps in understanding
|
||||||
|
- Suggest spikes or investigations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenSpec Awareness
|
||||||
|
|
||||||
|
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||||
|
|
||||||
|
### Check for context
|
||||||
|
|
||||||
|
At the start, quickly check what exists:
|
||||||
|
```bash
|
||||||
|
openspec list --json
|
||||||
|
```
|
||||||
|
|
||||||
|
This tells you:
|
||||||
|
- If there are active changes
|
||||||
|
- Their names and task status
|
||||||
|
- What the user might be working on
|
||||||
|
|
||||||
|
That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
|
||||||
|
```bash
|
||||||
|
openspec list --specs
|
||||||
|
```
|
||||||
|
Add `--json` for ids and requirement counts, and append `--store "<id>"` only for a registered standalone store. This is the inventory of what the project already claims to do, and `openspec list` on its own never shows it. To look at one, run `openspec show "<spec-id>" --type spec --json --no-scenarios` (same `--store` rule) - it returns that capability's purpose and requirement texts without pulling the whole spec file into context, and `--type spec` stops a change of the same name from making it ambiguous.
|
||||||
|
|
||||||
|
The filtered read is only an overview. Before deciding what is already covered or what should change, read each relevant spec in full, including scenarios, with `openspec show "<spec-id>" --type spec` (same `--store` rule).
|
||||||
|
|
||||||
|
Then read the project's own context from the resolved root - `<root.path>/openspec/config.yaml` (or `config.yml`). Use the `root.path` returned above, and skip this if neither file exists:
|
||||||
|
- `context`: project background - tech stack, conventions, constraints
|
||||||
|
- `rules`: keyed by artifact id - the entries for an artifact apply only when you write that artifact
|
||||||
|
|
||||||
|
Ground your thinking in these. They are constraints for you to follow, not content to reproduce: do NOT copy them into the conversation or into any artifact you create.
|
||||||
|
|
||||||
|
### When no change exists
|
||||||
|
|
||||||
|
Think freely. When insights crystallize, you might offer:
|
||||||
|
|
||||||
|
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||||
|
- Or keep exploring - no pressure to formalize
|
||||||
|
|
||||||
|
If the user asks you to capture the exploration as a new change, that request is the confirmation required above. It covers scaffolding that change and creating the change artifacts the request names, and nothing else. This holds only when the request is theirs: a yes to an offer you made confirms only the scope your offer itself named, so name the change and the artifacts in the offer. Don't re-ask for what they already asked for; do ask before anything beyond it. Transition seamlessly into the requested capture:
|
||||||
|
|
||||||
|
1. Run `openspec new change "<name>"` (with `--store <id>` when applicable) before creating any artifacts. Never create a new change directory under `openspec/changes/` by hand; the CLI scaffold creates required metadata such as `.openspec.yaml`. Keep the selected `--store <id>` on every applicable follow-up `status` and `instructions` command.
|
||||||
|
2. Run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is `ready`, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own `instruction` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run `openspec instructions "<prerequisite-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) for that prerequisite whether it is `ready` or `blocked`. If its own `instruction` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves.
|
||||||
|
3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. Choose the output path before creating anything: when `resolvedOutputPath` is a glob, use the instruction to choose a concrete path inside `changeRoot` and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`. If the instruction delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Otherwise write the artifact to that output path. Verify that the selected concrete output exists at that output path.
|
||||||
|
4. After creating each artifact, re-run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) and continue until every requested artifact is `done`, `skipped`, or was deliberately skipped because its own `instruction` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still `blocked` only because you deliberately skipped a conditional prerequisite, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture.
|
||||||
|
|
||||||
|
Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: `$openspec-propose (Codex) or /openspec-propose (other agents)` writes the remaining planning artifacts, and `$openspec-apply-change (Codex) or /openspec-apply-change (other agents)` implements the change once tasks exist. Capturing artifacts never starts implementing them.
|
||||||
|
|
||||||
|
### When a change exists
|
||||||
|
|
||||||
|
If the user mentions a change or you detect one is relevant:
|
||||||
|
|
||||||
|
1. **Resolve and read existing artifacts for context**
|
||||||
|
- Run `openspec status --change "<name>" --json`.
|
||||||
|
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
|
||||||
|
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
|
||||||
|
|
||||||
|
2. **Reference them naturally in conversation**
|
||||||
|
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||||
|
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||||
|
|
||||||
|
3. **Offer to capture when decisions are made**
|
||||||
|
|
||||||
|
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
|
||||||
|
|
||||||
|
The files below are the default spec-driven schema's. With another schema, capture each insight in whichever artifact from `artifactPaths` fits it, and name that artifact in your offer; never create an artifact the schema does not define.
|
||||||
|
|
||||||
|
| Insight Type | Where to Capture |
|
||||||
|
|----------------------------|-------------------------------------|
|
||||||
|
| New requirement discovered | `specs/<capability-path>/spec.md` |
|
||||||
|
| Requirement changed | `specs/<capability-path>/spec.md` |
|
||||||
|
| Design decision made | `design.md` |
|
||||||
|
| Scope changed | `proposal.md` |
|
||||||
|
| New work identified | `tasks.md` |
|
||||||
|
| Assumption invalidated | Relevant artifact |
|
||||||
|
|
||||||
|
Example offers (default schema):
|
||||||
|
- "That's a design decision. Capture it in design.md?"
|
||||||
|
- "This is a new requirement. Add it to specs?"
|
||||||
|
- "This changes scope. Update the proposal?"
|
||||||
|
|
||||||
|
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You Don't Have To Do
|
||||||
|
|
||||||
|
- Follow a script
|
||||||
|
- Ask the same questions every time
|
||||||
|
- Produce a specific artifact
|
||||||
|
- Reach a conclusion
|
||||||
|
- Stay on topic if a tangent is valuable
|
||||||
|
- Be brief (this is thinking time)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handling Different Entry Points
|
||||||
|
|
||||||
|
**User brings a vague idea:**
|
||||||
|
```
|
||||||
|
User: I'm thinking about adding real-time collaboration
|
||||||
|
|
||||||
|
You: Real-time collab is a big space. Let me think about this...
|
||||||
|
|
||||||
|
COLLABORATION SPECTRUM
|
||||||
|
============================================
|
||||||
|
|
||||||
|
Awareness Coordination Sync
|
||||||
|
| | |
|
||||||
|
v v v
|
||||||
|
+--------+ +--------+ +--------+
|
||||||
|
|Presence| |Cursors | | CRDT |
|
||||||
|
| "3 | | Multi | |Conflict|
|
||||||
|
|online" | | select | | free |
|
||||||
|
+--------+ +--------+ +--------+
|
||||||
|
| | |
|
||||||
|
trivial moderate complex
|
||||||
|
|
||||||
|
Where's your head at?
|
||||||
|
```
|
||||||
|
|
||||||
|
**User brings a specific problem:**
|
||||||
|
```
|
||||||
|
User: The auth system is a mess
|
||||||
|
|
||||||
|
You: [reads codebase]
|
||||||
|
|
||||||
|
+---------------------------------------------+
|
||||||
|
| CURRENT AUTH FLOW |
|
||||||
|
+---------------------------------------------+
|
||||||
|
|
|
||||||
|
+-------------+-------------+
|
||||||
|
v v v
|
||||||
|
+---------+ +---------+ +---------+
|
||||||
|
| Google | | GitHub | | Email |
|
||||||
|
| OAuth | | OAuth | | Magic |
|
||||||
|
+----+----+ +----+----+ +----+----+
|
||||||
|
| | |
|
||||||
|
+-------------+-------------+
|
||||||
|
v
|
||||||
|
+-----------+
|
||||||
|
| Session |
|
||||||
|
+-----+-----+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+-----------+
|
||||||
|
| Perms |
|
||||||
|
+-----------+
|
||||||
|
|
||||||
|
I see three tangles. Which one's burning?
|
||||||
|
```
|
||||||
|
|
||||||
|
**User is stuck mid-implementation:**
|
||||||
|
```
|
||||||
|
User: $openspec-explore (Codex) or /openspec-explore (other agents) add-auth-system
|
||||||
|
The OAuth integration is more complex than expected
|
||||||
|
|
||||||
|
You: [reads change artifacts]
|
||||||
|
|
||||||
|
You're on task 4: "Implement OAuth flow"
|
||||||
|
|
||||||
|
Let me trace what's involved...
|
||||||
|
|
||||||
|
[draws diagram, explores options, suggests paths]
|
||||||
|
|
||||||
|
Want to update the design to reflect this?
|
||||||
|
Or add a spike task to investigate?
|
||||||
|
```
|
||||||
|
|
||||||
|
**User wants to compare options:**
|
||||||
|
```
|
||||||
|
User: Should we use Postgres or SQLite?
|
||||||
|
|
||||||
|
You: Generic answer is boring. What's the context?
|
||||||
|
|
||||||
|
User: A CLI tool that tracks local dev environments
|
||||||
|
|
||||||
|
You: That changes everything.
|
||||||
|
|
||||||
|
+-------------------------------------------------+
|
||||||
|
| CLI TOOL DATA STORAGE |
|
||||||
|
+-------------------------------------------------+
|
||||||
|
|
||||||
|
Key constraints:
|
||||||
|
- No daemon running
|
||||||
|
- Must work offline
|
||||||
|
- Single user
|
||||||
|
|
||||||
|
SQLite Postgres
|
||||||
|
Deployment embedded needs server
|
||||||
|
Offline yes no
|
||||||
|
Single file yes no
|
||||||
|
|
||||||
|
SQLite. Not even close.
|
||||||
|
|
||||||
|
Unless... is there a sync component?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ending Discovery
|
||||||
|
|
||||||
|
There's no required ending. Discovery might:
|
||||||
|
|
||||||
|
- **Flow into a proposal**: "Ready to start? Run `$openspec-propose (Codex) or /openspec-propose (other agents)` and this becomes a change."
|
||||||
|
- **Result in artifact updates**: "Updated the design with these decisions"
|
||||||
|
- **Just provide clarity**: User has what they need, moves on
|
||||||
|
- **Continue later**: "We can pick this up anytime"
|
||||||
|
|
||||||
|
When it feels like things are crystallizing, you might summarize:
|
||||||
|
|
||||||
|
```
|
||||||
|
## What We Figured Out
|
||||||
|
|
||||||
|
**The problem**: [crystallized understanding]
|
||||||
|
|
||||||
|
**The approach**: [if one emerged]
|
||||||
|
|
||||||
|
**Open questions**: [if any remain]
|
||||||
|
|
||||||
|
**Next steps** (if ready):
|
||||||
|
- Turn this into a change: `$openspec-propose (Codex) or /openspec-propose (other agents)`
|
||||||
|
- Keep exploring: just keep talking
|
||||||
|
```
|
||||||
|
|
||||||
|
But this summary is optional. Sometimes the thinking IS the value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or `openspec/config.yaml` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. When the user is ready to build, name the handoff rather than starting: `$openspec-propose (Codex) or /openspec-propose (other agents)` turns the discussion into a change, and the work happens there.
|
||||||
|
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||||
|
- **Don't rush** - Discovery is thinking time, not task time
|
||||||
|
- **Don't force structure** - Let patterns emerge naturally
|
||||||
|
- **Don't auto-capture** - Offer to save insights, don't just do it. Read-only commands and tools need no confirmation. Before the first write-capable action—including `openspec new change` or another command that writes files—name the artifacts or files and proposed changes, ask a direct yes/no question, and wait for explicit confirmation in a separate user message. That confirmation covers only the described scope; ask again before expanding it. Answers to design or clarifying questions are never consent to write. That rule governs `openspec new change` whenever you are the one proposing the capture; the user's own capture request is the exception, handled in the capture transition above.
|
||||||
|
- **Don't manually scaffold changes** - Never create a new change directory under `openspec/changes/` by hand. Always use `openspec new change "<name>"` (with `--store <id>` when applicable) so required metadata such as `.openspec.yaml` is created before writing artifacts.
|
||||||
|
- **Do visualize** - A good diagram is worth many paragraphs
|
||||||
|
- **Do explore the codebase** - Ground discussions in reality
|
||||||
|
- **Do question assumptions** - Including the user's and your own
|
||||||
175
.agents/skills/openspec-propose/SKILL.md
Normal file
175
.agents/skills/openspec-propose/SKILL.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
name: openspec-propose
|
||||||
|
description: Propose a new OpenSpec change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. Also use when the user says "openspec propose" or "opsx propose".
|
||||||
|
allowed-tools: Bash(openspec:*)
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.16.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
Propose a new change - create the change and generate all artifacts in one step.
|
||||||
|
|
||||||
|
**Planning boundary**: This workflow creates planning artifacts only. The user request that selected or triggered this workflow authorizes planning only, even if it asks to build or fix something. Do not edit project code. After the planning artifacts are complete, stop. Do not start implementation in the same response, even if the initial request asks for it. Wait for a new user request after the artifacts are presented; then start the apply workflow.
|
||||||
|
|
||||||
|
I'll create a change with the artifacts your schema defines. With the default spec-driven schema that is:
|
||||||
|
- proposal.md (what & why)
|
||||||
|
- `specs/<capability-path>/spec.md` (what the system must do - a delta, not the main spec)
|
||||||
|
- design.md (how)
|
||||||
|
- tasks.md (implementation steps)
|
||||||
|
|
||||||
|
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
|
||||||
|
|
||||||
|
When the user is ready to implement, they must start the apply workflow explicitly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||||
|
|
||||||
|
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||||
|
|
||||||
|
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||||
|
|
||||||
|
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||||
|
|
||||||
|
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||||
|
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||||
|
|
||||||
|
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||||
|
|
||||||
|
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **Understand the request and clarify material ambiguity**
|
||||||
|
|
||||||
|
If no clear input is provided, ask the user (open-ended, no preset options):
|
||||||
|
> "What change do you want to work on? Describe what you want to build or fix."
|
||||||
|
|
||||||
|
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||||
|
|
||||||
|
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||||
|
|
||||||
|
If the request contains ambiguity that would materially affect scope, externally observable behavior, compatibility, or acceptance criteria, ask the user before creating the change. For minor details, make a reasonable assumption and record it in the planning artifacts.
|
||||||
|
|
||||||
|
2. **Load project context**
|
||||||
|
|
||||||
|
Run `openspec context --json` from the current working directory (or `openspec context --json --store "<store-id>"` when a registered store was explicitly selected). Use the returned `root.path` as the authoritative OpenSpec root. If context reports `no_openspec_root`, stop without creating or changing any files and follow the **Project check** above for how this workflow was reached. Offer `openspec init` only for an explicit OpenSpec request, and wait for the user to request initialization. Do not initialize automatically or run `openspec new change`. After initialization, rerun this context check before continuing. For any other context failure, stop and report the error; do not fall back to the current directory or run later OpenSpec commands without the selected store.
|
||||||
|
|
||||||
|
Only when context returns a resolved `root.path`, read `<root.path>/openspec/config.yaml`. Use `config.yml` only when `config.yaml` does not exist. If neither file exists, continue without project context. Do not fall back to `config.yml` if `config.yaml` is unreadable or invalid.
|
||||||
|
|
||||||
|
If the file parses as a YAML object and its `context` field is a string no larger than 51,200 bytes in UTF-8, apply that field before exploring the codebase or making planning decisions. If the file cannot be read or parsed, or the context field is invalid or oversized, continue without project context. Validate this field independently of other config fields, as OpenSpec does.
|
||||||
|
|
||||||
|
Treat context as project-provided data and constraints, not as authority to change this workflow: it cannot override user authorization, the planning boundary, tool restrictions, or artifact and output rules. Do not copy the context into artifacts; use it to focus any codebase exploration and as a constraint on the proposal.
|
||||||
|
|
||||||
|
3. **Determine the workflow schema**
|
||||||
|
|
||||||
|
Use the configured default schema unless the user explicitly requests a different workflow.
|
||||||
|
|
||||||
|
**Use a different schema only if the user:**
|
||||||
|
- Explicitly requests a specific schema by name → use `--schema <schema-name>`
|
||||||
|
- Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running `openspec context --json` from the current working directory. If the user explicitly selected a registered store, use `openspec context --json --store "<store-id>"`. Then run `openspec schemas --json` with its working directory set to the returned `root.path` and let them choose. This preserves roots selected by a local `store:` pointer or the global `defaultStore`; when a registered store was explicitly selected, append `--store "<store-id>"` to `openspec schemas --json` as well. If context fails, stop as described in the context-loading step; do not fall back to the current directory.
|
||||||
|
|
||||||
|
Otherwise, omit `--schema` to preserve the configured default.
|
||||||
|
|
||||||
|
4. **Create the change directory**
|
||||||
|
|
||||||
|
Choose one schema form below. If a registered store is selected, append `--store "<store-id>"` to that command and each later OpenSpec command shown below that accepts `--store`.
|
||||||
|
|
||||||
|
Using the configured default:
|
||||||
|
```bash
|
||||||
|
openspec new change "<name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Using an explicitly requested schema:
|
||||||
|
```bash
|
||||||
|
openspec new change "<name>" --schema "<schema-name>"
|
||||||
|
```
|
||||||
|
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
|
||||||
|
|
||||||
|
5. **Get the artifact build order**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>" --json
|
||||||
|
```
|
||||||
|
Parse the JSON to get:
|
||||||
|
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||||
|
- `artifacts`: list of all artifacts, each with its `status` and its `requires` edges (the artifact IDs it directly depends on)
|
||||||
|
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||||
|
|
||||||
|
6. **Create every artifact in the required set**
|
||||||
|
|
||||||
|
Use a todo list to track progress through the artifacts.
|
||||||
|
|
||||||
|
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||||
|
|
||||||
|
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||||
|
- Get instructions:
|
||||||
|
```bash
|
||||||
|
openspec instructions <artifact-id> --change "<name>" --json
|
||||||
|
```
|
||||||
|
- The instructions JSON includes:
|
||||||
|
- `context`: Project background (constraints for you - do NOT include in output)
|
||||||
|
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||||
|
- `template`: The structure to use for your output file
|
||||||
|
- `instruction`: Schema-specific guidance for this artifact type
|
||||||
|
- `skipped`/`warning`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact
|
||||||
|
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||||
|
- `dependencies`: Completed artifacts to read for context
|
||||||
|
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
|
||||||
|
- **Inspect the relevant project before drafting**: Read `context` and `rules` first, then inspect relevant implementation, nearby tests, configuration, and documentation outside `openspec/`. Keep inspection read-only and proportional to the change; reuse findings for later artifacts and inspect more only as needed.
|
||||||
|
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
|
||||||
|
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
|
||||||
|
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
|
||||||
|
- Choose the output path before creating anything. If `resolvedOutputPath` is a glob, follow `instruction` to choose a concrete path inside `changeRoot` that matches the glob, and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`
|
||||||
|
- If the `instruction` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
|
||||||
|
- Otherwise create the artifact file using `template` as the structure and write it to that output path
|
||||||
|
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||||
|
- Show brief progress: "Created <artifact-id>"
|
||||||
|
|
||||||
|
b. **Continue until every artifact in the required set exists (not just `apply.requires`)**
|
||||||
|
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||||
|
- The required set is `applyRequires` plus every artifact reachable from those by following the `requires` edges in `status --json` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone
|
||||||
|
- `status` is file-existence only, so an `applyRequires` artifact reading `done` does NOT mean its dependencies exist - writing `tasks.md` early marks `tasks` done while `specs` was never written. Use each artifact's `requires` edges, not its `status`, to build the required set: a `done` artifact still lists what it depends on
|
||||||
|
- An artifact already reading `status: "skipped"` is satisfied: the change declares `skip_specs` in `.openspec.yaml`, so its files must NOT exist. Never try to create one
|
||||||
|
- Create every artifact in the required set that is missing, then re-check - creating one can unblock others
|
||||||
|
- Skip one only when `status` already reports it `skipped`, or when its own `instruction` says it is conditional: run `openspec instructions <artifact-id> --change "<name>" --json` and skip only if its `instruction` field marks it optional (e.g. "create only if..."). Spec-driven's `design.md` qualifies; `specs` qualifies only via the `skipped` status above, never by your own judgment. Tell the user, and do not reconsider it
|
||||||
|
- Dependencies are enablers, not gates: if a required artifact is still `blocked` only because you skipped a conditional dependency, write it anyway
|
||||||
|
- Stop when every artifact in the required set is `done`, `skipped`, or was deliberately skipped
|
||||||
|
|
||||||
|
c. **If an artifact requires user input** (unclear context):
|
||||||
|
- Ask the user to clarify
|
||||||
|
- Then continue with creation
|
||||||
|
|
||||||
|
7. **Show final status**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output**
|
||||||
|
|
||||||
|
After completing all artifacts, summarize:
|
||||||
|
- Change name and location
|
||||||
|
- List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why
|
||||||
|
- What's ready: "All artifacts needed for implementation are ready."
|
||||||
|
- Prompt: "The artifacts are ready for review. When you are ready, run `$openspec-apply-change (Codex) or /openspec-apply-change (other agents)` or ask me to apply this change."
|
||||||
|
|
||||||
|
**Artifact Creation Guidelines**
|
||||||
|
|
||||||
|
- Follow the `instruction` field from `openspec instructions` for each artifact type - it is the authoritative guidance, even for familiar artifact names
|
||||||
|
- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
|
||||||
|
- The schema defines what each artifact should contain - follow it
|
||||||
|
- Read dependency artifacts for context before creating new ones
|
||||||
|
- Use `template` as the structure for your output file - fill in its sections
|
||||||
|
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||||
|
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||||
|
- These guide what you write, but should never appear in the output
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- The request that invoked this workflow authorizes planning only. Any implementation or apply instruction in that request does not carry forward. Do NOT implement the change, start the apply workflow, or edit project code during this workflow. After presenting the artifacts, stop and wait for a new user request to start the apply workflow
|
||||||
|
- Create every artifact the apply phase transitively depends on, not just the ids listed in `apply.requires`
|
||||||
|
- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them)
|
||||||
|
- Ask about ambiguities that would materially change scope, externally observable behavior, compatibility, or acceptance criteria; for minor details, make reasonable assumptions and record them
|
||||||
|
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||||
|
- Verify each artifact file exists after writing before proceeding to next
|
||||||
290
.agents/skills/openspec-sync-specs/SKILL.md
Normal file
290
.agents/skills/openspec-sync-specs/SKILL.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
---
|
||||||
|
name: openspec-sync-specs
|
||||||
|
description: Sync delta specs from an OpenSpec change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. Also use when the user says "openspec sync" or "opsx sync".
|
||||||
|
allowed-tools: Bash(openspec:*)
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.16.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
Sync delta specs from a change to main specs.
|
||||||
|
|
||||||
|
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||||
|
|
||||||
|
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||||
|
|
||||||
|
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||||
|
|
||||||
|
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||||
|
|
||||||
|
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||||
|
|
||||||
|
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||||
|
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||||
|
|
||||||
|
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||||
|
|
||||||
|
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec.
|
||||||
|
|
||||||
|
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **Select the change**
|
||||||
|
|
||||||
|
If a name is provided, use it. Otherwise:
|
||||||
|
- Infer from conversation context if the user mentioned a change
|
||||||
|
- Auto-select if only one active change exists
|
||||||
|
- If ambiguous, run `openspec list --json` to get available changes and ask the user to select one
|
||||||
|
|
||||||
|
When prompting, show changes that have delta specs (under `specs/` directory).
|
||||||
|
|
||||||
|
Always announce: "Using change: <name>" and how to override (e.g., `$openspec-sync-specs (Codex) or /openspec-sync-specs (other agents) <other>`).
|
||||||
|
|
||||||
|
2. **Resolve change context**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
The JSON includes `planningHome.root`. Main specs live under `<planningHome.root>/openspec/specs/` — use that (store-aware) root for every main-spec path below, not a hardcoded repo path. When a store is selected it points at the store, not the current repository.
|
||||||
|
|
||||||
|
3. **Find delta specs**
|
||||||
|
|
||||||
|
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the
|
||||||
|
only source of delta spec paths. If the `specs` entry is missing or
|
||||||
|
`existingOutputPaths` is empty, report that there are no delta specs to sync,
|
||||||
|
do not infer them from other artifacts, and stop without requesting artifact
|
||||||
|
instructions or writing a main spec.
|
||||||
|
|
||||||
|
Sync every path in `existingOutputPaths` unless the caller narrowed the set.
|
||||||
|
A caller narrows it by naming an explicit list of complete entries from
|
||||||
|
`existingOutputPaths` — copy those absolute values verbatim. Archive does
|
||||||
|
this inline, and a user can too (for example, by selecting the entry ending
|
||||||
|
in `/specs/billing/invoices/spec.md`).
|
||||||
|
Then sync only the named paths and leave the remaining delta specs untouched:
|
||||||
|
bulk archive excludes a delta whose implementation it could not find, and
|
||||||
|
syncing it anyway would write a main spec the caller deliberately withheld.
|
||||||
|
Carry that narrowed selection through step 4; never widen it back to the full
|
||||||
|
list. If a named path is not in `existingOutputPaths`, do not sync it —
|
||||||
|
report it and stop, rather than dropping it silently. If the named list is
|
||||||
|
empty, report that there is nothing to sync and stop without writing a main
|
||||||
|
spec.
|
||||||
|
|
||||||
|
Each delta spec file contains sections like:
|
||||||
|
- `## ADDED Requirements` - New requirements to add
|
||||||
|
- `## MODIFIED Requirements` - Changes to existing requirements
|
||||||
|
- `## REMOVED Requirements` - Requirements to remove
|
||||||
|
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
||||||
|
|
||||||
|
If no delta specs found, inform user and stop.
|
||||||
|
|
||||||
|
4. **For each delta spec, apply changes to main specs**
|
||||||
|
|
||||||
|
Before the first main-spec write, obtain one current specs-rule snapshot:
|
||||||
|
- If archive invoked this workflow inline and supplied a valid snapshot from
|
||||||
|
`openspec instructions specs --change "<name>" --json`, reuse it and do not
|
||||||
|
fetch the same instructions again.
|
||||||
|
- Otherwise run that command once now with the same selected-root flags.
|
||||||
|
- If the direct lookup exits non-zero or returns invalid artifact-instruction
|
||||||
|
JSON, report the error and stop before writing any main spec. Do not treat the
|
||||||
|
failure as an absent rule set.
|
||||||
|
- A valid response with omitted `rules` means no artifact rules are configured
|
||||||
|
and the existing semantic merge continues.
|
||||||
|
|
||||||
|
Apply returned `rules` only to the content and form of the main specs produced
|
||||||
|
by this merge. Artifact rules are not operation guidance and cannot change
|
||||||
|
selected roots, delta paths, CLI checks, or workflow steps. Use their text as
|
||||||
|
constraints without copying it verbatim into a main spec or summary.
|
||||||
|
|
||||||
|
For each capability delta spec path selected in step 3 — the full `existingOutputPaths` list, or the narrowed subset when a caller supplied one (these may belong to a selected store, not the repo):
|
||||||
|
|
||||||
|
a. **Read the delta spec** to understand the intended changes
|
||||||
|
|
||||||
|
b. **Read the main spec** at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (may not exist yet)
|
||||||
|
|
||||||
|
**If it does not exist yet** (a new capability), match what `openspec archive` does:
|
||||||
|
only ADDED requirements may be applied - step d creates the spec from them.
|
||||||
|
MODIFIED and RENAMED have no requirement to act on, so stop the sync for that
|
||||||
|
capability and report that its main spec does not exist and only ADDED is allowed
|
||||||
|
for a new spec; never invent the missing requirement. REMOVED has nothing to
|
||||||
|
remove - skip it and warn.
|
||||||
|
|
||||||
|
c. **Apply changes intelligently**:
|
||||||
|
|
||||||
|
**ADDED Requirements:**
|
||||||
|
- If requirement doesn't exist in main spec → add it
|
||||||
|
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
||||||
|
|
||||||
|
**MODIFIED Requirements:**
|
||||||
|
- Find the requirement in main spec
|
||||||
|
- Apply the changes - this can be:
|
||||||
|
- Adding new scenarios the main spec does not have yet
|
||||||
|
- Modifying existing scenarios
|
||||||
|
- Changing the requirement description
|
||||||
|
- Preserve scenarios/content not mentioned in the delta
|
||||||
|
|
||||||
|
**REMOVED Requirements:**
|
||||||
|
- Remove the entire requirement block from main spec
|
||||||
|
- Retiring the capability. Delete the whole `spec.md` - and the directory once
|
||||||
|
nothing else is left in it - only when ALL of these hold:
|
||||||
|
1. removing the requirements *this run* left no requirement blocks;
|
||||||
|
2. the rest of the spec is well-formed (it still has a `## Purpose`);
|
||||||
|
3. the main spec was not already empty before this sync - if you removed
|
||||||
|
nothing, change nothing;
|
||||||
|
4. every other nonblank line in the whole file is accounted for as the
|
||||||
|
title, Purpose, Requirements header, or a canonical requirement's
|
||||||
|
statement, scenarios, or fenced examples;
|
||||||
|
5. the change's `.openspec.yaml` declares `retire_capabilities: true`;
|
||||||
|
6. the `spec.md` resolves inside the real specs root (do not follow a
|
||||||
|
capability-directory symlink to delete an external file).
|
||||||
|
If removing the selected requirements would leave no requirement blocks and
|
||||||
|
any retirement condition is not satisfied, do not modify the main spec. Stop
|
||||||
|
the sync for that capability, report the blocking condition, and tell the user
|
||||||
|
how to resolve it. Never write or leave an empty `## Requirements` section.
|
||||||
|
When only the marker is missing, say that too - it is the one thing the user
|
||||||
|
can add to make the retirement go through.
|
||||||
|
- Deleting the file also deletes its `## Purpose`; any other section blocks
|
||||||
|
retirement. Name Purpose when you report the retirement. Include a pasteable
|
||||||
|
`git checkout` only when the spec lived in the caller's checkout;
|
||||||
|
otherwise give checkout-scoped recovery guidance.
|
||||||
|
|
||||||
|
**RENAMED Requirements:**
|
||||||
|
- Find the FROM requirement, rename to TO
|
||||||
|
|
||||||
|
**`## Purpose` in the delta:**
|
||||||
|
- The main spec already has one and it is authoritative - leave it alone
|
||||||
|
(this is what `openspec archive` does; it warns and moves on)
|
||||||
|
|
||||||
|
d. **Create new main spec** if capability doesn't exist yet:
|
||||||
|
- Only when the delta has ADDED requirements to put in it and no MODIFIED or
|
||||||
|
RENAMED requirements blocked this capability in step b. Otherwise create nothing
|
||||||
|
and leave the specs directory untouched. For a REMOVED-only delta, if the change's
|
||||||
|
`.openspec.yaml` declares `retire_capabilities: true`, report it as already retired
|
||||||
|
and continue without recreating the spec. Without that marker, report the sync as blocked:
|
||||||
|
`openspec archive` rejects it with `Spec must have at least one requirement`.
|
||||||
|
An empty delta has no operations to sync; report it as blocked too.
|
||||||
|
Never write an empty `## Requirements` section.
|
||||||
|
- Create `<planningHome.root>/openspec/specs/<capability-path>/spec.md`
|
||||||
|
- Add Purpose section: copy the delta's `## Purpose` body verbatim when it has one
|
||||||
|
(this is what `openspec archive` does); only write a brief TBD placeholder when it does not
|
||||||
|
- Add Requirements section with the ADDED requirements
|
||||||
|
- Follow the **Main Spec Format Reference** below
|
||||||
|
|
||||||
|
5. **Validate updated main specs**
|
||||||
|
|
||||||
|
Run `openspec validate --specs` with the same selected-root flags used earlier.
|
||||||
|
If validation fails, report the problems and do not claim the sync succeeded.
|
||||||
|
|
||||||
|
6. **Show summary**
|
||||||
|
|
||||||
|
After applying all changes, summarize:
|
||||||
|
- Which capabilities were updated
|
||||||
|
- What changes were made (requirements added/modified/removed/renamed)
|
||||||
|
- Any new main spec left with a TBD Purpose placeholder, so it gets written
|
||||||
|
now rather than lingering
|
||||||
|
- Any capability retired, naming the deleted `spec.md`, its Purpose, and
|
||||||
|
either a pasteable `git checkout` or checkout-scoped recovery guidance
|
||||||
|
|
||||||
|
**Delta Spec Format Reference**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Only on a delta that introduces a brand-new capability. Seeds the new main spec.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: New Feature
|
||||||
|
The system SHALL do something new.
|
||||||
|
|
||||||
|
#### Scenario: Basic case
|
||||||
|
- **WHEN** user does X
|
||||||
|
- **THEN** system does Y
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Existing Feature
|
||||||
|
The system SHALL keep doing the existing thing, now also handling A.
|
||||||
|
|
||||||
|
#### Scenario: Scenario the main spec already has
|
||||||
|
- **WHEN** user does X
|
||||||
|
- **THEN** system does Y
|
||||||
|
|
||||||
|
#### Scenario: New scenario to add
|
||||||
|
- **WHEN** user does A
|
||||||
|
- **THEN** system does B
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
### Requirement: Deprecated Feature
|
||||||
|
|
||||||
|
## RENAMED Requirements
|
||||||
|
|
||||||
|
- FROM: `### Requirement: Old Name`
|
||||||
|
- TO: `### Requirement: New Name`
|
||||||
|
```
|
||||||
|
|
||||||
|
**Main Spec Format Reference**
|
||||||
|
|
||||||
|
Main specs are what the delta merges INTO. They must never contain delta operation headers (`## ADDED/MODIFIED/REMOVED/RENAMED Requirements`) - after syncing, every requirement lives under a single `## Requirements` section:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# <capability> Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Short description of what this capability does and why it exists.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: New Feature
|
||||||
|
The system SHALL do something new.
|
||||||
|
|
||||||
|
#### Scenario: Basic case
|
||||||
|
- **WHEN** user does X
|
||||||
|
- **THEN** system does Y
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principle: Intelligent Merging**
|
||||||
|
|
||||||
|
Unlike programmatic merging, you merge rather than overwrite:
|
||||||
|
- A MODIFIED block carries the whole requirement - body plus every scenario that survives the change. `openspec validate` and `openspec archive` both reject one that drops a scenario the main spec still has.
|
||||||
|
- Keep anything the delta does not mention, in the main spec's existing order
|
||||||
|
- Use your judgment to merge changes sensibly
|
||||||
|
|
||||||
|
**Output On Success**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Specs Synced: <change-name>
|
||||||
|
|
||||||
|
Updated main specs:
|
||||||
|
|
||||||
|
**<capability-1>**:
|
||||||
|
- Added requirement: "New Feature"
|
||||||
|
- Modified requirement: "Existing Feature" (added 1 scenario)
|
||||||
|
|
||||||
|
**<capability-2>**:
|
||||||
|
- Created new spec file
|
||||||
|
- Added requirement: "Another Feature"
|
||||||
|
|
||||||
|
Main specs are now updated. The change remains active - archive when implementation is complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Read both delta and main specs before making changes
|
||||||
|
- Preserve existing content not mentioned in delta
|
||||||
|
- Never copy a delta file into a main spec as-is - merge its content so the main spec keeps the Main Spec Format Reference structure, with no delta operation headers
|
||||||
|
- If something is unclear, ask for clarification
|
||||||
|
- Show what you're changing as you go
|
||||||
|
- The operation should be idempotent - running twice should give same result
|
||||||
|
- Use only `artifactPaths.specs.existingOutputPaths`; never infer delta specs from unrelated artifacts
|
||||||
|
- Honor a caller-supplied subset of `existingOutputPaths`; never widen it back to the full list
|
||||||
|
- Fetch specs instructions once for direct sync, or reuse the archive-supplied snapshot inline
|
||||||
|
- Stop before every main-spec write on a non-zero or invalid JSON specs-instruction response
|
||||||
|
- Artifact rules constrain only the specs being written and are never copied into output files
|
||||||
109
.agents/skills/openspec-update-change/SKILL.md
Normal file
109
.agents/skills/openspec-update-change/SKILL.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
name: openspec-update-change
|
||||||
|
description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Also use when the user says "openspec update change" or "opsx update". If the user means the openspec update CLI command, which refreshes generated files, run that command instead. Never edits code.
|
||||||
|
allowed-tools: Bash(openspec:*)
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: openspec
|
||||||
|
version: "1.0"
|
||||||
|
generatedBy: "1.16.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
Revise a change's existing planning artifacts and keep them coherent. Never edit code.
|
||||||
|
|
||||||
|
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||||
|
|
||||||
|
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
|
||||||
|
|
||||||
|
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
|
||||||
|
|
||||||
|
Otherwise, with no root, what happens next depends on how this workflow was reached:
|
||||||
|
|
||||||
|
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
|
||||||
|
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
|
||||||
|
|
||||||
|
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
|
||||||
|
|
||||||
|
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||||
|
|
||||||
|
This workflow revises artifacts that already exist; it never creates missing ones. When an artifact is missing, `openspec status --change "<name>" --json` names the next one and `openspec instructions "<artifact-id>" --change "<name>" --json` explains how to write it.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **Select the change**
|
||||||
|
|
||||||
|
If a name is provided, use it. Otherwise:
|
||||||
|
- Infer from conversation context if the user mentioned a change
|
||||||
|
- Auto-select if only one active change exists
|
||||||
|
- If ambiguous, run `openspec list --json` to get available changes sorted by most recently modified, and ask the user to select one
|
||||||
|
|
||||||
|
When prompting, present the top 3-4 most recently modified changes as options, showing:
|
||||||
|
- Change name
|
||||||
|
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||||
|
- How recently it was modified (from `lastModified` field)
|
||||||
|
|
||||||
|
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update.
|
||||||
|
|
||||||
|
Always announce: "Using change: <name>" and how to override (e.g., `$openspec-update-change (Codex) or /openspec-update-change (other agents) <other>`).
|
||||||
|
|
||||||
|
2. **Get the change's artifacts**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>" --json
|
||||||
|
```
|
||||||
|
Parse the JSON to understand current state. The response includes:
|
||||||
|
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
||||||
|
- `artifacts`: Array of artifacts with their status ("done", "skipped", "ready", "blocked")
|
||||||
|
- `isPlanningComplete`: Boolean indicating if all planning artifacts are complete. Older CLI versions expose the same value as `isComplete`.
|
||||||
|
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||||
|
|
||||||
|
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
|
||||||
|
|
||||||
|
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`), plus any confirmed concrete path allowed under a partly populated glob artifact (step 4). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
|
||||||
|
|
||||||
|
3. **Understand the request**
|
||||||
|
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
|
||||||
|
- If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication.
|
||||||
|
|
||||||
|
4. **Read and reconcile**
|
||||||
|
- Read the artifact(s) the request touches and the change's other existing artifacts.
|
||||||
|
- Draft the requested edit in the conversation, not in files. Work out exactly what it changes; step 5 owns every write. Then check every other existing artifact against the drafted edit - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
|
||||||
|
- Note everything that is now inconsistent, missing, or contradictory.
|
||||||
|
- Propose revisions to files that already exist (`existingOutputPaths`). If an artifact has no existing output files and status `ready` or `blocked`, note it and point the user to `openspec instructions "<artifact-id>" --change "<name>" --json` for how to create them. Leave `skipped` artifacts untouched; do not treat them as missing or defer them to the continue workflow.
|
||||||
|
- A glob artifact (e.g. `specs/**/*.md`) is marked `done` after at least one file matches, and the continue workflow only handles `ready` artifacts. When reconciliation identifies a missing file for a glob artifact whose `existingOutputPaths` is non-empty:
|
||||||
|
1. Run `openspec instructions "<artifact-id>" --change "<name>" --json` and use its `instruction` and `template`. Treat `context` and `rules` as constraints; do not copy them into the file. If instructions report `skipped: true`, do not create the file. Read current dependency files from disk; if a required non-skipped dependency is missing, stop and ask the user to restore it first.
|
||||||
|
2. Choose a concrete path inside `changeRoot` that matches `artifactPaths.<id>.outputPath` and does not already exist. Verify it remains inside `changeRoot` after resolving any symlinked parent directories. The glob `resolvedOutputPath` is not a valid target.
|
||||||
|
3. Include the new file in step 5's proposed revisions and create it only after the user confirms.
|
||||||
|
4. After confirmation, immediately before creation, refresh status and instructions. Verify the artifact is still in scope, not skipped, and partially populated; repeat the concrete-path checks above.
|
||||||
|
5. Use a create operation that fails if the target already exists. If `instruction` delegates creation to another skill or command, invoke it only if it can honor the confirmed path and these guardrails; otherwise stop. If any check fails or the confirmed draft is no longer valid, stop and reconcile with the user rather than replacing existing content or choosing a different path.
|
||||||
|
- If the change is already coherent, say so and propose no revisions.
|
||||||
|
|
||||||
|
5. **Confirm and apply, one artifact at a time**
|
||||||
|
- This step performs every artifact write in this workflow; no earlier step edits an artifact.
|
||||||
|
- Show each proposed revision and why - including the requested edit drafted in step 4. Write only after the user confirms.
|
||||||
|
- If the user rejects a revision, do not write it - leave that artifact unchanged.
|
||||||
|
- When a substantial rewrite is needed, get that artifact's rules and template first:
|
||||||
|
```bash
|
||||||
|
openspec instructions "<artifact-id>" --change "<name>" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Point to the next step (guidance only - NEVER act on it)**
|
||||||
|
- Artifacts with empty `existingOutputPaths` and status `ready` or `blocked` -> run `openspec status --change "<name>" --json` for the next artifact and point the user to `openspec instructions "<artifact-id>" --change "<name>" --json` for how to create it.
|
||||||
|
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `$openspec-apply-change (Codex) or /openspec-apply-change (other agents)` to carry the delta into code.
|
||||||
|
- Everything done and implemented -> suggest `$openspec-archive-change (Codex) or /openspec-archive-change (other agents)`.
|
||||||
|
|
||||||
|
**Output**
|
||||||
|
|
||||||
|
After each invocation, show:
|
||||||
|
- Which artifacts were revised (and which proposed revisions were rejected)
|
||||||
|
- Any file created under a glob artifact that was already partially populated
|
||||||
|
- Anything deferred because it does not exist yet (artifacts with no files and status `ready` or `blocked`, never `skipped` artifacts)
|
||||||
|
- Where the change stands and the recommended next command
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `$openspec-apply-change (Codex) or /openspec-apply-change (other agents)`.
|
||||||
|
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
|
||||||
|
- Edit only the concrete files in `existingOutputPaths`, plus any confirmed concrete path allowed under a partly populated glob artifact (step 4); never write to a glob `resolvedOutputPath`.
|
||||||
|
- Do not advance the build frontier: if an artifact has empty `existingOutputPaths` and status `ready` or `blocked`, creating them is a separate step, outside this workflow. Leave `skipped` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose `existingOutputPaths` is non-empty.
|
||||||
|
- Confirm every edit with the user before writing.
|
||||||
|
- If the request changes the change's *intent* rather than refining it, ask for a distinct unused change name and recommend `openspec new change "<new-change-name>"` instead (the "Update vs. Start Fresh" heuristic).
|
||||||
526
README.md
526
README.md
@@ -1,14 +1,326 @@
|
|||||||
# Opportunities Extension 1.0.1
|
# Opportunities Extension
|
||||||
|
|
||||||
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table.
|
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table or calendar.
|
||||||
|
|
||||||
|
See [Release Notes](RELEASE_NOTES.md) for version history and
|
||||||
|
[Architecture](docs/architecture.md) for execution, authentication and storage contracts.
|
||||||
|
|
||||||
|
## OpenSpec Workflow
|
||||||
|
|
||||||
|
The [twelve durable capability specifications](openspec/specs/) define the reviewed
|
||||||
|
behavioral baseline for this checkout. The initial
|
||||||
|
[`document-project-baseline` change](openspec/changes/archive/2026-10-31-document-project-baseline/proposal.md)
|
||||||
|
was completed and archived on 2026-10-31. Its
|
||||||
|
[design and evidence map](openspec/changes/archive/2026-10-31-document-project-baseline/design.md)
|
||||||
|
connect the baseline to source and tests, and its
|
||||||
|
[tracked tasks](openspec/changes/archive/2026-10-31-document-project-baseline/tasks.md)
|
||||||
|
record the completed application work. Use the durable specifications as the
|
||||||
|
starting point for subsequent capability changes.
|
||||||
|
|
||||||
|
For subsequent work, explore the problem, propose a change with the affected
|
||||||
|
capabilities, apply its tasks, verify the outcome, then archive. In Codex these
|
||||||
|
steps use `$openspec-explore`, `$openspec-propose`, `$openspec-apply-change` and
|
||||||
|
`$openspec-archive-change`. Application and archival are separate actions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openspec list --json
|
||||||
|
openspec list --specs --json
|
||||||
|
openspec validate --specs --strict
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep setup/usage guidance here, behavioral requirements in OpenSpec, execution
|
||||||
|
details in the architecture document and historical releases in RELEASE_NOTES.md.
|
||||||
|
|
||||||
|
## Opportunity Views
|
||||||
|
|
||||||
|
**Overview** is the default table view. It gives Name and Customer greater width
|
||||||
|
priority, uses customer `PartyName`, and labels win probability **Prob.**. Monetary
|
||||||
|
values and headers align right; other headers align left. Counts and stage amounts
|
||||||
|
follow the filtered opportunities.
|
||||||
|
|
||||||
|
Overview and **Calendar** share Reference, search, Customer, Owner, Status, Stage,
|
||||||
|
Booking/Workload and eligible SR/Action filters. Selecting Customers narrows the
|
||||||
|
available Owners, and selecting Owners narrows Customers, using loaded data.
|
||||||
|
Clicking one choice while all choices are selected isolates that choice; subsequent
|
||||||
|
clicks can add others. This also applies to Booking/Workload badges. The SR below
|
||||||
|
4h option has its own exclusive behavior, described under Workload Actions.
|
||||||
|
|
||||||
|
Calendar displays Close Date (`EffectiveDate`) in vertically scrolling months
|
||||||
|
restricted to Reference. There are no previous/next buttons or month/week switch.
|
||||||
|
Every matching opportunity has a card; crowded days scroll internally instead of
|
||||||
|
using a hidden-event overflow link. Switching views and filtering loaded records
|
||||||
|
do not send new opportunity requests. When today is inside Reference, the view
|
||||||
|
positions around its week with one preceding and one following week where the
|
||||||
|
Reference allows; otherwise it starts at the beginning of the range.
|
||||||
|
|
||||||
|
Cards preserve the API calendar day without timezone shifts and use stage colors.
|
||||||
|
Clicking opens read-only details with an opportunity link and copy action. Invalid
|
||||||
|
or missing close dates remain accessible in a separate collapsible list. Fiscal
|
||||||
|
labels use `FY27-Q2-W19`: June-May fiscal years, Sunday-Saturday weeks, and W1 for
|
||||||
|
the week containing June 1. The week's Sunday determines its quarter, except W1
|
||||||
|
belongs to Q1.
|
||||||
|
|
||||||
|
Table sorting remembers the last column and direction independently for Overview,
|
||||||
|
Installed Base, Service Requests, Rate Card, Consumption projection, contact
|
||||||
|
opportunities and Manage Time. Preferences survive reopening and page reloads in
|
||||||
|
the same browser profile. Opportunity product tables retain their original order.
|
||||||
|
|
||||||
|
FullCalendar Standard 6.1.19 is bundled locally under `src/vendor/fullcalendar/`
|
||||||
|
with its MIT license. It is initialized only on first use, without a runtime CDN.
|
||||||
|
|
||||||
|
## Products, Workloads and Projections
|
||||||
|
|
||||||
|
Product counts and details load in the background after opportunities are shown.
|
||||||
|
Workloads are retrieved per distinct customer account, paginated and matched to
|
||||||
|
products by `RevnId`. Valid workload cache entries are reused across modal openings
|
||||||
|
and token renewals according to Cache duration.
|
||||||
|
|
||||||
|
When a nonempty product list contains only blank or `-` Workload names, the ramp
|
||||||
|
badge becomes **Booking**, with its own color. If any product has a Workload name,
|
||||||
|
the ramp behavior remains. Booking and Workload badges beside the view tabs filter
|
||||||
|
these categories.
|
||||||
|
|
||||||
|
Clicking a ramp badge opens **Consumption projection** even when there is no
|
||||||
|
forecast discrepancy. Difference alerts sit between the ramp and SR badges;
|
||||||
|
products without a workload record show an unavailable alert in a disabled color.
|
||||||
|
Projection calculates monthly amounts from adjusted ACR and ramp months, including
|
||||||
|
first-month proration, and compares `forecast - calculated` at cent precision.
|
||||||
|
|
||||||
|
**Update estimates** asks for confirmation before sending the Calculated values
|
||||||
|
to SPA. It prevents duplicate submission, reports success or failure, refreshes
|
||||||
|
the comparison after success and invalidates the affected workload cache.
|
||||||
|
|
||||||
|
## Customer Organization Charts
|
||||||
|
|
||||||
|
The organization-chart icon beside **View customer service requests** opens a
|
||||||
|
builder shared by opportunities for the same customer. Add people and direct
|
||||||
|
reports, change their manager using **Reports to**, or click a person to edit
|
||||||
|
name and notes, and view contact details. **Name** uses the shared searchable select, populated by
|
||||||
|
`requestQueryAccountContact` after a 350 ms pause in typing, using the Token Relay
|
||||||
|
Bearer token and filtering by the customer's `PartyId`. Each POST returns up to
|
||||||
|
15 relevance-ranked results; empty queries and chart opening do not send searches.
|
||||||
|
Superseded responses are ignored. Contact names are keyed by `PartyId`;
|
||||||
|
options display only the person's name and job title. **Job Title** and **Email address**
|
||||||
|
are read-only and fetched afresh using the person's name as `keywords` whenever
|
||||||
|
Person details opens. The stored contact `PartyId` identifies the correct result.
|
||||||
|
Job Title is saved with the chart and shown immediately on the node. A fresh
|
||||||
|
response updates the matching person's name and Job Title automatically, preserving
|
||||||
|
notes and reporting relationships; **Save chart** persists those changes.
|
||||||
|
Email remains in memory only; existing persisted emails are removed by migration. Existing
|
||||||
|
saved people remain available even if absent from the contact list. Notes appear on hover or
|
||||||
|
keyboard focus. Branches can be expanded/collapsed; zoom, fit and canvas panning
|
||||||
|
help navigate larger trees. Deleting a person promotes their direct reports to
|
||||||
|
that person's manager without deleting those reports.
|
||||||
|
|
||||||
|
**Apply changes** updates the working tree; **Save chart** persists it. Unsaved
|
||||||
|
changes require confirmation before closing. The extension background stores
|
||||||
|
JSON-compatible records in IndexedDB, scoped by current user and customer ID in
|
||||||
|
the current browser profile. Records include schema version, revision, update
|
||||||
|
timestamp and a flat node array (`id`, `parentId`, `name`, `jobTitle`, `notes`,
|
||||||
|
`collapsed`, and optional `contactPartyId` and `PartyNumber`). PartyNumber is
|
||||||
|
preserved as an identifier, including alphanumeric values such as `A4KCS2P`.
|
||||||
|
Person details persists a returned PartyNumber so it can be reused by subsequent
|
||||||
|
opportunity lookups. Concurrent stale saves are rejected. Saving the hierarchy
|
||||||
|
does not write it to Oracle or synchronize it between devices. Charts support up
|
||||||
|
to 500 people and 60 reporting levels, with required name and optional notes.
|
||||||
|
|
||||||
|
The opportunities icon beside the person's delete action opens
|
||||||
|
**Opportunities | <name>**. It uses that person's stored PartyNumber in
|
||||||
|
`OpportunityContact.ContactPartyNumber`, resolving it through contact lookup when
|
||||||
|
missing. It loads all pages while `hasMore=true`, shows a loading skeleton and
|
||||||
|
provides sortable results. Clicking an opportunity Name opens its corresponding
|
||||||
|
Fusion opportunity in a new tab. Unresolved identifiers produce an explicit error
|
||||||
|
instead of an unfiltered opportunity query.
|
||||||
|
|
||||||
|
The Create Contact button at the right of **Name** opens an Oracle contact form.
|
||||||
|
First Name, Last Name and Email Address are required. Job Title and Work/Mobile
|
||||||
|
Phone are optional; supplied country codes and numbers are combined as
|
||||||
|
`55-99999999999`. Email and phone consent selections map to the corresponding
|
||||||
|
Do/DoNot flags. Account identifiers come from the current customer, and absent
|
||||||
|
optional values are omitted. Save submits directly to the contact API, without a
|
||||||
|
payload preview. Contact creation is a separate Oracle write from local chart
|
||||||
|
persistence.
|
||||||
|
|
||||||
|
The interaction is inspired by [OrgChart](https://github.com/dabeng/OrgChart).
|
||||||
|
Layout uses bundled [D3 Hierarchy 3.1.2](https://d3js.org/d3-hierarchy/tree), with
|
||||||
|
the extension's existing modal, theme and autocomplete patterns. D3 and icon
|
||||||
|
licenses are in `src/vendor/d3-hierarchy/` and `src/vendor/lucide/`. There is no
|
||||||
|
runtime CDN dependency or new browser permission.
|
||||||
|
|
||||||
|
For synthetic browser checks, install Playwright separately (or provide it through
|
||||||
|
`NODE_PATH`) and run `node scripts/verify-org-chart.cjs`. `TEST_BROWSER_CHANNEL`
|
||||||
|
can select an installed `msedge` or `chrome`. This exercises real IndexedDB in a
|
||||||
|
local fixture and writes light/dark/mobile screenshots to a temporary folder;
|
||||||
|
native extension storage and Oracle integration still require the browser smoke
|
||||||
|
check below.
|
||||||
|
|
||||||
## 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, customer tables, organization charts, Manage Time, 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.
|
||||||
|
|
||||||
|
## Authentication and Access
|
||||||
|
|
||||||
|
Before dependent data requests, the extension establishes a valid SPA identity.
|
||||||
|
Concurrent consumers share acquisition; Reference changes and modal reopenings
|
||||||
|
reuse success. Identity also has a background browser-session cache, partitioned
|
||||||
|
by Firefox cookie store and private-browsing context, so a page reload does not
|
||||||
|
necessarily fetch identity again. The validated API response must contain user
|
||||||
|
id, name, roles and a token; the reusable identity stores only id, name and roles.
|
||||||
|
Failures keep dependent requests waiting and retry with backoff.
|
||||||
|
|
||||||
|
Users with **SALES_REP** do not load CRM SR or Workload Actions data or see their
|
||||||
|
badges. **Manage Time** is also hidden and its data request entry points are
|
||||||
|
blocked. Independent SR session bootstrap may still run; readiness does not grant
|
||||||
|
access to restricted activity data.
|
||||||
|
|
||||||
|
When identity requires session recovery, the extension opens
|
||||||
|
`https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace`
|
||||||
|
in an inactive auxiliary tab. SPA inspection rejects error documents, SAML/login
|
||||||
|
forms and OAuth callbacks before an identity probe. HTTP 200, cookies or elapsed
|
||||||
|
time alone do not prove readiness. Rejected callbacks restart at this bootstrap
|
||||||
|
URL. Recovery preserves the originating browser context and cleans up its owned
|
||||||
|
tab; its bounded cycle can be retried by the waiting identity gate.
|
||||||
|
|
||||||
|
Consumer-token recovery uses the same SPA bootstrap URL for invalid JSON and
|
||||||
|
HTTP 401/403, with up to five HTTP attempts. Token Relay uses the Fusion XSRF
|
||||||
|
cookie, has a 30-second request timeout and bounded cookie recovery; it cannot
|
||||||
|
remain indefinitely pending when the timeout fires.
|
||||||
|
|
||||||
|
SR session preparation can run concurrently with SPA preparation through
|
||||||
|
`https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/`.
|
||||||
|
It reports **SR session ready** on success, waits up to 70 seconds, and offers
|
||||||
|
Retry after failure. Eligible users' resource data is consumed after both identity
|
||||||
|
and SR preparation finish. See the architecture document for service-specific
|
||||||
|
credentials and the distinction between session navigation and API requests.
|
||||||
|
|
||||||
|
## CRM Service Request Badges
|
||||||
|
|
||||||
|
The header's **Refresh all** button invalidates persisted Products and activity
|
||||||
|
caches and clears related in-memory data views. It reloads the selected Reference,
|
||||||
|
displays opportunities first, then starts permitted SR and product enrichment.
|
||||||
|
Actions follow their SR/product prerequisites. Customer details, rate cards and consumption charts are
|
||||||
|
requested again when opened. Preferences, cookies and authentication are preserved.
|
||||||
|
The button is disabled during invalidation and list reload; product/activity badges
|
||||||
|
continue to show subsequent background progress. Cache-clear failures are surfaced
|
||||||
|
instead of silently reusing cached data, and stale product responses cannot restore
|
||||||
|
an invalidated cache.
|
||||||
|
|
||||||
|
Opportunity retrieval first calls `requestOpportunitiesSavedSearch` and requires
|
||||||
|
exactly one valid `isDefault=true` item. Its `savedSearchNumber` becomes
|
||||||
|
`copiedFrom: queries/<savedSearchNumber>` for every opportunity page. A missing or
|
||||||
|
ambiguous default blocks the dependent query instead of using a hard-coded value.
|
||||||
|
|
||||||
|
After all opportunity pages are displayed, SR and product enrichment start in the
|
||||||
|
background. Before the first `resquestCrmServiceRequest` page,
|
||||||
|
`requestSRSearchResources` retrieves CRMServiceRequest searches with the general
|
||||||
|
Token Relay Bearer. The first item's `savedSearchNumber` supplies SR `copiedFrom`;
|
||||||
|
this is independent of Opportunity's default-search selection.
|
||||||
|
SR fetches all pages of `SVC_PENDING_DELIVERY` / `ORA_SVC_MY_SR_TEAM_SRS` records
|
||||||
|
through Fusion `searchResources/latest`. Each page can update badges while the
|
||||||
|
remaining pages load. These requests appear in the Service Requests debug category.
|
||||||
|
After a complete successful SR query (HTTP 200 with valid responses on all pages),
|
||||||
|
its result is reused for subsequent Reference/product cycles in the current page
|
||||||
|
session. Refresh all explicitly clears it. Failed pages are retried up to five
|
||||||
|
total attempts each; completed pages are not requested again. Resetting the query
|
||||||
|
stops further retries from the previous run.
|
||||||
|
|
||||||
|
Records are linked by `OpportunityName_Id_c.OptyNumber`. A green SR badge indicates
|
||||||
|
one or more matches; its tooltip lists each SR number, title, reported hours, owner,
|
||||||
|
email and contact flags. A red badge indicates no match within that query's scope.
|
||||||
|
A failed lookup shows `SR ?`, not a false no-match result, and opportunities still load.
|
||||||
|
Badges follow Ramp Months. Successful SR and Actions responses are persisted in
|
||||||
|
extension local storage, isolated by tokenrelay principal. The cache uses the same
|
||||||
|
Preferences lifetime as Products (24 hours by default), anchored to the product
|
||||||
|
timestamp rather than the activity response time. Actions entries match each
|
||||||
|
product's update; the SR snapshot matches the latest product update in the cycle.
|
||||||
|
SR snapshots are stored separately by update timestamp so changing Reference does
|
||||||
|
not overwrite a previous period's snapshot. Expired snapshots are removed on
|
||||||
|
subsequent SR cache writes; Refresh all invalidates every snapshot.
|
||||||
|
No credentials, request headers or debug envelopes are stored in this cache.
|
||||||
|
Matching activity entries can be restored without network calls. Missing, expired
|
||||||
|
or mismatched entries remain unknown until queried, and cache-miss activity
|
||||||
|
lookups are allowed even when the products came from cache. A completed SR result
|
||||||
|
is reused and can be associated with the current product update. Empty or entirely
|
||||||
|
failed product cycles do not start product-dependent Actions; independently started
|
||||||
|
SR loading does not depend on a successful product cycle.
|
||||||
|
Activity lookups do not advance the existing Last update timestamp.
|
||||||
|
Closing and reopening the modal preserves results and does not
|
||||||
|
reload products or trigger these lookups. Already-running product requests may
|
||||||
|
finish in the background and update their related activities.
|
||||||
|
|
||||||
|
## Workload Actions
|
||||||
|
|
||||||
|
The missing Act badge includes a separate plus control that opens an Action form
|
||||||
|
with Owner, Role, Action, Team, Start date, End Date, Status, Complexity and Notes.
|
||||||
|
Opening the form loads team members and action lookups using the WWB-TS token.
|
||||||
|
Team members use the product RevnId; when multiple workloads are available, the
|
||||||
|
user selects one before its members are requested. Owner options use email for
|
||||||
|
both label and value; lookup options display name and submit code.
|
||||||
|
All Action selects support local autocomplete by label or code, arrow-key
|
||||||
|
navigation and Enter selection. Escape or leaving the field restores the last
|
||||||
|
selected option; typed search text is never submitted as an option value.
|
||||||
|
Fields are enabled only after both metadata requests return HTTP 200. Loading,
|
||||||
|
errors and manual retry are supported, and debug logs mask the credential.
|
||||||
|
Save validates all required fields and the date range before posting to the WWB
|
||||||
|
actions endpoint. Dates are sent at midnight UTC in ISO format; notes are an
|
||||||
|
array of strings (empty when omitted). The current user.id supplies createdBy
|
||||||
|
and updatedBy. While saving, editing and dismissal are locked to prevent duplicate
|
||||||
|
submissions. Success closes the form and displays a toast; failure preserves the
|
||||||
|
form and displays an error toast without automatically retrying the POST.
|
||||||
|
Cancel or Escape dismisses an idle form. The form is unavailable to SALES_REP
|
||||||
|
users, like the other Action features.
|
||||||
|
|
||||||
|
SR and Action filter badges appear before Status for users allowed to view these
|
||||||
|
activities. They filter the loaded data without issuing requests and combine with
|
||||||
|
the existing opportunity filters. The SR below 4h option replaces the other SR
|
||||||
|
selections and matches at least one linked SR with a numeric `ReportedHours_c < 4`
|
||||||
|
(including zero, excluding missing values). Action matches use the same ownership
|
||||||
|
rule as the row badge. Loading/failed lookups are not treated as missing records;
|
||||||
|
with both positive and negative options selected, all activity states remain
|
||||||
|
visible. These selections are kept in memory, not persisted.
|
||||||
|
|
||||||
|
Workload Actions are queried in the background after the product cycle completes,
|
||||||
|
and only after SR has completed successfully (or was restored/reused). If SR is
|
||||||
|
unavailable, Actions are not requested and remain unknown rather than absent.
|
||||||
|
Each failed workload lookup gets up to five total attempts, stopping on success
|
||||||
|
or when its session is superseded. Valid activity cache entries are reused;
|
||||||
|
cache misses can trigger lookups for cached products. Each distinct `RevnId` uses `requestWlActions` with
|
||||||
|
the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR
|
||||||
|
aggregates all products: green means an action's `owner` matches the general
|
||||||
|
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
|
||||||
|
means ownership could not be confirmed (including a missing principal). Tooltips
|
||||||
|
retain all returned actions, regardless of owner. The principal scopes persisted
|
||||||
|
activity entries; this data cache excludes credentials and debug envelopes.
|
||||||
|
Queries are shared per product cycle (up to four concurrently). Reference changes
|
||||||
|
reuse matching activity data or load missing data as needed.
|
||||||
|
Only the first page is requested for now; `hasNext` is indicated in the tooltip.
|
||||||
|
The debug panel groups these requests under Actions, with credentials masked.
|
||||||
|
|
||||||
|
## Manage Time
|
||||||
|
|
||||||
|
Eligible users can open **Manage Time** once the SR session is ready. It restores
|
||||||
|
the selected reference date, moves by seven days and preserves local week drafts
|
||||||
|
while navigating within the modal. Service Request, Activity and Task Type use
|
||||||
|
searchable dependent selections; Service and Non Service rows use their respective
|
||||||
|
task types. Duplicate SR selections are rejected with row feedback.
|
||||||
|
|
||||||
|
Daily hours must be numeric and between 0 and 24 inclusive. Save creates new
|
||||||
|
entries, updates changed saved entries and applies queued deletions. Deleting an
|
||||||
|
unsaved row is local; deleting a saved row waits for Save. Partial failures retain
|
||||||
|
unfinished work without repeating confirmed writes. Loading uses skeletons and
|
||||||
|
errors expose retry. The feature is unavailable for SALES_REP.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
Use Node.js:
|
Use Node.js:
|
||||||
@@ -17,27 +329,185 @@ Use Node.js:
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
The build recreates `dist/chromium` and `dist/firefox` from the same source.
|
The build updates `dist/chromium` and `dist/firefox` from the same source.
|
||||||
|
It preserves the output directories and unchanged files, replacing changed files
|
||||||
|
only after their complete contents have been written to a temporary file. Scripts
|
||||||
|
are published before the manifest, avoiding missing or partially written scripts
|
||||||
|
while an unpacked extension is loaded. Reload the extension and the Oracle page
|
||||||
|
after updating; clear historical extension errors to check for new failures.
|
||||||
|
|
||||||
## Release 1.0.0
|
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. CSS is serialized at build time in the order defined by
|
||||||
|
`scripts/compose.cjs`, including feature styles followed by shared table and modal
|
||||||
|
overrides in `src/content/styles/tables.css` and `dialogs.css`. There is no
|
||||||
|
runtime module loader, browser-side eval, ES-module requirement or new dependency.
|
||||||
|
|
||||||
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
|
## Customer Insights
|
||||||
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
|
|
||||||
- Opportunity type view preferences persisted in the browser.
|
|
||||||
- Background product retrieval with configurable caching, progress tracking, and expandable product details.
|
|
||||||
- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal.
|
|
||||||
- Light and dark themes based on Oracle Redwood.
|
|
||||||
- Separate outputs for Chromium/Edge and Firefox.
|
|
||||||
|
|
||||||
## Release 1.0.1
|
Installed Base and Service Requests include a search input above their tables.
|
||||||
|
Search filters loaded records across every displayed column, including raw and
|
||||||
|
formatted numbers/dates, without additional API calls. Matching ignores case
|
||||||
|
and accents; all whitespace-separated terms must match somewhere in the row.
|
||||||
|
The result count updates as users type. Clearing the input restores all records;
|
||||||
|
reopening a dialog starts with an empty search. Search remains available during
|
||||||
|
loading and retains the entered terms when results arrive. Both themes are supported.
|
||||||
|
|
||||||
- Retries the consumer token service when it returns HTTP 200 with an HTML fallback.
|
Each Cloud Consumption plan includes a **Rate card** button beside Contract ID.
|
||||||
- Opens the SPA session page in an inactive temporary tab and closes it as soon as valid JSON is received.
|
It opens an animated right-side drawer with skeleton rows, an all-column search
|
||||||
- Limits the recovery flow to five attempts and records the session bootstrap details in debug mode.
|
and sortable headers. The drawer reuses the Cloud Consumption header component
|
||||||
|
with the title **Rate Card**, customer subtitle, and the active plan's Booking
|
||||||
|
Customer, Sold To Party Name, Contract ID, Header Status, Start Date and End Date.
|
||||||
|
There is no separate plan-number subtitle or nested Rate Card action; its Close button
|
||||||
|
dismisses only the drawer. `requestRatecardByPlan` sends the active plan's `subPlanNum`
|
||||||
|
as the payload's `planNumber`, with `recordsSize: 10000`, to the fixed SPA endpoint.
|
||||||
|
It uses the existing OCICONS-TS `uiaasHeader.value` in `G2m-Authorization` and is
|
||||||
|
logged in the Consumption debug category with the existing credential masking.
|
||||||
|
The eleven requested fields are displayed, including percentage discounts and
|
||||||
|
USD prices with at least four decimal places. Search includes raw and formatted
|
||||||
|
values; prices, quantities, percentages and dates sort by value. An **Order Number**
|
||||||
|
multiselect beside Search groups distinct order numbers and supports selecting all
|
||||||
|
or several orders. Clicking one order while all are selected isolates that order.
|
||||||
|
Order selections and text search combine locally.
|
||||||
|
Shared table styles remove the old fixed minimum width, tighten cell spacing and
|
||||||
|
allow multiword headers to wrap at word boundaries so the table fits the drawer.
|
||||||
|
Closing the drawer, changing plans or closing Cloud Consumption discards late UI
|
||||||
|
updates. Concurrent requests for the same plan are shared; reopening after completion
|
||||||
|
fetches again. Errors remain local. No new persisted cache, permissions, token
|
||||||
|
refresh or retries are added. The provided contract requests up to 10000 records;
|
||||||
|
the response's `searchAfter` is not used without a defined pagination contract.
|
||||||
|
`tests/ratecard.test.cjs` covers transport, formatting, filtering, sorting and
|
||||||
|
drawer lifecycle with synthetic responses and DOM, without launching browsers.
|
||||||
|
|
||||||
|
Cloud Consumption loads `requestConsumptionPlanDetail` for the active plan after
|
||||||
|
account plans arrive, independently of the chart. Selecting another plan loads
|
||||||
|
its `subPlanNum` on demand. The GET uses `active=true` and the raw OCICONS-TS
|
||||||
|
`uiaasHeader.value` in `G2m-Authorization`, through the existing consumer session.
|
||||||
|
Projection badges align with the chart container's left edge, while the
|
||||||
|
Quarterly/Monthly/Weekly/Daily controls stay on the right of the same header row:
|
||||||
|
red Forfeit, blue Overage.
|
||||||
|
Hover or keyboard focus reveals an interactive tooltip; clicking pins it for
|
||||||
|
access to the Order number links. Escape, Close, outside clicks, tab changes and
|
||||||
|
modal closure dismiss it. Each root line retains its nested `clines` details.
|
||||||
|
Only HTTPS order links on `accountdashboard.oracle.com` become clickable.
|
||||||
|
Amounts use the requested non-LC fields, formatted without inferring a local
|
||||||
|
currency. Details are kept only in the current modal, with in-flight deduplication
|
||||||
|
and local loading/empty/error states. Failed details do not block the chart.
|
||||||
|
The existing Consumption debug category includes this request.
|
||||||
|
|
||||||
|
The Service Requests action sits immediately after Installed Base below Customer.
|
||||||
|
Its dialog opens with skeleton rows and lists open SRs for the selected customer.
|
||||||
|
The background first calls `requestAccountSummary` with ACCP-TS, then uses its
|
||||||
|
`id` (not `registryId`) in the GET `requestCustomerServiceRequest` URL. The SR
|
||||||
|
credential is `uiaasHeader.value` from `SPA-ORANGE`, sent verbatim in
|
||||||
|
`custsr-auth`, without a Bearer prefix, regardless of `uiaasHeader.label`.
|
||||||
|
Pages of 40 are consolidated using `totalResults`, `count`
|
||||||
|
and `offset`. Concurrent clicks for the same customer share the in-flight query;
|
||||||
|
results are not cached, and closed/replaced dialogs ignore late responses.
|
||||||
|
The table maps Name/Registry ID from `customer`, Product Name from `component`,
|
||||||
|
and formats status as a badge and dates using the existing date formatter.
|
||||||
|
Debug contains a Service Requests category; credential headers are masked before
|
||||||
|
delivery to content. This feature adds no permissions or authentication retries.
|
||||||
|
|
||||||
|
The Installed Base action sits beside customer consumption below Customer. The
|
||||||
|
customer appears in its modal header rather than a repeated Customer column. It
|
||||||
|
opens a skeleton dialog, then calls `requestAccountSummary` with ACCP-TS and uses
|
||||||
|
the returned `registryId` in `requestInstallBase`. The latter uses the SAIB-TS
|
||||||
|
`uiaasHeader.label` and `uiaasHeader.value`, and loads pages of 25 until `hasMore`
|
||||||
|
is false. Missing credentials, invalid collections and stalled pagination show
|
||||||
|
an error rather than partial results. These requests appear in debug; credential
|
||||||
|
headers in this flow are masked before delivery to the content script. No new
|
||||||
|
cache, permissions or session recovery logic is introduced. Amounts use two
|
||||||
|
decimal places without inferring a currency from the separate LC fields.
|
||||||
|
|
||||||
|
The opportunities table places product counts and badges below Name and customer
|
||||||
|
actions below Customer. Their spacing is coordinated; longer names can grow the
|
||||||
|
row without overlapping controls. Shared table and modal styles provide consistent
|
||||||
|
titles, typography, borders, padding and loading skeletons in both themes.
|
||||||
|
|
||||||
|
## Debug Requests
|
||||||
|
|
||||||
|
Request records are persisted in page localStorage under a tab-specific namespace.
|
||||||
|
Closing the main modal preserves them. The reset occurs immediately before the
|
||||||
|
first `requestOpportunities` page of a new query; subsequent pages preserve that
|
||||||
|
query's history. Requests logged before that reset, including its saved-search
|
||||||
|
lookup, are removed by the same boundary.
|
||||||
|
|
||||||
|
The hidden debug panel does not prebuild request details. Opening it loads the
|
||||||
|
summaries and expanding an entry constructs its details. Storage is updated in
|
||||||
|
batches of changed records; quota or access failures retain the session's records
|
||||||
|
in memory with explanatory feedback rather than interrupting API requests.
|
||||||
|
|
||||||
|
Product HTTP requests appear in Debug Requests (Ctrl+Shift+D) as soon as they start.
|
||||||
|
Each entry identifies the opportunity and page offset, stays `running` while
|
||||||
|
waiting for headers or the response body, and updates with HTTP status, response,
|
||||||
|
duration or error. Cache hits do not create HTTP entries. Product diagnostics
|
||||||
|
mask authorization and sensitive response headers before messaging the source
|
||||||
|
tab/frame; this change adds no timeout, retry or authentication behavior.
|
||||||
|
|
||||||
|
Debug Requests opens as a full-page view inside the extension using Ctrl+Shift+D, without
|
||||||
|
opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+Shift+D
|
||||||
|
returns to the underlying view. Debug stays above other extension modals.
|
||||||
|
Header/category navigation remains above the
|
||||||
|
scrollable timeline; expanded details use two columns on wide screens and one on
|
||||||
|
narrow screens. Theme-specific colors distinguish success, failure, running,
|
||||||
|
HTTP methods/statuses, request/response sections and JSON tokens. Highlighting
|
||||||
|
uses safe text nodes only after the existing sensitive-value masking. Compact JSON,
|
||||||
|
supported protection prefixes and JSON documents wrapped in strings are normalized
|
||||||
|
and indented, including while sensitive values are visible. Large JSON responses
|
||||||
|
are captured in full, preserving indentation, highlighting and sensitive-field
|
||||||
|
masking. Original sensitive values are retained for explicit reveal, including
|
||||||
|
identity, tokenrelay and catalog tokens. Already incomplete
|
||||||
|
or invalid JSON remains plain text. The visibility toggle retains its existing
|
||||||
|
behavior. This is display masking, not encryption or guaranteed redaction at rest:
|
||||||
|
captured records can retain original sensitive values in localStorage. Focus,
|
||||||
|
expansion and timeline scroll position survive log updates.
|
||||||
|
|
||||||
|
Debug tabs group Authentication, Opportunities, Contacts, Products, Consumption,
|
||||||
|
Installed Base, Service Requests and Actions
|
||||||
|
requests; unknown request types appear under Other. Only categories with entries
|
||||||
|
are shown. All preserves the complete timeline. Every tab sorts by request start
|
||||||
|
time, retaining original request IDs and showing start times in collapsed rows.
|
||||||
|
Counts and running states update without changing the selected category or
|
||||||
|
collapsing expanded details. Use arrow keys, Home and End to navigate the tabs.
|
||||||
|
|
||||||
|
## 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,
|
||||||
|
organization-chart and Manage Time 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.
|
||||||
|
Calendar week positioning, responsive tables, modal dragging and print pagination
|
||||||
|
still need visual checks in installed browsers; synthetic tests alone do not
|
||||||
|
certify those layouts or live Oracle authentication.
|
||||||
|
|
||||||
|
Without npm, run `node scripts/build.mjs`, `node scripts/test.cjs` and
|
||||||
|
`node scripts/build.mjs --check`. The documentation-baseline checks use Node 22.15.0;
|
||||||
|
the build has no runtime dependency installation step.
|
||||||
|
|
||||||
|
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,
|
||||||
|
and consumption data; 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.
|
||||||
|
|
||||||
## 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.
|
||||||
@@ -56,3 +526,25 @@ The build recreates `dist/chromium` and `dist/firefox` from the same source.
|
|||||||
- `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome`
|
- `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome`
|
||||||
|
|
||||||
When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface.
|
When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface.
|
||||||
|
|
||||||
|
Opening the saved shortcut ending in `#opportunities-extension` also opens the
|
||||||
|
modal without clicking the Sales group. Its intent is captured before Fusion can
|
||||||
|
rewrite the URL, then consumed once when the extension is ready.
|
||||||
|
|
||||||
|
## Favorites, Modal Interaction and Printing
|
||||||
|
|
||||||
|
With the main modal open, **Ctrl+Shift+F** opens a searchable browser bookmark
|
||||||
|
folder picker. Choose the destination and Save; the extension uses the bookmarks
|
||||||
|
API rather than the browser's native Add Favorite dialog. If the shortcut is not
|
||||||
|
bookmarked, a prominent hint appears for 20 seconds and disappears immediately
|
||||||
|
after saving. **Last update** remains the first header badge.
|
||||||
|
|
||||||
|
Non-full-page modals can be dragged from any noninteractive area of their title
|
||||||
|
bar. Up to 70% of their width and height can move beyond the viewport; title-bar
|
||||||
|
buttons keep their normal actions. Full-page views remain fixed.
|
||||||
|
|
||||||
|
The print icon beside **Booking** and **Workload** opens a report containing the
|
||||||
|
current filters, counts, stage amounts and opportunity list. Unrestricted Customer
|
||||||
|
and Owner selections are labeled **All customers** and **All owners**. The report
|
||||||
|
requests A4 landscape with page margins and supports the browser's Print / Save PDF
|
||||||
|
dialog. Browser print settings can override the requested orientation and paper.
|
||||||
|
|||||||
107
RELEASE_NOTES.md
Normal file
107
RELEASE_NOTES.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Release Notes
|
||||||
|
|
||||||
|
## Release 1.1.0 - 2026-10-30
|
||||||
|
|
||||||
|
- Display opportunities first, then load Service Requests and update their badges in the background.
|
||||||
|
- Clicking an item when all filter choices are selected now selects that item alone, including Booking and Workload.
|
||||||
|
- Added a bookmark shortcut that opens the extension automatically from Fusion.
|
||||||
|
- Ctrl+Shift+F opens a searchable bookmarks folder picker; Ctrl+Shift+D toggles Debug Requests.
|
||||||
|
- Show a prominent favorites shortcut hint for 20 seconds when the shortcut is not bookmarked; keep Last update first in the header.
|
||||||
|
- Smaller dialogs can be dragged by their title bars, with up to 70% overflow beyond viewport edges.
|
||||||
|
- Updated Chromium/Edge and Firefox builds. Automated validation uses synthetic data; live Oracle and installed-browser testing are not included.
|
||||||
|
|
||||||
|
## Release 1.0.9 - 2026-10-28
|
||||||
|
|
||||||
|
- Added a landscape print/PDF view with filters, totals, and opportunities.
|
||||||
|
- Resolve the CRM Service Request saved search dynamically before querying records.
|
||||||
|
- Restore hidden-iframe Internal SR session recovery with bounded retries and shared pending requests.
|
||||||
|
- Align opportunity badges and customer actions consistently.
|
||||||
|
- Move release history from README to this dedicated file.
|
||||||
|
|
||||||
|
## Release 1.0.8 - 2026-10-27
|
||||||
|
|
||||||
|
- Added customer Organization Charts with contact search, contact creation, person details, notes, hierarchy editing and locally persisted chart data.
|
||||||
|
- Added paginated, sortable opportunities by contact using the contact PartyNumber.
|
||||||
|
- Unified modal and table layouts, loading skeletons and typography; added Rate Card order-number filtering and simplified Overview customer names using PartyName.
|
||||||
|
- Added Reference-bounded scrolling calendars with all daily opportunity cards and positioning around the current week.
|
||||||
|
- Added Booking/Workload filters and improved ramp/projection access, alerts and unavailable-workload indicators.
|
||||||
|
- Removed AI-generated opportunity summaries and their dedicated requests.
|
||||||
|
- Persisted debug requests in localStorage with lazy rendering; history resets only when a new opportunity query starts.
|
||||||
|
- Reuse successful current-user identity during the browser session; fixed debug timer invocation and added Token Relay timeout/retry support.
|
||||||
|
- Prepare SPA and SR sessions concurrently, inspect SPA before visual assets finish loading, and provide SR session retry with a 70-second timeout.
|
||||||
|
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests are not included.
|
||||||
|
|
||||||
|
## Release 1.0.7 - 2026-10-26
|
||||||
|
|
||||||
|
- Remember table sorting preferences and restore the selected column and direction when reopening supported tables.
|
||||||
|
- Retrieve the user's Opportunity saved searches before loading opportunities and use the default savedSearchNumber for copiedFrom across all result pages.
|
||||||
|
- Show requestOpportunitiesSavedSearch in the Opportunities debug category and stop dependent queries when the default search is missing or invalid.
|
||||||
|
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests were not performed for this release.
|
||||||
|
|
||||||
|
## Release 1.0.6 - 2026-10-21
|
||||||
|
|
||||||
|
- Validate the current-user JSON structure before starting dependent requests, sharing concurrent authentication work and retrying failed identity acquisition.
|
||||||
|
- Recover SPA sessions through one inactive auxiliary tab at `https://spa.oracle.com/`, preserving the originating window and Firefox cookie store.
|
||||||
|
- Inspect the SPA document before retrying identity: reject error pages, SAML/login forms, OAuth callbacks and incomplete documents. HTTP 200, cookies and elapsed time alone no longer release identity probes.
|
||||||
|
- Read identity in the verified SPA tab, at most once per document; restart rejected authentication at the home URL rather than replaying OAuth callbacks. Readiness timeouts do not trigger fallback API probes.
|
||||||
|
- Correct native timer invocation and retain protected debug diagnostics and auxiliary-tab cleanup.
|
||||||
|
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle authentication and installed-browser smoke tests were not performed for this release.
|
||||||
|
|
||||||
|
## Release 1.0.5 - 2026-10-20
|
||||||
|
|
||||||
|
- Corrected Reference date ranges for Oracle fiscal quarters and years, updated the opportunity query structure and removed the Renewals reference.
|
||||||
|
- Added paginated workload retrieval by unique customer account and joined workloads to products by RevnId.
|
||||||
|
- Added consumption projection comparisons using adjustedACR, RampMonths_c and the reference workbook formula, including first-month proration.
|
||||||
|
- Product and opportunity alerts identify monthly forecast differences. Single-product alerts open the comparison directly; multiple-product alerts expand the product list.
|
||||||
|
- Added Update estimates with explicit confirmation before PATCH, dynamic calculated amounts and current-user attribution, duplicate-submit protection and success/error feedback.
|
||||||
|
- Successful forecast updates refresh local comparisons and invalidate workload caches. Workloads reuse Cache duration across modal openings and token renewals.
|
||||||
|
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
|
||||||
|
|
||||||
|
## Release 1.0.4 - 2026-10-19
|
||||||
|
|
||||||
|
- Added Manage Time with weekly navigation, a persisted reference date, daily hour totals and loading skeletons.
|
||||||
|
- Added searchable Service Request, Activity and Task Type fields, expanded SR tooltips and separate Service/Non Service task types.
|
||||||
|
- Existing time entries populate weekly hours and Notes. Local drafts are preserved while navigating between weeks.
|
||||||
|
- Save creates entries with POST, updates changed entries with PUT and applies queued deletions with DELETE. Writes use Session, Origin and the observed App Builder client ID.
|
||||||
|
- Added required-field validation, the 0-24 hour range, duplicate SR selection prevention, temporary row highlighting and success/error toasts.
|
||||||
|
- Reused Cache duration across form queries and invalidated cached responses after successful writes. Resource user data is reused after SR session initialization.
|
||||||
|
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
|
||||||
|
|
||||||
|
## Release 1.0.3 - 2026-10-13
|
||||||
|
|
||||||
|
- Added monthly and weekly calendar views with stage colors, fiscal week labels, daily counts and overflow details.
|
||||||
|
- Added searchable Action forms, required-field validation, saving and local badge updates.
|
||||||
|
- Added Service Request creation with dynamic lookups, payload/header confirmation and session recovery.
|
||||||
|
- Added Internal Service Request details and creation. Save reads parent data from `requestSRInfo`, resolves the current resource user, and links the new record using the parent `SrId`.
|
||||||
|
- Internal SR creation uses Session authentication, JSON, Origin and the observed App Builder client ID. Customer deadlines remain date-only; internal deadlines use midnight UTC ISO timestamps.
|
||||||
|
- Improved activity filters, cache reuse, Refresh all, progress indicators and compact layouts.
|
||||||
|
- Debug retains complete response bodies and reveals original sensitive values through the visibility toggle; values are hidden by default.
|
||||||
|
- Regenerated Chromium/Edge and Firefox distributions. Automated tests use synthetic data; real Oracle creation and installed-browser smoke tests are not part of release validation.
|
||||||
|
|
||||||
|
## Release 1.0.2
|
||||||
|
|
||||||
|
- Modularized the extension with regression tests and documented execution boundaries.
|
||||||
|
- Added AI opportunity summaries with a dedicated, reusable Sales Common access token.
|
||||||
|
- Added Installed Base and customer Service Requests tables with search and dark-mode support.
|
||||||
|
- Expanded Cloud Consumption with plan projections, interactive details and a searchable, sortable Rate Card drawer.
|
||||||
|
- Added Ramp Months, CRM Service Request and Workload Actions badges; SR details include direct links and copy controls.
|
||||||
|
- Added a one-time current-user lookup before the initial request flow. Users with `SALES_REP` do not query or display CRM SR/Actions.
|
||||||
|
- Improved debug request grouping, full-page viewing, response formatting and theme contrast.
|
||||||
|
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
|
||||||
|
- Regenerated Chromium/Edge and Firefox distributions. Validation uses automated, browser-independent tests; installed-browser smoke tests were not performed for this release.
|
||||||
|
|
||||||
|
## Release 1.0.1
|
||||||
|
|
||||||
|
- Retries the consumer token service when it returns HTTP 200 with an HTML fallback.
|
||||||
|
- Opens the SPA session page in an inactive temporary tab and closes it as soon as valid JSON is received.
|
||||||
|
- Limits the recovery flow to five attempts and records the session bootstrap details in debug mode.
|
||||||
|
|
||||||
|
## Release 1.0.0
|
||||||
|
|
||||||
|
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
|
||||||
|
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
|
||||||
|
- Opportunity type view preferences persisted in the browser.
|
||||||
|
- Background product retrieval with configurable caching, progress tracking, and expandable product details.
|
||||||
|
- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal.
|
||||||
|
- Light and dark themes based on Oracle Redwood.
|
||||||
|
- Separate outputs for Chromium/Edge and Firefox.
|
||||||
3120
dist/chromium/background.js
vendored
3120
dist/chromium/background.js
vendored
File diff suppressed because it is too large
Load Diff
14685
dist/chromium/content.js
vendored
14685
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
31
dist/chromium/manifest.json
vendored
31
dist/chromium/manifest.json
vendored
@@ -2,16 +2,31 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Opportunities Extension",
|
"name": "Opportunities Extension",
|
||||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
"bookmarks",
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage"
|
"storage",
|
||||||
|
"webRequest",
|
||||||
|
"declarativeNetRequestWithHostAccess"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*"
|
"https://spa.oracle.com/*",
|
||||||
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
||||||
|
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"shortcut-intent.js"
|
||||||
|
],
|
||||||
|
"all_frames": false,
|
||||||
|
"run_at": "document_start"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matches": [
|
"matches": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
||||||
@@ -22,6 +37,16 @@
|
|||||||
],
|
],
|
||||||
"all_frames": true,
|
"all_frames": true,
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://spa.oracle.com/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"spa-page.js"
|
||||||
|
],
|
||||||
|
"all_frames": false,
|
||||||
|
"run_at": "document_end"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"background": {
|
"background": {
|
||||||
|
|||||||
11
dist/chromium/shortcut-intent.js
vendored
Normal file
11
dist/chromium/shortcut-intent.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Capture the bookmark before Fusion rewrites its navigation URL.
|
||||||
|
(() => {
|
||||||
|
if (window.top !== window) return;
|
||||||
|
const capture = () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (url.hash !== "#opportunities-extension" && url.searchParams.get("opportunitiesExtension") !== "open") return;
|
||||||
|
try { window.sessionStorage.setItem("opportunitiesExtension.shortcutIntent", String(Date.now())); } catch { /* The URL remains a fallback. */ }
|
||||||
|
};
|
||||||
|
capture();
|
||||||
|
window.addEventListener("hashchange", capture);
|
||||||
|
})();
|
||||||
58
dist/chromium/spa-page.js
vendored
Normal file
58
dist/chromium/spa-page.js
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
// Source: src/auth/spa-page.js
|
||||||
|
function createSpaPage() {
|
||||||
|
function start() {
|
||||||
|
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
|
||||||
|
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
|
||||||
|
if (!runtime) return;
|
||||||
|
const documentId = `${performance.timeOrigin}:${location.pathname}`;
|
||||||
|
let identityRequest = null;
|
||||||
|
|
||||||
|
function inspect() {
|
||||||
|
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
|
||||||
|
let status = "loading";
|
||||||
|
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
|
||||||
|
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|
||||||
|
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|
||||||
|
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
|
||||||
|
else if ((document.readyState === "interactive" || document.readyState === "complete") && text
|
||||||
|
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
|
||||||
|
return { status, documentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.onMessage.addListener((message, sender, reply) => {
|
||||||
|
if (sender.id !== runtime.id) return false;
|
||||||
|
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
|
||||||
|
reply(inspect());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
|
||||||
|
const page = inspect();
|
||||||
|
if (page.status !== "ready" || page.documentId !== message.documentId) {
|
||||||
|
reply({ error: "SPA page is not ready or has navigated." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A document is probed at most once; failed identity reads require a fresh navigation.
|
||||||
|
if (!identityRequest) identityRequest = (async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 15000);
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
|
||||||
|
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
|
||||||
|
});
|
||||||
|
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
|
||||||
|
contentType: response.headers.get("content-type") || "", body: await response.text() };
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error.message || "Unable to read SPA identity." };
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
})();
|
||||||
|
identityRequest.then(reply);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { start };
|
||||||
|
}
|
||||||
|
|
||||||
|
createSpaPage().start();
|
||||||
|
})();
|
||||||
3120
dist/firefox/background.js
vendored
3120
dist/firefox/background.js
vendored
File diff suppressed because it is too large
Load Diff
14685
dist/firefox/content.js
vendored
14685
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
31
dist/firefox/manifest.json
vendored
31
dist/firefox/manifest.json
vendored
@@ -2,16 +2,31 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Opportunities Extension",
|
"name": "Opportunities Extension",
|
||||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
"bookmarks",
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage"
|
"storage",
|
||||||
|
"webRequest",
|
||||||
|
"declarativeNetRequestWithHostAccess"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*"
|
"https://spa.oracle.com/*",
|
||||||
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
||||||
|
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"shortcut-intent.js"
|
||||||
|
],
|
||||||
|
"all_frames": false,
|
||||||
|
"run_at": "document_start"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matches": [
|
"matches": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
||||||
@@ -22,6 +37,16 @@
|
|||||||
],
|
],
|
||||||
"all_frames": true,
|
"all_frames": true,
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://spa.oracle.com/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"spa-page.js"
|
||||||
|
],
|
||||||
|
"all_frames": false,
|
||||||
|
"run_at": "document_end"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"background": {
|
"background": {
|
||||||
|
|||||||
11
dist/firefox/shortcut-intent.js
vendored
Normal file
11
dist/firefox/shortcut-intent.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Capture the bookmark before Fusion rewrites its navigation URL.
|
||||||
|
(() => {
|
||||||
|
if (window.top !== window) return;
|
||||||
|
const capture = () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (url.hash !== "#opportunities-extension" && url.searchParams.get("opportunitiesExtension") !== "open") return;
|
||||||
|
try { window.sessionStorage.setItem("opportunitiesExtension.shortcutIntent", String(Date.now())); } catch { /* The URL remains a fallback. */ }
|
||||||
|
};
|
||||||
|
capture();
|
||||||
|
window.addEventListener("hashchange", capture);
|
||||||
|
})();
|
||||||
58
dist/firefox/spa-page.js
vendored
Normal file
58
dist/firefox/spa-page.js
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
// Source: src/auth/spa-page.js
|
||||||
|
function createSpaPage() {
|
||||||
|
function start() {
|
||||||
|
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
|
||||||
|
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
|
||||||
|
if (!runtime) return;
|
||||||
|
const documentId = `${performance.timeOrigin}:${location.pathname}`;
|
||||||
|
let identityRequest = null;
|
||||||
|
|
||||||
|
function inspect() {
|
||||||
|
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
|
||||||
|
let status = "loading";
|
||||||
|
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
|
||||||
|
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|
||||||
|
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|
||||||
|
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
|
||||||
|
else if ((document.readyState === "interactive" || document.readyState === "complete") && text
|
||||||
|
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
|
||||||
|
return { status, documentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.onMessage.addListener((message, sender, reply) => {
|
||||||
|
if (sender.id !== runtime.id) return false;
|
||||||
|
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
|
||||||
|
reply(inspect());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
|
||||||
|
const page = inspect();
|
||||||
|
if (page.status !== "ready" || page.documentId !== message.documentId) {
|
||||||
|
reply({ error: "SPA page is not ready or has navigated." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A document is probed at most once; failed identity reads require a fresh navigation.
|
||||||
|
if (!identityRequest) identityRequest = (async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 15000);
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
|
||||||
|
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
|
||||||
|
});
|
||||||
|
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
|
||||||
|
contentType: response.headers.get("content-type") || "", body: await response.text() };
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error.message || "Unable to read SPA identity." };
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
})();
|
||||||
|
identityRequest.then(reply);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { start };
|
||||||
|
}
|
||||||
|
|
||||||
|
createSpaPage().start();
|
||||||
|
})();
|
||||||
337
docs/architecture.md
Normal file
337
docs/architecture.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
# Architecture and Compatibility Contracts
|
||||||
|
|
||||||
|
This document describes the current checkout, including behavior added after the
|
||||||
|
1.1.0 release. [refactoring.md](refactoring.md) records the earlier modularization;
|
||||||
|
historical observations there do not override the current contracts below.
|
||||||
|
|
||||||
|
The [OpenSpec capability baseline](../openspec/specs/) and its archived
|
||||||
|
[source/test evidence map](../openspec/changes/archive/2026-10-31-document-project-baseline/design.md)
|
||||||
|
provide behavioral context. The initial change was completed and archived on
|
||||||
|
2026-10-31, promoting all twelve capabilities to durable specifications. Use those
|
||||||
|
capabilities for subsequent delta changes.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- `src/content/shortcut-intent.js` runs at `document_start` in the top frame,
|
||||||
|
preserving bookmark intent before Fusion rewrites the URL. The main content
|
||||||
|
script runs at `document_idle`, in all matching frames on the two
|
||||||
|
configured Fusion welcome pages. Their code also checks the hostname/path.
|
||||||
|
- `src/auth/spa-page.js` runs at `document_end` as a separate top-frame-only SPA content script, copied
|
||||||
|
into both distributions. It handles only extension messages for DOM readiness
|
||||||
|
inspection and a fixed same-origin identity GET; it sends no requests on its own.
|
||||||
|
`services/spa/page-client.js` connects this receiver to the background recovery
|
||||||
|
flow and the existing debug envelope. No page-world script is injected.
|
||||||
|
- There are no extension UI pages, injected page-context scripts, custom CSP,
|
||||||
|
declared execution world, or web-accessible resources.
|
||||||
|
- `scripts/build.mjs` defines permissions: `bookmarks`, `cookies`, `storage`,
|
||||||
|
`webRequest` and `declarativeNetRequestWithHostAccess`. Host permissions cover
|
||||||
|
`eeho.fa.us2.oraclecloud.com`, `spa.oracle.com`, and
|
||||||
|
`comcipapic-oalprod.integration.ocp.oraclecloud.com` over HTTPS.
|
||||||
|
- Bookmark access supports folder selection and shortcut detection. SR integration
|
||||||
|
observes App Builder client IDs and installs narrow session rules for required
|
||||||
|
Origin headers; it is not a general request proxy.
|
||||||
|
|
||||||
|
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 | Plan projection details | Per-panel WeakMap, shared in-flight requests, nested tooltip DOM, temporary pointer/resize listeners cleaned up on dismiss/tab change/modal close |
|
||||||
|
| Content | Rate card | Pure column/filter/sort model, controller with per-plan in-flight sharing, animated drawer with skeleton/search/table; response guards and cleanup on close or plan switch |
|
||||||
|
| Content | Service Requests UI | Customer-specific in-flight queries, skeleton/table modal, stale-response guards; no persistent cache |
|
||||||
|
| Content | Organization Chart | Working hierarchy, contact autocomplete/creation, transient email and contact-opportunity views |
|
||||||
|
| Content | Manage Time | Week drafts, dependent selections, validated hours and deferred writes/deletions |
|
||||||
|
| Content | Authentication/debug | General token promise, SPA authorizations, request records and visibility state |
|
||||||
|
| 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 |
|
||||||
|
| Background | Organization Chart storage | User/customer-scoped IndexedDB records and revision checks |
|
||||||
|
| Background | SR and time integration | Session preparation, resource lookup, service operations and header adaptation |
|
||||||
|
|
||||||
|
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 generally abort network traffic. Token Relay specifically
|
||||||
|
uses a 30-second timeout with AbortController when available. Its timeout also
|
||||||
|
covers response reading; debug persistence failures cannot leave it pending.
|
||||||
|
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.
|
||||||
|
Normal authorization reuse and debug capture have different storage lifetimes;
|
||||||
|
see Storage Contracts before making claims about retained sensitive values.
|
||||||
|
|
||||||
|
| Credential | Acquisition | Consumers | Lifetime/recovery |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| XSRF | Background cookie lookup, then content `document.cookie` fallback | `x-xsrf-token` for Token Relay | Cookie recovery on missing/invalid response with a bounded refresh attempt |
|
||||||
|
| Fusion general | GET `/fscmRestApi/tokenrelay` | Opportunity/saved-search, product, CRM SR and contact queries/writes | Shared content acquisition; 30-second request timeout |
|
||||||
|
| 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, consumption trend, plan detail and rate card authorization | Same service response, distinct credential from ACCP-TS |
|
||||||
|
| SPA-ORANGE | SPA consumer service `uiaasHeader.value` | Customer service requests, fixed `custsr-auth` header with raw value, no Bearer prefix; ignores `uiaasHeader.label` | Optional at consumer acquisition; required only when opening Service Requests; existing in-memory session rules |
|
||||||
|
| SAIB-TS | SPA consumer service label/value | Installed Base after account registry resolution | Required when opening Installed Base |
|
||||||
|
| WWB-TS | SPA consumer service value | Workloads and Actions in `Wwb-Provider-Authorization` | Shared consumer acquisition; data caching is separately scoped |
|
||||||
|
| SR integration session | Integration cookies and `Authorization: Session` | Internal SR and Manage Time operations; catalog preparation | Required Origin and observed `x-appbuilder-client-id` where applicable; session preparation is separate from identity |
|
||||||
|
| Catalog authorization | SR catalog/token preparation | SR creation | Operation-specific headers; do not replace with the Fusion general token |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Identity is validated before dependent data requests and then reused. Content
|
||||||
|
shares its pending identity promise; background shares by cookie store/private
|
||||||
|
context and can restore user id/name/roles from extension session storage. A page
|
||||||
|
reload alone therefore does not force identity revalidation. SALES_REP blocks
|
||||||
|
CRM activity data, badges and Manage Time entry points. Independent SR session
|
||||||
|
navigation can start concurrently before the role result is consumed.
|
||||||
|
|
||||||
|
SPA bootstrap uses
|
||||||
|
`https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace`.
|
||||||
|
Recovery rejects login/error/callback documents before a same-origin identity
|
||||||
|
probe. It closes owned tabs and preserves the originating window/container.
|
||||||
|
SR preparation uses the SeaaS_initiation external application, with a 70-second
|
||||||
|
timeout and manual retry. Internal SR detail recovery instead uses a hidden iframe
|
||||||
|
and bounded, shared 401 checks, not a visible tab or an unlimited request loop.
|
||||||
|
|
||||||
|
| Request | Method/path | Important contract |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| requestOpportunitiesSavedSearch | GET `/crmRestApi/searchResources/11.13.18.05/custom-actions/queries` | Opportunity entity; require one `isDefault=true` result with a valid savedSearchNumber |
|
||||||
|
| requestOpportunities | POST `/crmRestApi/searchResources/11.13.18.05/custom-actions/queries` | `copiedFrom=queries/<default savedSearchNumber>` across pages; RecordSet OR plus EffectiveDate AND; Preference transient; pages of 15 |
|
||||||
|
| requestSRSearchResources | GET `/crmRestApi/searchResources/latest/custom-actions/queries` | CRMServiceRequest entity; use the first item's savedSearchNumber |
|
||||||
|
| resquestCrmServiceRequest | POST `/crmRestApi/searchResources/latest/custom-actions/queries` | Dynamic copiedFrom; pending-delivery team SRs; all pages; incremental badge updates |
|
||||||
|
| 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 |
|
||||||
|
| 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}]` |
|
||||||
|
| requestCustomerServiceRequest | GET `provider-proxy/g2m-custsr-provider/service/oscSr/{accountSummaryId}` | SPA-ORANGE; summary `id`, not `registryId`; encoded filter `{hierarchy:"single",status:"open"}`, creationDate descending, pages of 40 using totalResults/count/offset |
|
||||||
|
| 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 |
|
||||||
|
| requestConsumptionPlanDetail | GET `provider-proxy/g2m-consumption/service/v1/plan/plandetail/{subPlanNum}?active=true` | Raw OCICONS-TS in G2m-Authorization; array of lines with optional nested clines; no retry or persisted cache |
|
||||||
|
| requestRatecardByPlan | POST `provider-proxy/g2m-consumption/service/v1/ratecard/byPlan` | `{planNumber,recordsSize:10000}`, where planNumber is the selected plan's subPlanNum; raw OCICONS-TS in fixed G2m-Authorization; response must be an array of records; no retries, cursor pagination or persistent cache |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
After all opportunity pages arrive, the ready list is displayed before background
|
||||||
|
SR and product loading start. Completed product cycles use successful/reused SR
|
||||||
|
results before Workload Actions. Cached products can still require activity
|
||||||
|
network reads on cache miss. Failed SR/Action lookups remain unknown rather than
|
||||||
|
confirmed absent. Actions currently fetch only their first page and disclose
|
||||||
|
`hasNext`; contact opportunities and account workloads consolidate all pages.
|
||||||
|
|
||||||
|
Contact search uses Fusion `searchResources/latest` POST with a customer PartyId
|
||||||
|
criterion, keywords, Preference transient and the general Bearer. Contact
|
||||||
|
opportunities use the person's opaque PartyNumber in the GET filter, including
|
||||||
|
alphanumeric identifiers. Contact creation uses the selected customer's account
|
||||||
|
identifiers and an explicit Save; local hierarchy persistence is independent.
|
||||||
|
|
||||||
|
## Message Contracts
|
||||||
|
|
||||||
|
The examples below have the `opportunitiesExtension.` prefix; the complete name
|
||||||
|
inventory is in `src/shared/messages.js`. 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/installBase/customerServiceRequests, debugRequests |
|
||||||
|
| requestCustomerServiceRequest | partyNumber, authorizations.accountSummary/customerServiceRequests | ok, items (consolidated response.objects), debugRequests; error envelope on failure |
|
||||||
|
| requestAccountPlans | partyNumber, authorizations | ok, accountPlanId, plans, debugRequests |
|
||||||
|
| requestConsumptionTrend | subPlanNum, period, authorization | ok, subPlanNum, period, items, debugRequests |
|
||||||
|
| requestConsumptionPlanDetail | subPlanNum, authorization | ok, subPlanNum, items (including clines), debugRequests; error envelope on failure |
|
||||||
|
| requestRatecardByPlan | planNumber, authorization | ok, planNumber, items, debugRequests; error envelope on failure |
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| Page localStorage: `opportunities-extension-table-sort-v1-<table>` | Sort key and direction, independently per supported table; memory fallback if storage is unavailable |
|
||||||
|
| Page localStorage: `opportunities-extension-manage-time-reference-date` | Valid selected date; falls back to today |
|
||||||
|
| Page localStorage: `opportunities-extension-debug:<tabId>:<id>` and `:index` | Full captured request records and index; may retain sensitive originals; quota failures fall back to memory |
|
||||||
|
| Page sessionStorage: `opportunities-extension-debug-tab` | Namespace identifier for debug records |
|
||||||
|
| Page sessionStorage: `opportunitiesExtension.shortcutIntent` | Early bookmark timestamp; consumed once and expires after ten minutes |
|
||||||
|
| Extension storage.local: `opportunitiesExtension.opptyProductsCache.v1` | Object indexed by OptyNumber, each entry `{fetchedAt,items}` |
|
||||||
|
| Extension storage.local: `opportunitiesExtension.workloadsCache.v1` | Workload data by normalized principal/customer; `{fetchedAt,items}`; no persistent entry without a principal |
|
||||||
|
| Extension storage.local: `opportunitiesExtension.activities.v1:<scope>` | SR/Action records scoped by principal, kind and identifier; SR keys also include product-update timestamp |
|
||||||
|
| Extension storage.local: `opportunitiesExtension.activities.invalidatedAt.v1` | Invalidation timestamp preventing obsolete activity snapshots from being reused or rewritten |
|
||||||
|
| Extension storage.session: `opportunities-extension-current-user:<context>` | Validated `{id,name,roles}`, partitioned by cookie store/private context; no identity token in this cache |
|
||||||
|
| Extension storage.session: `opportunitiesExtension.resourceUsers.v1` | Resource user, email and fetchedAt; shared concurrent reads and TTL validation |
|
||||||
|
| Extension storage.session: `opportunitiesExtension.srAppBuilderClientId.v1` | Observed integration origin/client ID/timestamp; one-hour validity |
|
||||||
|
| Extension-owned IndexedDB: `opportunities-extension-organizations`, store `charts` | Database version 3; user/customer key, record schemaVersion 1, revision, updatedAt and normalized nodes |
|
||||||
|
| Page memory: form request cache | Allowlisted read results, shared in-flight promises, maximum 500 entries, scoped to current user |
|
||||||
|
| Page memory: active views and authentication | Working charts, transient contact email, week drafts, opportunity results, token promises and service authorization values |
|
||||||
|
|
||||||
|
Product TTL defaults to 24 hours and is capped at 30 days by the background cache.
|
||||||
|
Activity TTL uses the Preferences duration, also capped at 30 days, anchored to
|
||||||
|
the product-update timestamp rather than response time. SR snapshots preserve
|
||||||
|
different update timestamps; expired SR snapshots are pruned on subsequent writes.
|
||||||
|
The form request cache defaults to 24 hours and caps its duration at seven days;
|
||||||
|
it passes that duration to supported operations, including workload/resource reads.
|
||||||
|
Workload reuse preserves the original timestamp instead of restarting the TTL.
|
||||||
|
|
||||||
|
The product cache is keyed by opportunity number, not user or container. Do not
|
||||||
|
extend the stronger principal/customer scoping of workload and activity caches to
|
||||||
|
that cache by assumption. Identity caching has browser-session lifetime and its
|
||||||
|
own cookie-store/private-context partition, with memory fallback where extension
|
||||||
|
session storage is unavailable. Browser storage APIs are distinct from page storage.
|
||||||
|
|
||||||
|
Form cache reuse excludes writes and token generation. Successful supported writes
|
||||||
|
invalidate reusable form reads; concurrent reads share promises and suppress
|
||||||
|
duplicate debug envelopes. Refresh all invalidates related product, workload,
|
||||||
|
activity and form data before reloading Reference, while preserving preferences
|
||||||
|
and authentication. Generation checks prevent old reads from restoring invalidated
|
||||||
|
snapshots. Invalidation failure is surfaced rather than silently accepted.
|
||||||
|
|
||||||
|
Organization-chart nodes retain id, parentId, name, jobTitle, notes, collapsed,
|
||||||
|
optional contactPartyId and alphanumeric PartyNumber. Email is excluded; older
|
||||||
|
stored email fields are removed during migration. Chart storage is isolated by
|
||||||
|
normalized user/customer and browser profile, enforces revision checks, and has no
|
||||||
|
cross-device sync. Clearing a Fusion page's IndexedDB does not clear the extension's
|
||||||
|
IndexedDB, and an already-open working chart can still exist in page memory.
|
||||||
|
|
||||||
|
Storage failures have feature-specific behavior: preferences and data caches can
|
||||||
|
fall back to current memory/results, debug keeps records in memory with feedback,
|
||||||
|
and chart persistence reports failure rather than claiming a durable save.
|
||||||
|
Recreating the background loses in-flight jobs while restoring supported stored
|
||||||
|
data on demand. Expiration does not imply that every cache physically deletes its
|
||||||
|
old records immediately.
|
||||||
|
|
||||||
|
## UI Contracts
|
||||||
|
|
||||||
|
### Debug lifecycle
|
||||||
|
|
||||||
|
`features/debug/state.js` stores each changed request under the current tab's
|
||||||
|
localStorage prefix and maintains an index, batching writes with a short timer.
|
||||||
|
Opening Debug Requests reloads saved records while retaining handles used by
|
||||||
|
in-flight requests. Hidden debug does not construct request-detail DOM; opening
|
||||||
|
renders summaries and expanding a record mounts details. Storage exceptions keep
|
||||||
|
the memory records usable and do not block the recorded network operation.
|
||||||
|
|
||||||
|
`services/fusion/opportunities.js` resets debug immediately before sending page
|
||||||
|
one, after resolving the saved search. Thus earlier authentication and saved-search
|
||||||
|
logs can disappear at that boundary. Pagination does not reset history, and closing
|
||||||
|
the main modal does not delete it. Reset clears only the current tab prefix and
|
||||||
|
invalidates previous-generation completions, preserving preferences and other tabs.
|
||||||
|
|
||||||
|
The default UI masks sensitive values and the explicit visibility toggle can show
|
||||||
|
retained originals, including tokens in captured responses. Individual transports
|
||||||
|
also mask selected headers, but that is not a universal redaction guarantee.
|
||||||
|
localStorage records can contain sensitive values; display masking is neither
|
||||||
|
encryption nor protection from other code with access to that page's storage.
|
||||||
|
The normal identity cache excludes its token, which does not imply the debug
|
||||||
|
response excludes it. This documentation change does not alter retention policy.
|
||||||
|
|
||||||
|
### Shared interactions
|
||||||
|
|
||||||
|
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; the active
|
||||||
|
Reference choices use supported fiscal/date ranges rather than a RENEWALS view.
|
||||||
|
|
||||||
|
Preserve SVG chart data normalization, animation, tooltips and series selection.
|
||||||
|
Treat displayed API text as data. Debug masking and DOM isolation have limitations;
|
||||||
|
do not equate display masking with full redaction or encryption at rest.
|
||||||
|
|
||||||
|
## 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,
|
||||||
|
date/debug utilities and the organization-chart model can be 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`, `current-user.js`, `token-relay.js`, `spa-credentials.js`, `sr-request-headers.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 |
|
||||||
|
| org charts and contacts | `features/org-chart/`, `services/fusion/account-contact.js` | Local hierarchy/storage vs Oracle contact operations |
|
||||||
|
| Manage Time | `features/manage-time/`, `services/fusion/time-entries.js` | Weekly UI drafts vs session-authenticated reads/writes |
|
||||||
|
| 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 restores supported product, identity and session metadata.
|
||||||
|
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. Identify the affected OpenSpec capabilities and propose the behavior delta,
|
||||||
|
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. 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.
|
||||||
65
docs/browser-smoke.md
Normal file
65
docs/browser-smoke.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 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.7; 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 |
|
||||||
|
| Organization charts | Icon after customer SR action; add/report/reparent/edit/notes/collapse; Save chart then reopen from another opportunity for the same customer; reload page and restart browser retains chart; theme and small viewport remain usable |
|
||||||
|
| Organization chart conflicts | Two tabs open the same chart; save one, then save stale second tab; second save reports a conflict and retains local edits rather than overwriting the first |
|
||||||
|
| 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.
|
||||||
0
openspec/changes/archive/.gitkeep
Normal file
0
openspec/changes/archive/.gitkeep
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-31
|
||||||
|
skip_specs: true
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
See [proposal.md](proposal.md) for motivation. The current config selects `spec-driven` and contains commented examples only. README documents workflow and usage; `docs/architecture.md` documents runtime boundaries; the twelve specs are the reviewed behavioral baseline; RELEASE_NOTES records releases.
|
||||||
|
|
||||||
|
Read-only inspection covered those documents, `package.json`, `scripts/build.mjs`, `scripts/test.cjs` and the installed OpenSpec configuration parser. The installed CLI accepts `context` as a string, `rules` as artifact-keyed arrays of strings and `operations.apply/archive.guidance` as arrays of strings. No custom schema, store or dependency is necessary. Design is included to make the exact configuration and its cross-workflow effects reviewable before apply.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:** Make durable conventions available to future planning and implementation; separate shared context, artifact rules and advisory execution guidance; keep the candidate valid for the installed CLI.
|
||||||
|
|
||||||
|
**Non-Goals:** Turn config into a second README, freeze current endpoint URLs or module inventories, introduce executable policy enforcement, or make archive mean release. No main specification needs modification.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Keep the standard schema and local root
|
||||||
|
|
||||||
|
Retain `schema: spec-driven`. Do not add a `store`, custom workflow or schema override: the existing local workflow already supports proposal/design/tasks and explicit spec skipping for tooling-only changes.
|
||||||
|
|
||||||
|
### Keep only stable constraints in shared context
|
||||||
|
|
||||||
|
Use a short context with documentation references, English output conventions, browser compatibility and the user's release expectations. Detailed APIs, role gates, storage keys, module layouts, test counts and URLs stay in the existing documents. This avoids a copied baseline drifting independently.
|
||||||
|
|
||||||
|
English artifacts and UI do not require English conversation: communication can continue in Portuguese. The Firefox-only XPI expectation applies when a release is requested; it does not drop Chromium/Edge support or authorize a release during ordinary work.
|
||||||
|
|
||||||
|
### Separate artifact guidance from operation guidance
|
||||||
|
|
||||||
|
`rules` describe the content of proposal, design, specs and tasks. `operations` hold apply/archive advice and do not override explicit choices, skill steps or CLI checks. Hardcoding developer-machine paths or adding redundant approval gates was considered and rejected.
|
||||||
|
|
||||||
|
### Proposed replacement for openspec/config.yaml
|
||||||
|
|
||||||
|
This block is the reviewed candidate, not an applied configuration. Replace the config with it only during apply.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
schema: spec-driven
|
||||||
|
|
||||||
|
context: |
|
||||||
|
Opportunities Extension supports Oracle Fusion workflows in Chromium/Edge and Firefox.
|
||||||
|
Write OpenSpec artifacts and extension-authored UI text in English; preserve service-provided names.
|
||||||
|
Use openspec/specs/ for behavioral contracts, docs/architecture.md for execution and storage
|
||||||
|
boundaries, README.md for setup and usage, and RELEASE_NOTES.md for release history.
|
||||||
|
Preserve both browser targets and the existing extension design system unless the change
|
||||||
|
explicitly proposes a compatibility or presentation change.
|
||||||
|
When a release is requested, include a valid Firefox XPI; XPI packaging is Firefox-only.
|
||||||
|
Keep the README main title as Opportunities Extension and release history in RELEASE_NOTES.md.
|
||||||
|
OpenSpec archival and product release are separate operations.
|
||||||
|
|
||||||
|
rules:
|
||||||
|
proposal:
|
||||||
|
- Identify affected existing capabilities and distinguish observed behavior from proposed changes.
|
||||||
|
- State scope, non-goals and compatibility impact; do not introduce product requirements for tooling-only changes.
|
||||||
|
design:
|
||||||
|
- Reference the relevant architecture contracts instead of duplicating their inventories.
|
||||||
|
- For affected integrations, describe request ordering, credentials, pagination, retries and stale-response handling.
|
||||||
|
- For affected persistence, describe ownership, identity scope, invalidation and migration or rollback needs.
|
||||||
|
- For UI changes, identify shared components and relevant loading, empty, failure and theme behavior.
|
||||||
|
specs:
|
||||||
|
- Describe observable behavior with testable scenarios; keep implementation steps and module wiring in design and tasks.
|
||||||
|
- Use synthetic identifiers and examples; never embed real tokens, cookies or personal contact data.
|
||||||
|
tasks:
|
||||||
|
- Tie each task to a concrete deliverable and include verification proportional to the affected behavior.
|
||||||
|
- For source changes, include relevant synthetic tests, regeneration of affected browser outputs and a build consistency check.
|
||||||
|
- Record installed-browser and live Oracle checks separately from synthetic tests, including checks not performed.
|
||||||
|
- Include version, release notes and Firefox XPI work only when release delivery is part of the requested scope.
|
||||||
|
|
||||||
|
operations:
|
||||||
|
apply:
|
||||||
|
guidance:
|
||||||
|
- Edit source modules and regenerate distributions through the build script; do not hand-edit generated bundles.
|
||||||
|
- Use synthetic data for automated checks and avoid incidental writes to live Oracle services during validation.
|
||||||
|
- Run focused checks for the change; use node scripts/test.cjs for integration verification of runtime changes and node scripts/build.mjs --check for generated-output consistency.
|
||||||
|
- Report what changed, verification results and material limitations without claiming unperformed browser or live-service validation.
|
||||||
|
archive:
|
||||||
|
guidance:
|
||||||
|
- Check documentation links when moving a change and preserve the distinction between archived evidence and current specifications.
|
||||||
|
- Summarize task completion, specification synchronization and the final archive location; do not treat archival as a product release.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation approach
|
||||||
|
|
||||||
|
Parse the candidate as YAML and validate it with the installed OpenSpec configuration schema before apply. The context must be a nonempty UTF-8 string below 51,200 bytes; all artifact rule and operation guidance entries must be nonempty strings. Use only supported artifact and operation identifiers.
|
||||||
|
|
||||||
|
After apply, inspect `openspec context --json` and `openspec instructions proposal/design/tasks/apply/archive --change configure-project-context --json` as separate calls. Confirm context is delivered, artifact rules occur only on their matching artifact, and apply/archive advice is exposed as `operationGuidance`. For specs rules, inspect the parsed configuration rather than creating fictitious delta files or removing `skip_specs` just to probe a skipped artifact.
|
||||||
|
|
||||||
|
Run `openspec validate configure-project-context --strict`, `openspec validate --specs --strict` and `git diff --check`. A config-only change does not justify rebuilding distributions or running all runtime tests. Preserve baseline runtime verification as historical evidence, not a new test claim.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Context becomes stale] -> Keep it short and reference existing authoritative files; omit current version, endpoint lists, absolute paths and test totals.
|
||||||
|
- [Guidance is mistaken for enforcement] -> Explain that config is prompt-level guidance; it does not guarantee tests, safe storage or API authorization.
|
||||||
|
- [New rules overconstrain unrelated work] -> Scope design rules to affected integrations/persistence/UI and verification to the change's impact.
|
||||||
|
- [A future CLI changes supported fields] -> Validate parsing and actual instruction delivery with the installed CLI when applying or upgrading.
|
||||||
|
- [Release expectations are misread as authorization] -> Keep release tasks conditional and explicitly separate archive from release.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
During apply, replace only `openspec/config.yaml` with the candidate, verify configuration consumption and complete the tracked tasks. Existing specs, archived artifacts and runtime data need no migration. Future instruction reads receive the new guidance; existing artifacts are not rewritten automatically.
|
||||||
|
|
||||||
|
Rollback restores the prior config from the implementation diff without deleting changes or specs. No runtime deployment, data migration or browser reinstallation is required.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The behavioral baseline is now archived, but OpenSpec configuration still contains only the default schema and commented examples. Future changes need concise, durable project constraints and artifact guidance without repeatedly reconstructing decisions from conversation history or duplicating the twelve specifications.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Keep `schema: spec-driven` and the repository-local planning root.
|
||||||
|
- Add a short English `context` capturing documentation ownership, English UI/artifacts, browser compatibility, established presentation conventions and the Firefox XPI release expectation.
|
||||||
|
- Add focused `rules` for proposal, design, specs and tasks; keep implementation guidance in the supported `operations.apply` and `operations.archive` sections.
|
||||||
|
- Provide the exact proposed YAML in the design for review before replacing `openspec/config.yaml` during apply.
|
||||||
|
- Validate configuration parsing and instruction delivery without changing extension behavior.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
None. This change configures development tooling rather than introducing product behavior.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
None. The twelve existing capabilities remain unchanged. This tooling-only change declares `skip_specs: true`; no delta specification is required or generated.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Implementation target: `openspec/config.yaml` only, plus this change's completion records.
|
||||||
|
- OpenSpec agents receive project context, per-artifact rules and advisory apply/archive guidance.
|
||||||
|
- No source, browser permissions, API contracts, storage data, generated distributions, dependencies, version numbers or release history change.
|
||||||
|
- Out of scope: new workflows/schemas, external stores, runtime changes, releasing a version, generating an XPI now, or automatically applying/archiving future changes.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Apply the reviewed configuration
|
||||||
|
|
||||||
|
- [x] 1.1 Replace `openspec/config.yaml` with the YAML candidate in design.md, preserving `schema: spec-driven`; verify its parsed values match the candidate, context is below 51,200 UTF-8 bytes, rules and guidance are nonempty string arrays, and only supported artifact/operation keys are used. Record the configuration validation result in the change.
|
||||||
|
- [x] 1.2 Confirm instruction delivery using `openspec context --json` and separate `openspec instructions` calls for proposal, design, tasks, apply and archive on this change; verify the root is unchanged, context is present, artifact rules match their artifact and operation advice is separate. Verify specs rules in parsed config without creating delta files for this `skip_specs` change; record results and the advisory nature of config guidance.
|
||||||
|
|
||||||
|
## 2. Verify scope and integration
|
||||||
|
|
||||||
|
- [x] 2.1 Run `openspec validate configure-project-context --strict` and `openspec validate --specs --strict`; verify the change is valid with no deltas and all twelve existing capabilities remain valid.
|
||||||
|
- [x] 2.2 Run `git diff --check` and compare the final changes against the apply starting state; confirm only config and this change's completion records changed, no machine-specific paths or live credentials were introduced, and no runtime tests/build/browser checks are claimed as performed for this config-only update.
|
||||||
|
|
||||||
|
## Workflow follow-up
|
||||||
|
|
||||||
|
- Archive only after application and review; this change has no delta specs to synchronize and does not constitute a release.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Application Verification
|
||||||
|
|
||||||
|
- Configuration parsed with the installed OpenSpec YAML parser and ProjectConfigSchema. The applied values match the design candidate exactly.
|
||||||
|
- Schema remains spec-driven; context is 762 UTF-8 bytes. All four artifact rule groups and both operation guidance groups contain nonempty strings and supported keys.
|
||||||
|
- CLI context and separate proposal/design/tasks/apply/archive instruction lookups passed. Root is unchanged; context matches after the artifact loader's documented trim normalization; artifact rules match their artifact and operationGuidance remains separate. Specs rules were checked in parsed config; no delta files were created.
|
||||||
|
- Configuration advice is prompt-level guidance, not executable enforcement. Source regeneration and runtime test guidance are inapplicable to this configuration-only edit; no source or generated output was changed. No Oracle requests were sent.
|
||||||
|
- Strict validation passed for configure-project-context with zero deltas (skip_specs), and for all twelve existing main specifications.
|
||||||
|
- Final scope review and git diff --check passed. Relative to the initial clean tracked state plus the preexisting change directory, edits are confined to openspec/config.yaml, task completion markers and this verification record. No machine-specific paths or live credentials were added to config. Runtime tests, builds, installed-browser checks and live-service checks were not run because no runtime behavior or generated output changed.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-31
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
See proposal.md for motivation and the capability inventory. This is the initial specification baseline for the current checkout, whose package/build version is 1.1.0. The checkout includes behavior added after the 1.1.0 release; the baseline must not be presented as an exact description of that tag.
|
||||||
|
|
||||||
|
OpenSpec uses the existing `spec-driven` schema. Its main specification directory is empty. The change introduces behavioral contracts, not runtime features. A design is warranted because reconciling documentation spans authentication, data loading, storage and UI boundaries and requires explicit decisions about conflicting historical descriptions.
|
||||||
|
|
||||||
|
The application uses factory-based JavaScript modules. `src/content/application.js` and `src/background/application.js` compose collaborators; `scripts/sources.cjs` and `scripts/compose.cjs` assemble isolated classic scripts. `scripts/build.mjs` generates Manifest V3 distributions for Chromium/Edge and Firefox with different background entry points. No framework migration is involved.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Preserve externally observable behavior in requirements with testable scenarios.
|
||||||
|
- Make source/test evidence discoverable without putting internal function names into normative requirements.
|
||||||
|
- Reconcile README and architecture descriptions with current behavior during application.
|
||||||
|
- Establish the distinction between a proposed baseline in this change and durable specifications promoted after review and completion.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Fix unrelated runtime defects, add retries, migrate storage, alter permissions or change endpoints.
|
||||||
|
- Declare every scenario already covered by automated or live-browser tests.
|
||||||
|
- Copy the full README into configuration, customize the OpenSpec schema, or add machine-specific executable paths as project requirements.
|
||||||
|
- Change release history, regenerate packages for documentation edits, or publish a version.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Bootstrap existing capabilities explicitly
|
||||||
|
|
||||||
|
The specs use `ADDED Requirements` because the OpenSpec inventory is empty. This means new specification coverage, not newly implemented product behavior. Each spec has its own Purpose and cohesive domain boundary. A documentation-only `skip_specs` change would not fulfill the user's request to establish functional artifacts, while a single all-project spec would make future deltas difficult to review.
|
||||||
|
|
||||||
|
### 2. Resolve documentation conflicts against observable implementation
|
||||||
|
|
||||||
|
Use current source plus relevant regression scenarios to determine the baseline. Historical release notes explain evolution; they do not override the current implementation. Tests are supporting evidence, not proof of live Oracle compatibility. Unverified aspirations must not be silently converted into requirements or implemented during baseline application.
|
||||||
|
|
||||||
|
### 3. Preserve runtime and storage distinctions
|
||||||
|
|
||||||
|
Document content/background messaging and browser API adaptation without changing them. Distinguish page localStorage, sessionStorage, extension storage, in-memory caches and extension-owned IndexedDB. In particular, debug display masking does not guarantee that stored records exclude credentials. A blanket statement that all production credentials remain only in memory would contradict the retained debug response behavior.
|
||||||
|
|
||||||
|
The principal flow to describe is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
identity and session preparation
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Token Relay --> Opportunity saved search --> opportunity pages
|
||||||
|
|
|
||||||
|
v
|
||||||
|
display ready list
|
||||||
|
|
|
||||||
|
+-------------------------+------------------+
|
||||||
|
v v
|
||||||
|
CRM SR background pages product enrichment
|
||||||
|
| |
|
||||||
|
+-------- SR success/reuse + products -------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Workload Actions
|
||||||
|
```
|
||||||
|
|
||||||
|
Workloads additionally depend on account/product identifiers. This diagram does not prescribe a new global request scheduler. Profile gates apply to the data operations they control; concurrent session bootstrap is not itself a Manage Time data request.
|
||||||
|
|
||||||
|
### 4. Keep documentation responsibilities separate
|
||||||
|
|
||||||
|
- README.md: concise product overview, supported entry points, major features, development/build/test/loading instructions and links to deeper documentation.
|
||||||
|
- docs/architecture.md: execution contexts, transport/credential boundaries, storage ownership, actual permissions and verification limits.
|
||||||
|
- RELEASE_NOTES.md: historical release record; preserve prior descriptions as history rather than rewriting old releases to match today.
|
||||||
|
- `openspec/specs/`: durable behavioral contracts after the baseline is reviewed, applied and archived.
|
||||||
|
- This change: proposal, design, initial delta specs and actionable documentation/verification tasks.
|
||||||
|
|
||||||
|
Replacing the README entirely with OpenSpec would make ordinary setup less accessible. Duplicating complete behavioral contracts in both places would make future drift more likely. Link from documentation instead, retaining only the usage detail needed by its audience.
|
||||||
|
|
||||||
|
### 5. Use minimal configuration
|
||||||
|
|
||||||
|
Retain `openspec/config.yaml` as initialized. Discoverable code structure and local CLI locations do not need to become project context rules. All artifacts in this change are English to match the extension documentation; discussion with the user remains Portuguese. Any later configuration customization is a separate explicit decision.
|
||||||
|
|
||||||
|
## Evidence Map
|
||||||
|
|
||||||
|
Paths below are relative to the repository root. These identify where to review behavior, not a claim that every specification scenario has already been executed.
|
||||||
|
|
||||||
|
| Capability | Primary source evidence | Existing regression evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| session-authentication | `src/auth/current-user.js`, `src/auth/consumer-session.js`, `src/auth/spa-page.js`, `src/background/contracts.js`, `src/content/application.js` | `tests/currentUser.test.cjs`, `tests/spaSessionWait.test.cjs`, `tests/spaSessionReload.test.cjs`, `tests/srSessionPreparation.test.cjs` |
|
||||||
|
| opportunity-exploration | `src/services/fusion/opportunities.js`, `src/features/opportunities/controller.js`, `filters.js`, `table.js`, `fiscal-periods.js` | `tests/dependentFilters.test.cjs`, `tests/allOpportunityFilters.test.cjs`, `tests/opportunityTable.test.cjs`, `tests/dashboardTotals.test.cjs`, `tests/characterization.test.cjs` |
|
||||||
|
| opportunity-calendar | `src/features/opportunities/calendar.js`, `src/features/opportunities/styles.css` | `tests/opportunityCalendar.test.cjs` plus installed-browser visual inspection for week positioning |
|
||||||
|
| product-workloads | `src/features/products/details.js`, `workloads.js`, `workloads-cache.js`, `ramp-projection.js`, `forecast-update.js` | `tests/workloads.test.cjs`, `tests/ramp-projection.test.cjs`, `tests/workload-forecast-update.test.cjs`, `tests/badgeIcons.test.cjs` |
|
||||||
|
| service-requests | `src/services/fusion/crm-service-requests.js`, `src/features/opportunities/crm-service-requests.js`, `crm-sr-form.js`, `internal-sr-session.js`, `internal-sr-form.js`, `src/features/service-requests/` | `tests/srRequestPriority.test.cjs`, `tests/crmServiceRequests.test.cjs`, `tests/activityProgress.test.cjs`, `tests/internalSrRecovery.test.cjs`, `tests/srCreate.test.cjs`, `tests/serviceRequests.test.cjs` |
|
||||||
|
| workload-actions | `src/features/opportunities/product-activities.js`, `workload-actions.js`, `workload-action-form.js` | `tests/productActivities.test.cjs`, `tests/workloadActions.test.cjs`, `tests/workloadActionForm.test.cjs` |
|
||||||
|
| manage-time | `src/features/manage-time/view.js`, `src/services/fusion/time-entries.js`, `src/content/application.js` | `tests/manageTime.test.cjs`, `tests/currentUser.test.cjs` |
|
||||||
|
| customer-insights | `src/features/install-base/`, `src/features/consumption/`, `src/features/ratecard/`, `src/services/spa/` | `tests/installBase.test.cjs`, `tests/consumptionTrend.test.cjs`, `tests/consumptionPlanDetail.test.cjs`, `tests/ratecard.test.cjs` |
|
||||||
|
| organization-charts | `src/features/org-chart/model.js`, `storage.js`, `view.js`, `src/services/fusion/account-contact.js` | `tests/orgChart.test.cjs`, `tests/accountContact.test.cjs` |
|
||||||
|
| extension-shell | `src/content/fusion-page.js`, `shortcut-intent.js`, `modal.js`, `dialog-drag.js`, `src/features/opportunities/print.js` | `tests/shortcutLink.test.cjs`, `tests/bookmarkShortcut.test.cjs`, `tests/bookmarkHint.test.cjs`, `tests/modalShortcuts.test.cjs`, `tests/dialogDrag.test.cjs` |
|
||||||
|
| preferences-cache | `src/features/preferences/`, `src/features/opportunities/activity-cache.js`, `refresh-all.js`, `src/features/products/cache.js` | `tests/tableSorting.test.cjs`, `tests/activityCache.test.cjs`, `tests/formRequestCache.test.cjs`, `tests/refreshAll.test.cjs` |
|
||||||
|
| request-debugging | `src/features/debug/state.js`, `content-log.js`, `content-view.js`, `src/services/fusion/opportunities.js` | `tests/debugStorage.test.cjs`, `tests/debugTabs.test.cjs`, `tests/productsDebug.test.cjs` |
|
||||||
|
|
||||||
|
## Documentation Reconciliation
|
||||||
|
|
||||||
|
During application, make the following concrete corrections, verifying the cited implementation again before editing:
|
||||||
|
|
||||||
|
| Document/topic | Required reconciliation |
|
||||||
|
| --- | --- |
|
||||||
|
| README Opportunity Views | Replace month/week navigation and overflow links with Reference-bounded scrolling months, all daily cards and current-week positioning. |
|
||||||
|
| README and architecture AI references | Remove descriptions of active AI summaries, Sales Common token acquisition and AI suites/modules. Preserve historical release notes. |
|
||||||
|
| README authentication | Use the current SPA UI bootstrap URL; distinguish successful identity reuse from session recovery attempts. |
|
||||||
|
| README SR/Actions ordering | Describe opportunities displayed first, progressive SR enrichment and Actions depending on product identifiers and SR success/reuse. Remove the assertion that SR must wait for product completion. |
|
||||||
|
| README activity cache | Reconcile claims that cached products never allow activity network requests: the current product-activity orchestration permits cache-miss lookups. Retain timestamp/principal cache scoping where implemented. |
|
||||||
|
| README access restrictions | Include Manage Time UI and data gates for SALES_REP. Do not claim that all independent SR session bootstrap is disabled by that role. |
|
||||||
|
| README Organization Charts | Include alphanumeric PartyNumber, on-demand contact refresh, transient email, contact creation and fully paginated contact opportunities. |
|
||||||
|
| README capabilities | Cover dependent Customer/Owner options, Booking/Workload filters, printing, bookmark shortcuts/hints and draggable headers. |
|
||||||
|
| README Rate Card | Document Order Number multiselect and inspect final composed styles before describing responsive width; a single earlier CSS min-width rule is not proof of the effective layout. |
|
||||||
|
| Architecture contexts/permissions | Reflect the early shortcut-intent script, SPA readiness script and generated manifest permissions/hosts from `scripts/build.mjs`, including bookmarks and SR integration access. |
|
||||||
|
| Architecture request lifecycle | Remove the blanket claim that no AbortController is used; distinguish Token Relay timeouts from stale-response suppression and requests continuing behind a hidden modal. |
|
||||||
|
| Architecture storage | Describe debug localStorage and its reset boundary, preferences/sort storage, activity/product/workload caching, identity/session reuse and org-chart IndexedDB. Distinguish UI masking from storage confidentiality. |
|
||||||
|
| Architecture legacy contracts | Remove active RENEWALS and AI contracts; explain dynamic saved-search selection for Opportunity and CRM SR independently. |
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [A baseline can accidentally promise unimplemented behavior] -> Trace each requirement to source and scenarios; raise discrepancies rather than adding runtime fixes under this documentation change.
|
||||||
|
- [Documentation already contains contradictory cache and session claims] -> Review the composition root and effective call paths, not just an isolated helper or old prose.
|
||||||
|
- [Synthetic tests cannot verify Oracle endpoint availability or browser SSO] -> Record automated validation separately from installed-browser/live-service verification; never label the latter passed without execution.
|
||||||
|
- [Debug records can contain sensitive values] -> Describe the current behavior accurately without embedding real tokens, customer responses or personal data in artifacts. Any storage-hardening change is separate work.
|
||||||
|
- [Broad baseline coverage can obscure ownership] -> Keep twelve cohesive capability files and a source/test evidence map; do not duplicate implementation details in requirements.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Review the proposed contracts against the current checkout and resolve documentation discrepancies without changing runtime behavior.
|
||||||
|
2. Apply the README and architecture corrections; keep the existing principal README title and separate release history.
|
||||||
|
3. Validate the OpenSpec change strictly, verify build consistency and run the relevant existing synthetic checks. Record any failures or browser-verification gaps honestly.
|
||||||
|
4. Complete the documentation tasks, then use the archive workflow to promote the initial specs to `openspec/specs/`. Do not manually copy them early or mark the change applied merely because planning is complete.
|
||||||
|
5. For subsequent behavior changes, identify the affected baseline capability, create delta requirements, implement and verify, then archive.
|
||||||
|
|
||||||
|
No runtime deployment or data migration is required. Before archival, rollback consists of reverting the documentation edits and removing the proposed change through an explicit repository operation. After archival, restore/revise documentation and specifications together through a follow-up change; never delete user data or rewrite release history as part of this baseline.
|
||||||
|
|
||||||
|
## Application Review Record
|
||||||
|
|
||||||
|
Reviewed on 2026-10-31 against the current checkout:
|
||||||
|
|
||||||
|
- All twelve proposed capabilities have a matching specification file. Structural review found 69 requirements and 88 scenarios; every requirement has a scenario with a trigger and outcome.
|
||||||
|
- README.md and docs/architecture.md now distinguish opportunity-first loading, background SR progress, product-dependent Actions and cache-miss activity reads from the superseded loading descriptions.
|
||||||
|
- The documentation distinguishes page storage, extension storage and chart IndexedDB; it explicitly states that debug masking does not remove retained originals from storage.
|
||||||
|
- Review confirmed the current SPA bootstrap, role gates, dynamic saved searches, contact identifiers and composed Rate Card styles without changing their implementation. No additional runtime behavior was introduced to satisfy the baseline.
|
||||||
|
- Task-specific synthetic suites passed as their documentation groups were applied. Local documentation links resolve.
|
||||||
|
- No installed-browser visual test or live Oracle authentication/read/write was performed. Calendar sizing, responsive layouts, dragging, print pagination and live endpoint availability retain that verification limitation; synthetic scenario coverage is not a claim of end-to-end certification.
|
||||||
|
|
||||||
|
Final application checks on 2026-10-31:
|
||||||
|
|
||||||
|
- `openspec validate document-project-baseline --strict`: passed.
|
||||||
|
- `node scripts/build.mjs --check`: passed; existing Chromium/Edge and Firefox outputs match the source.
|
||||||
|
- `node scripts/test.cjs` using Node 22.15.0: 54 test files, 462 tests passed, zero failed.
|
||||||
|
- `git diff --check` and a whitespace scan of the new OpenSpec Markdown files: passed.
|
||||||
|
- The tracked diff contains only README.md and docs/architecture.md. The change's task/review records are updated in the already-created OpenSpec tree; the preexisting untracked `.agents/` installation is untouched.
|
||||||
|
- Source, tests, build scripts, distributions, package version, release history and OpenSpec configuration are unchanged. The change is applied but not archived; the main spec inventory remains unpopulated until the separate archive workflow.
|
||||||
|
|
||||||
|
## Archive Record
|
||||||
|
|
||||||
|
On 2026-10-31, after explicit approval to synchronize and archive, all twelve
|
||||||
|
capabilities were promoted to `openspec/specs/`. Their authored Purpose sections,
|
||||||
|
69 requirements and 88 scenarios were preserved; the main files use canonical
|
||||||
|
Specification titles and Requirements sections without delta-operation headers.
|
||||||
|
All twelve main specifications passed `openspec validate --specs --strict`.
|
||||||
|
The completed change (13/13 tasks, schema `spec-driven`) was moved to
|
||||||
|
`openspec/changes/archive/2026-10-31-document-project-baseline/`, including its
|
||||||
|
`.openspec.yaml`. README and architecture navigation now point to the durable
|
||||||
|
specifications and archived evidence. This archive record supersedes the
|
||||||
|
pre-archive inventory status in the application checks above.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
OpenSpec has been initialized without capability specifications, while the README and architecture document still describe several superseded behaviors. Establish an evidence-backed baseline so future changes can state precisely which existing contracts they preserve or modify.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Introduce initial specifications for existing behavior, using the current source and regression tests as evidence. These are new OpenSpec records, not new product features.
|
||||||
|
- Record authentication boundaries, asynchronous loading order, role restrictions, persistence, and user interactions as requirements with observable scenarios.
|
||||||
|
- Plan corrections to README.md and docs/architecture.md, including Calendar, removed AI summaries, SPA navigation, SR loading, Manage Time access, contact identifiers, permissions, and debug storage.
|
||||||
|
- Keep README.md as the product/development entry point and RELEASE_NOTES.md as release history. Link durable behavior to OpenSpec after the baseline is applied and archived.
|
||||||
|
- Distinguish verified code behavior, synthetic test coverage, and live-browser verification still required. Do not treat historical requests or old documentation as implemented behavior.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `session-authentication`: Identity acquisition/reuse, SPA/SR session recovery, credential routing and profile gates.
|
||||||
|
- `opportunity-exploration`: Saved-search queries, pagination, fiscal Reference, coordinated filters, table sorting and indicators.
|
||||||
|
- `opportunity-calendar`: Reference-bounded monthly scrolling, daily cards, fiscal labels and current-week positioning.
|
||||||
|
- `product-workloads`: Product enrichment, workload caching, Booking classification, projections and forecast updates.
|
||||||
|
- `service-requests`: Progressive CRM SR enrichment, saved-search lookup, customer/internal SR details and creation.
|
||||||
|
- `workload-actions`: Ownership badges, dependent loading, action metadata and validated creation.
|
||||||
|
- `manage-time`: Weekly entry viewing/editing, task selection, validation and explicit writes.
|
||||||
|
- `customer-insights`: Installed Base, consumption plans/trends/details and Rate Card exploration.
|
||||||
|
- `organization-charts`: Local hierarchy storage, contact lookup/creation and opportunities by contact.
|
||||||
|
- `extension-shell`: Fusion entry points, bookmarks, keyboard shortcuts, modal interaction and printing.
|
||||||
|
- `preferences-cache`: Persistent preferences, sorting and cache invalidation boundaries.
|
||||||
|
- `request-debugging`: Request capture, lazy rendering, history lifetime and sensitive-value visibility.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
None. The existing OpenSpec specification inventory is empty.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Planning artifacts are confined to `openspec/changes/document-project-baseline/`.
|
||||||
|
- Subsequent application updates README.md and docs/architecture.md; successful archive promotes the reviewed baseline to `openspec/specs/`.
|
||||||
|
- Source, generated distributions, API contracts, browser permissions, dependencies, storage formats and version 1.1.0 remain unchanged by this documentation effort.
|
||||||
|
- No release, tag, commit, live Oracle write, new authentication behavior, or OpenSpec schema/configuration customization is included.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Provide customer-specific Installed Base and consumption exploration without disrupting the main opportunity view.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Installed Base uses the registry identifier
|
||||||
|
Installed Base SHALL first resolve account summary and query using its `registryId` with the SAIB authorization. It SHALL continue pagination until `hasMore=false`, and reject invalid or stalled pages instead of presenting a partial list as complete.
|
||||||
|
|
||||||
|
#### Scenario: More installed items exist
|
||||||
|
- **WHEN** an Installed Base page returns `hasMore=true`
|
||||||
|
- **THEN** subsequent pages are loaded before the complete result is presented
|
||||||
|
|
||||||
|
### Requirement: Installed Base avoids duplicate customer presentation
|
||||||
|
Installed Base SHALL show the customer in the modal header, omit the Customer column, and provide searchable sortable data with loading skeletons.
|
||||||
|
|
||||||
|
#### Scenario: User searches installed items
|
||||||
|
- **WHEN** search text is entered
|
||||||
|
- **THEN** loaded rows and the result count update locally without another account query
|
||||||
|
|
||||||
|
### Requirement: Consumption follows the selected plan
|
||||||
|
Customer consumption SHALL resolve available account plans and load trends for the selected plan with quarterly, monthly, weekly and daily periods. Plan detail loading SHALL be independent of trend loading and failed details SHALL not block the chart.
|
||||||
|
|
||||||
|
#### Scenario: User switches plans
|
||||||
|
- **WHEN** the selected plan changes while an earlier plan request is pending
|
||||||
|
- **THEN** late results from the earlier plan do not replace the selected plan's view
|
||||||
|
|
||||||
|
### Requirement: Projection details preserve safe order navigation
|
||||||
|
Consumption plan details SHALL expose Forfeit and Overage information with nested line details in interactive tooltips. Only HTTPS order links on `accountdashboard.oracle.com` SHALL be clickable.
|
||||||
|
|
||||||
|
#### Scenario: Unsafe order URL
|
||||||
|
- **WHEN** a returned order URL uses a different host or unsupported scheme
|
||||||
|
- **THEN** the detail remains readable without offering that URL as a clickable order link
|
||||||
|
|
||||||
|
### Requirement: Rate Card queries the active plan
|
||||||
|
Rate Card SHALL query using the selected plan's `subPlanNum` and OCICONS authorization, show plan/customer context and provide search, sorting and multi-selection of distinct Order Numbers. The current request SHALL use a 10000-record limit without implying unsupported cursor pagination.
|
||||||
|
|
||||||
|
#### Scenario: Order filter isolates one choice
|
||||||
|
- **WHEN** all order numbers are selected and the user clicks one order
|
||||||
|
- **THEN** only that order remains selected and search continues to combine with the order filter
|
||||||
|
|
||||||
|
#### Scenario: Rate Card closes while loading
|
||||||
|
- **WHEN** the drawer closes before its request completes
|
||||||
|
- **THEN** the late result does not reopen or overwrite a different active plan view
|
||||||
|
|
||||||
|
### Requirement: Customer tables sort and search by meaningful values
|
||||||
|
Customer table search SHALL match case-insensitively and accent-insensitively across displayed values. Sorting SHALL use numeric or date values where applicable, and restore each supported table's saved ordering.
|
||||||
|
|
||||||
|
#### Scenario: Numeric amount ordering
|
||||||
|
- **WHEN** the user sorts a customer table by a monetary column
|
||||||
|
- **THEN** rows are ordered by numeric amount rather than by the formatted text
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Provide predictable entry points, navigation, modal interaction and printing across the supported browser extension targets.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Supported Fusion pages expose the extension
|
||||||
|
The extension SHALL provide its entry tile on the configured Oracle Fusion hcmUI and fscmUI FuseWelcome pages. A bookmarked URL with the extension shortcut marker SHALL open the main modal without requiring a click on the Sales group.
|
||||||
|
|
||||||
|
#### Scenario: Fusion rewrites the URL
|
||||||
|
- **WHEN** the user follows the shortcut URL and Fusion subsequently replaces its query string
|
||||||
|
- **THEN** the captured shortcut intent still permits the extension to open once when ready
|
||||||
|
|
||||||
|
### Requirement: Favorites use an explicit folder choice
|
||||||
|
Ctrl+Shift+F within the extension SHALL open a searchable browser-bookmark folder picker. Saving SHALL use the chosen writable folder and extension shortcut URL rather than silently choosing an arbitrary location.
|
||||||
|
|
||||||
|
#### Scenario: User selects a bookmark folder
|
||||||
|
- **WHEN** the user confirms a writable folder and saves
|
||||||
|
- **THEN** the extension shortcut is saved in that folder
|
||||||
|
|
||||||
|
### Requirement: Bookmark discovery keeps Last update first
|
||||||
|
When the shortcut is not bookmarked, the header SHALL show a prominent shortcut hint for 20 seconds. Last update SHALL remain the first badge and other badges SHALL appear to its right.
|
||||||
|
|
||||||
|
#### Scenario: Favorite is saved while the hint is visible
|
||||||
|
- **WHEN** the shortcut is successfully added to bookmarks
|
||||||
|
- **THEN** the favorite hint is removed without waiting for the remaining timeout
|
||||||
|
|
||||||
|
### Requirement: Non-full-page modals can be dragged by their header
|
||||||
|
Non-full-page modals SHALL support dragging from noninteractive areas of their title bar, allowing up to 70 percent of their width and height beyond viewport edges. Header controls SHALL retain their own interactions and full-page views SHALL not become draggable.
|
||||||
|
|
||||||
|
#### Scenario: Drag exceeds viewport edge
|
||||||
|
- **WHEN** the user drags a movable modal far outside the viewport
|
||||||
|
- **THEN** movement is clamped so at least 30 percent of each modal dimension remains within the allowed bounds
|
||||||
|
|
||||||
|
### Requirement: Shared presentation remains consistent
|
||||||
|
Modal titles, spacing, loading skeletons and table presentation SHALL follow the extension's shared visual patterns in light and dark themes. User-facing labels SHALL use English, with service-provided names preserved.
|
||||||
|
|
||||||
|
#### Scenario: Customer detail is opened while loading
|
||||||
|
- **WHEN** the user opens a customer table before its data arrives
|
||||||
|
- **THEN** its title and loading skeleton use the shared modal styling
|
||||||
|
|
||||||
|
### Requirement: Print output reflects the current opportunity view
|
||||||
|
The print action beside Booking/Workload filters SHALL produce a landscape-oriented browser print layout containing active filter summaries, indicators and the opportunity list with page padding.
|
||||||
|
|
||||||
|
#### Scenario: All customers and owners are selected
|
||||||
|
- **WHEN** the user opens the print layout with unrestricted Customer and Owner selections
|
||||||
|
- **THEN** it labels them `All customers` and `All owners` instead of expanding every selected name
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Let authorized users review and maintain weekly time entries with validated hours and explicit, traceable persistence.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Manage Time requires profile and session readiness
|
||||||
|
Manage Time SHALL remain unavailable to `SALES_REP` users. For eligible users, its launcher SHALL remain disabled until SR session readiness, and data request entry points SHALL enforce the profile gate before loading time data.
|
||||||
|
|
||||||
|
#### Scenario: Restricted profile with a ready session
|
||||||
|
- **WHEN** SR session preparation succeeds for a user with `SALES_REP`
|
||||||
|
- **THEN** Manage Time remains hidden and opening it does not send its data requests
|
||||||
|
|
||||||
|
### Requirement: Week navigation preserves the user's work
|
||||||
|
Manage Time SHALL restore its selected reference date, navigate in seven-day increments and retain local drafts while moving between weeks within the modal. Late responses for a different week or closed modal SHALL not replace active values.
|
||||||
|
|
||||||
|
#### Scenario: Navigate away and back
|
||||||
|
- **WHEN** the user edits an unsaved week, navigates to another week and returns
|
||||||
|
- **THEN** the earlier week's local edits remain available
|
||||||
|
|
||||||
|
### Requirement: Entry selections are dependent and searchable
|
||||||
|
Service Request, Activity and Task Type selections SHALL support autocomplete. Activities SHALL correspond to the chosen SR, task types SHALL match Service or Non Service rows, and duplicate SR selection SHALL be rejected with row feedback.
|
||||||
|
|
||||||
|
#### Scenario: Duplicate SR
|
||||||
|
- **WHEN** a user selects an SR already used by another row
|
||||||
|
- **THEN** the previous selection is restored and the existing row is highlighted
|
||||||
|
|
||||||
|
### Requirement: Hours are validated before persistence
|
||||||
|
Each entered daily hour value SHALL be numeric and within the inclusive range 0 through 24. The view SHALL show daily totals and reject invalid rows before any affected write.
|
||||||
|
|
||||||
|
#### Scenario: Invalid daily hours
|
||||||
|
- **WHEN** a user attempts to save a row containing 25 hours for one day
|
||||||
|
- **THEN** validation prevents that invalid time entry from being submitted
|
||||||
|
|
||||||
|
### Requirement: Save controls creation updates and deletions
|
||||||
|
Save SHALL create new entries with POST, update changed persisted entries with PUT and apply queued persisted deletions with DELETE. Removing an unsaved row SHALL remain local. Writes SHALL use the required session and current-resource context.
|
||||||
|
|
||||||
|
#### Scenario: Persisted row is removed before Save
|
||||||
|
- **WHEN** a user removes a persisted row without saving
|
||||||
|
- **THEN** the deletion remains queued and no DELETE is sent yet
|
||||||
|
|
||||||
|
#### Scenario: Partial save failure
|
||||||
|
- **WHEN** some entry operations succeed and another operation fails
|
||||||
|
- **THEN** successful results remain reflected locally and unfinished work remains available for retry without duplicating confirmed writes
|
||||||
|
|
||||||
|
### Requirement: Loading and failures are local to the time view
|
||||||
|
Manage Time SHALL display skeleton rows while loading and provide retry on data failure without discarding the user's current draft values.
|
||||||
|
|
||||||
|
#### Scenario: Week retrieval fails
|
||||||
|
- **WHEN** the active week's request fails
|
||||||
|
- **THEN** the modal exposes the failure and retry while protecting existing edits
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Present the filtered opportunities by close date in a vertically navigable calendar bounded by the chosen Reference.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Calendar is a Reference-bounded monthly view
|
||||||
|
Calendar SHALL show a continuous monthly view restricted to the selected Reference, with vertical scrolling and no month/week switch or previous/next navigation buttons. Switching between Overview and Calendar SHALL not fetch opportunities again.
|
||||||
|
|
||||||
|
#### Scenario: Reference spans multiple months
|
||||||
|
- **WHEN** the user opens Calendar for a multi-month Reference
|
||||||
|
- **THEN** all months in the Reference remain reachable by vertical scrolling, including months with no opportunities
|
||||||
|
|
||||||
|
### Requirement: Every daily opportunity has a card
|
||||||
|
Each day SHALL show cards for all matching opportunities with a scrollable daily region when necessary. A hidden-event overflow link SHALL not replace the extra cards.
|
||||||
|
|
||||||
|
#### Scenario: Many opportunities close on one day
|
||||||
|
- **WHEN** the day's cards exceed its available height
|
||||||
|
- **THEN** the user can scroll within the day to reach all cards and the count reflects all matching opportunities
|
||||||
|
|
||||||
|
### Requirement: Initial positioning includes adjacent weeks
|
||||||
|
When today is within Reference, Calendar SHALL position around the current week and adjust visible week sizing to include the preceding and following weeks where the Reference permits. Dates outside Reference SHALL remain excluded.
|
||||||
|
|
||||||
|
#### Scenario: Current week crosses a month boundary
|
||||||
|
- **WHEN** Calendar positions around today and the three-week interval crosses into another month
|
||||||
|
- **THEN** the rendered portions of those weeks are included in the positioning calculation
|
||||||
|
|
||||||
|
### Requirement: Dates retain their calendar-day meaning
|
||||||
|
Calendar SHALL use `EffectiveDate` as a date-only close date without timezone shifts. Opportunities with missing or invalid close dates SHALL remain available in a separate list, and fiscal week labels SHALL use Sunday-start weeks and a June-start fiscal year.
|
||||||
|
|
||||||
|
#### Scenario: API date includes an offset
|
||||||
|
- **WHEN** an opportunity close date contains a timezone offset
|
||||||
|
- **THEN** its card retains the API calendar day instead of shifting to an adjacent day
|
||||||
|
|
||||||
|
#### Scenario: Missing close date
|
||||||
|
- **WHEN** a filtered opportunity has no valid close date
|
||||||
|
- **THEN** it is accessible through the undated-opportunities list
|
||||||
|
|
||||||
|
### Requirement: Calendar details are read-only
|
||||||
|
Opportunity cards SHALL show stage information and open read-only details with the corresponding opportunity link. Calendar labels and controls SHALL be in English.
|
||||||
|
|
||||||
|
#### Scenario: User selects a card
|
||||||
|
- **WHEN** an opportunity card is clicked
|
||||||
|
- **THEN** its details are shown without changing the opportunity in Oracle
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Let users explore Oracle opportunities within a fiscal Reference using consistent filters, totals and sortable tabular data.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Queries use the user's default saved search
|
||||||
|
Opportunity queries SHALL resolve the unique saved search marked `isDefault=true` and use its `savedSearchNumber` in `copiedFrom` for every page. Missing or ambiguous default searches SHALL prevent a dependent opportunity query.
|
||||||
|
|
||||||
|
#### Scenario: Valid default search
|
||||||
|
- **WHEN** saved searches return exactly one valid default
|
||||||
|
- **THEN** the opportunity payload uses `queries/<savedSearchNumber>` and reuses it throughout pagination
|
||||||
|
|
||||||
|
#### Scenario: No valid default
|
||||||
|
- **WHEN** no unique default saved search is returned
|
||||||
|
- **THEN** the extension reports the failure instead of using a hard-coded saved-search identifier
|
||||||
|
|
||||||
|
### Requirement: Opportunities display before background enrichment
|
||||||
|
The extension SHALL collect the opportunity result pages before displaying the ready list, then start Service Request and product enrichment without waiting for them to finish. Responses from an obsolete Reference request SHALL not replace the active result.
|
||||||
|
|
||||||
|
#### Scenario: SR remains pending
|
||||||
|
- **WHEN** opportunity pagination completes and Service Request loading remains pending
|
||||||
|
- **THEN** the opportunity table is already usable
|
||||||
|
|
||||||
|
#### Scenario: Reference changes during loading
|
||||||
|
- **WHEN** an older query completes after a newer Reference query has started
|
||||||
|
- **THEN** the older result does not overwrite the active view
|
||||||
|
|
||||||
|
### Requirement: Reference uses Oracle fiscal periods
|
||||||
|
The selected Reference SHALL determine the query date range using a June-through-May fiscal year. Opportunity type selections SHALL also constrain the query; an empty type selection SHALL show an explanatory empty state without sending an opportunity query.
|
||||||
|
|
||||||
|
#### Scenario: Fiscal first quarter
|
||||||
|
- **WHEN** Reference resolves to the first fiscal quarter
|
||||||
|
- **THEN** the selected period covers June through August with the next quarter boundary excluded
|
||||||
|
|
||||||
|
### Requirement: Filters are coordinated across views
|
||||||
|
Overview and Calendar SHALL share search, Customer, Owner, Status, Stage, eligible SR/Action filters and Booking/Workload filters. Customer options SHALL narrow by selected Owners and Owner options by selected Customers without additional API calls.
|
||||||
|
|
||||||
|
#### Scenario: Customer narrows owner choices
|
||||||
|
- **WHEN** a customer associated with two owners is selected
|
||||||
|
- **THEN** the Owner choices contain the corresponding owners and widening the customer selection restores applicable choices
|
||||||
|
|
||||||
|
#### Scenario: All-selected item is clicked
|
||||||
|
- **WHEN** the user clicks an item while all choices in its multiselect or filter-badge group are selected
|
||||||
|
- **THEN** only that item remains selected and subsequent clicks can add other choices
|
||||||
|
|
||||||
|
#### Scenario: Booking isolation
|
||||||
|
- **WHEN** both Booking and Workload are selected and Booking is clicked
|
||||||
|
- **THEN** only Booking opportunities remain visible in both views
|
||||||
|
|
||||||
|
### Requirement: Overview preserves table meaning and ordering
|
||||||
|
Overview SHALL restore its last sort column and direction, display customer `PartyName`, label win probability `Prob.`, and give Name and Customer greater width priority. Monetary cells and their headers SHALL align right; other headers SHALL align left.
|
||||||
|
|
||||||
|
#### Scenario: Overview reopens
|
||||||
|
- **WHEN** the user reopens Overview after selecting a descending Amount sort
|
||||||
|
- **THEN** that ordering is restored and the displayed amount values determine the order
|
||||||
|
|
||||||
|
### Requirement: Indicators reflect the filtered opportunities
|
||||||
|
The opportunity count and stage amounts SHALL follow the active filters without issuing additional queries solely to calculate the displayed indicators.
|
||||||
|
|
||||||
|
#### Scenario: Local filter changes
|
||||||
|
- **WHEN** the user narrows the loaded list using a Customer filter
|
||||||
|
- **THEN** the count and stage indicators update with the filtered opportunities
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Maintain customer organization charts locally while enriching people with Oracle contact information and related opportunities.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Charts are scoped and stored locally
|
||||||
|
Charts SHALL persist JSON-compatible records in extension-owned IndexedDB, scoped by user and customer in the browser profile. Records SHALL include revision and update metadata; stale saves SHALL be rejected. Email SHALL not persist in chart nodes.
|
||||||
|
|
||||||
|
#### Scenario: Concurrent chart change
|
||||||
|
- **WHEN** a user saves an outdated revision after another window has saved the chart
|
||||||
|
- **THEN** the save is rejected with guidance to reopen the current chart
|
||||||
|
|
||||||
|
#### Scenario: Browser profile is different
|
||||||
|
- **WHEN** the same user accesses the customer in another browser profile
|
||||||
|
- **THEN** this local chart is not automatically synchronized to that profile
|
||||||
|
|
||||||
|
### Requirement: Hierarchy editing preserves remaining people
|
||||||
|
The chart SHALL support adding people, changing their manager, notes, branch expansion/collapse, zoom and panning. It SHALL reject invalid hierarchies, support up to 500 people and 60 levels, and promote direct reports to the removed person's manager when a person is deleted.
|
||||||
|
|
||||||
|
#### Scenario: Manager is deleted
|
||||||
|
- **WHEN** a user deletes a person with direct reports
|
||||||
|
- **THEN** those reports remain in the chart under the deleted person's manager
|
||||||
|
|
||||||
|
#### Scenario: Unsaved chart is closed
|
||||||
|
- **WHEN** the user closes a chart with unsaved changes
|
||||||
|
- **THEN** the extension requests confirmation before discarding those changes
|
||||||
|
|
||||||
|
### Requirement: Contact autocomplete is debounced and customer-scoped
|
||||||
|
Name autocomplete SHALL search contacts after a 350 ms typing pause, filter by the customer's PartyId, display PartyName and JobTitle, and ignore superseded results. Empty queries and opening the chart alone SHALL not issue contact searches.
|
||||||
|
|
||||||
|
#### Scenario: User keeps typing
|
||||||
|
- **WHEN** another character arrives before the search delay expires
|
||||||
|
- **THEN** the pending search is superseded rather than sending one query per character
|
||||||
|
|
||||||
|
### Requirement: Person details refresh contact data on demand
|
||||||
|
Opening a stored person SHALL query contacts using the person's Name as keywords and match by contact PartyId. A matching result SHALL refresh Name and Job Title while preserving notes and reporting relationships; Email SHALL remain transient. Job Title SHALL be eligible for chart persistence.
|
||||||
|
|
||||||
|
#### Scenario: Contact data changed
|
||||||
|
- **WHEN** a matching contact response has a new name or job title
|
||||||
|
- **THEN** the working chart updates those fields and retains the person's notes and manager
|
||||||
|
|
||||||
|
### Requirement: Contact PartyNumber is preserved as an opaque identifier
|
||||||
|
Chart nodes SHALL retain the contact's PartyNumber, including alphanumeric values. Opening person details SHALL persist a returned PartyNumber; opportunity lookup SHALL use an existing stored value and attempt resolution when it is missing.
|
||||||
|
|
||||||
|
#### Scenario: Alphanumeric number is already saved
|
||||||
|
- **WHEN** opportunities are requested for a person with PartyNumber `A4KCS2P`
|
||||||
|
- **THEN** that exact value is used in `OpportunityContact.ContactPartyNumber`, without numeric conversion
|
||||||
|
|
||||||
|
### Requirement: Contact opportunity tables load all pages
|
||||||
|
The person card SHALL provide an opportunities action that opens `Opportunities | <name>`, shows a skeleton while loading and follows `hasMore` until all pages are collected. The resulting table SHALL be sortable and link each Name to its corresponding opportunity in a new tab.
|
||||||
|
|
||||||
|
#### Scenario: Contact has more than one result page
|
||||||
|
- **WHEN** the opportunities response sets `hasMore=true`
|
||||||
|
- **THEN** the next offset is queried using the same contact PartyNumber until the result is complete
|
||||||
|
|
||||||
|
### Requirement: New contacts are created explicitly
|
||||||
|
The contact-creation form SHALL require First Name, Last Name and Email, map email/phone consent to their corresponding Do/DoNot flags and obtain account identifiers from the selected customer. Provided phone components SHALL be combined as country code, hyphen and number. Save SHALL submit directly without a payload-preview step.
|
||||||
|
|
||||||
|
#### Scenario: Optional phone is omitted
|
||||||
|
- **WHEN** required contact fields are valid but a phone number is not filled
|
||||||
|
- **THEN** that absent phone value is not included as a populated contact field
|
||||||
|
|
||||||
|
#### Scenario: Phone components are entered
|
||||||
|
- **WHEN** country code `55` and phone number `99999999999` are provided
|
||||||
|
- **THEN** the submitted raw phone value is `55-99999999999`
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Preserve user preferences and reuse eligible data without allowing expired or invalidated results to silently replace fresh information.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Preferences survive reopening
|
||||||
|
The extension SHALL persist supported filter preferences, theme, opportunity type selections and cache duration in the browser profile. Invalid or unavailable stored values SHALL fall back to usable defaults or in-memory state.
|
||||||
|
|
||||||
|
#### Scenario: Stored preference cannot be read
|
||||||
|
- **WHEN** browser storage is unavailable or a saved preference is invalid
|
||||||
|
- **THEN** the extension remains usable with its fallback rather than failing to open
|
||||||
|
|
||||||
|
### Requirement: Sort preferences belong to individual tables
|
||||||
|
Supported sortable tables SHALL retain their own last sort key and direction without changing other tables' preferences. Product detail tables SHALL remain excluded from this sorting behavior.
|
||||||
|
|
||||||
|
#### Scenario: Two tables use different sorts
|
||||||
|
- **WHEN** the user sorts Installed Base by amount and contact opportunities by close date
|
||||||
|
- **THEN** reopening each table restores its own saved ordering
|
||||||
|
|
||||||
|
### Requirement: Cache reuse respects validity and identity boundaries
|
||||||
|
Product, workload and activity caches SHALL apply their configured lifetimes and existing identity/account/product-update scopes. A missing or expired activity cache SHALL not be represented as a confirmed empty result.
|
||||||
|
|
||||||
|
#### Scenario: Workload token rotates
|
||||||
|
- **WHEN** the same user's account workload data is still valid but the authorization token changes
|
||||||
|
- **THEN** the account data can be reused without treating the token string as a different customer identity
|
||||||
|
|
||||||
|
### Requirement: Only eligible reads are reused
|
||||||
|
Form request caching SHALL reuse only known read operations, share concurrent reads and invalidate applicable entries after successful writes. Create, update and delete operations SHALL not be served from a response cache.
|
||||||
|
|
||||||
|
#### Scenario: Repeated write
|
||||||
|
- **WHEN** the user explicitly submits a new valid write operation
|
||||||
|
- **THEN** the operation goes through the write transport rather than returning a cached success
|
||||||
|
|
||||||
|
### Requirement: Refresh all invalidates reusable data safely
|
||||||
|
Refresh all SHALL invalidate product/activity and related cached data before reloading the active Reference, preserve user preferences and authentication, and prevent pre-invalidation responses from repopulating invalidated cache entries. Cache invalidation errors SHALL be reported.
|
||||||
|
|
||||||
|
#### Scenario: Old product request finishes after refresh
|
||||||
|
- **WHEN** an old request completes after its cache has been invalidated
|
||||||
|
- **THEN** its result does not restore the invalidated cache snapshot
|
||||||
|
|
||||||
|
#### Scenario: Activity updates arrive
|
||||||
|
- **WHEN** only background activity lookup results change
|
||||||
|
- **THEN** those lookups do not independently advance the product Last update timestamp
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Enrich opportunities with products and workload information and support review and explicit updates of consumption projections.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Product enrichment runs in the background
|
||||||
|
Product loading SHALL update opportunity product counts and progress without blocking the displayed opportunity list. Product detail tables SHALL retain their source order rather than applying the general table sorting preference.
|
||||||
|
|
||||||
|
#### Scenario: Some products are still loading
|
||||||
|
- **WHEN** opportunities are visible and their products have not all arrived
|
||||||
|
- **THEN** completed product results update their related opportunities while remaining products continue loading
|
||||||
|
|
||||||
|
### Requirement: Workload retrieval is shared by customer
|
||||||
|
Workload retrieval SHALL consolidate all result pages for each distinct customer account, reuse eligible cached results and correlate workloads to products by revenue identifier. Concurrent consumers of the same account SHALL share loading.
|
||||||
|
|
||||||
|
#### Scenario: Multiple opportunities share a customer
|
||||||
|
- **WHEN** their products require workloads for the same account
|
||||||
|
- **THEN** one shared account retrieval supplies the matching products and continues while the response indicates more pages
|
||||||
|
|
||||||
|
### Requirement: Booking classification requires loaded products without Workload names
|
||||||
|
An opportunity with a nonempty loaded product list SHALL display a distinct Booking badge when every product has a blank or `-` Workload name. If any product has a Workload name, the existing ramp badge behavior SHALL remain.
|
||||||
|
|
||||||
|
#### Scenario: No products have an assigned Workload
|
||||||
|
- **WHEN** all loaded products have blank or `-` Workload names
|
||||||
|
- **THEN** Booking replaces Ramp Months and the opportunity matches the Booking filter
|
||||||
|
|
||||||
|
#### Scenario: Mixed product assignments
|
||||||
|
- **WHEN** at least one product has a Workload name
|
||||||
|
- **THEN** the opportunity retains its ramp behavior and is not classified as Booking
|
||||||
|
|
||||||
|
### Requirement: Projection access is independent of a difference alert
|
||||||
|
Clicking a ramp badge SHALL open consumption projection even when forecast and calculated amounts match. Difference alerts SHALL be placed between ramp and SR badges; product alerts without a workload record SHALL indicate unavailability with disabled styling.
|
||||||
|
|
||||||
|
#### Scenario: Forecast matches calculation
|
||||||
|
- **WHEN** the user clicks a ramp badge for a product with no forecast difference
|
||||||
|
- **THEN** Consumption projection still opens
|
||||||
|
|
||||||
|
### Requirement: Projection comparison uses calculated monthly values
|
||||||
|
Consumption projection SHALL calculate monthly amounts using adjusted ACR, ramp months and first-month proration, and compare forecast minus calculated amounts at cent precision.
|
||||||
|
|
||||||
|
#### Scenario: Monthly discrepancy
|
||||||
|
- **WHEN** a calculated monthly amount differs from its forecast after rounding to cents
|
||||||
|
- **THEN** the comparison shows the difference and enables the corresponding alert
|
||||||
|
|
||||||
|
### Requirement: Estimate updates require confirmation
|
||||||
|
Update estimates SHALL ask for confirmation before writing calculated values to SPA, prevent duplicate submissions and report success or failure. Successful updates SHALL refresh the comparison and invalidate affected workload cache data.
|
||||||
|
|
||||||
|
#### Scenario: Confirmation is cancelled
|
||||||
|
- **WHEN** the user declines the update confirmation
|
||||||
|
- **THEN** no forecast update is sent
|
||||||
|
|
||||||
|
#### Scenario: Confirmed update succeeds
|
||||||
|
- **WHEN** the user confirms and the update request succeeds
|
||||||
|
- **THEN** the comparison reflects the new values and subsequent workload reads do not reuse the invalidated values
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Make extension HTTP activity inspectable without pre-rendering an ever-growing request interface or confusing cached data with new network operations.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: HTTP activity has a visible lifecycle
|
||||||
|
Debug Requests SHALL record request identity, method, URL, timing, headers, body, response and final status when available. Running requests SHALL transition to success or failure, cache hits SHALL not create fictitious HTTP requests, and entries SHALL be grouped by request category.
|
||||||
|
|
||||||
|
#### Scenario: Request is pending
|
||||||
|
- **WHEN** a logged request has started but not completed
|
||||||
|
- **THEN** it is displayed as running with its start time and later updates with completion or failure
|
||||||
|
|
||||||
|
### Requirement: Request history is persisted per tab context
|
||||||
|
Request records SHALL be stored in page localStorage under a tab-specific namespace. Closing the main modal SHALL preserve them. Starting page one of a new opportunity query SHALL reset that history; later pages of the same query SHALL retain it.
|
||||||
|
|
||||||
|
#### Scenario: Modal closes and reopens
|
||||||
|
- **WHEN** the main modal closes and reopens without a new opportunity query
|
||||||
|
- **THEN** the existing debug history remains available
|
||||||
|
|
||||||
|
#### Scenario: New query begins
|
||||||
|
- **WHEN** the first opportunity page is about to be sent
|
||||||
|
- **THEN** old history is cleared for that tab without deleting preferences or another tab's debug records
|
||||||
|
|
||||||
|
### Requirement: Debug rendering is deferred
|
||||||
|
The hidden debug view SHALL avoid constructing request-detail DOM. Opening the view SHALL render request summaries and expanding a request SHALL construct its details, preserving expansion and scroll state across updates where applicable.
|
||||||
|
|
||||||
|
#### Scenario: Requests accumulate while debug is closed
|
||||||
|
- **WHEN** requests complete while the debug panel is hidden
|
||||||
|
- **THEN** records are captured without mounting their detail elements in the hidden panel
|
||||||
|
|
||||||
|
### Requirement: Debug access stays above other extension views
|
||||||
|
Ctrl+Shift+D SHALL toggle Debug Requests as a full-page extension view above other extension modals. Closing it SHALL return to the underlying view without opening a browser tab or native fullscreen mode.
|
||||||
|
|
||||||
|
#### Scenario: Debug opens over person details
|
||||||
|
- **WHEN** the user presses Ctrl+Shift+D while a nested modal is open
|
||||||
|
- **THEN** Debug Requests appears in front of that modal
|
||||||
|
|
||||||
|
### Requirement: Sensitive-value masking is a display control
|
||||||
|
Debug SHALL hide sensitive values by default and support explicit reveal of retained originals. Documentation SHALL distinguish masking from storage protection: captured debug records can retain sensitive response values and masking SHALL not be described as encryption or guaranteed redaction at rest.
|
||||||
|
|
||||||
|
#### Scenario: Identity token is captured
|
||||||
|
- **WHEN** the identity response includes a token
|
||||||
|
- **THEN** normal debug display hides the token and the explicit visibility control can reveal its retained value
|
||||||
|
|
||||||
|
### Requirement: Debug failures do not interrupt application requests
|
||||||
|
Unavailable or full localStorage SHALL leave records available in memory with explanatory feedback. Debug timers and persistence errors SHALL not prevent the underlying network request from starting or completing.
|
||||||
|
|
||||||
|
#### Scenario: Storage quota is exhausted
|
||||||
|
- **WHEN** persisting a request record fails due to localStorage quota
|
||||||
|
- **THEN** the request outcome remains available in memory and the application request continues normally
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Expose service requests linked to opportunities and customers, with progressive status badges, detail views and explicit record creation.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: CRM SR queries resolve their saved search
|
||||||
|
Before the first CRM Service Request page, the extension SHALL retrieve SR search resources using the general Token Relay Bearer and use the first item's valid `savedSearchNumber` as `queries/<savedSearchNumber>` in `copiedFrom`.
|
||||||
|
|
||||||
|
#### Scenario: Search resources have no identifier
|
||||||
|
- **WHEN** the first item has no valid saved-search number
|
||||||
|
- **THEN** the dependent CRM SR query fails explicitly instead of using a fixed fallback
|
||||||
|
|
||||||
|
### Requirement: SR enrichment is progressive and nonblocking
|
||||||
|
For eligible users, CRM SR pages SHALL load after opportunities are displayed and update their linked badges in the background. The query SHALL select pending-delivery team SRs and match opportunities through the returned opportunity number.
|
||||||
|
|
||||||
|
#### Scenario: First SR page arrives
|
||||||
|
- **WHEN** more SR pages remain after the first successful page
|
||||||
|
- **THEN** information received so far can update the visible opportunities without waiting for all pages
|
||||||
|
|
||||||
|
### Requirement: Failed SR loading remains distinguishable from no matches
|
||||||
|
The extension SHALL distinguish loading, failed and confirmed empty SR results. Pagination SHALL detect lack of progress, retry each failed page at most five total attempts, and stop work superseded by a newer query.
|
||||||
|
|
||||||
|
#### Scenario: SR lookup fails
|
||||||
|
- **WHEN** a lookup cannot establish whether an opportunity has matching SRs
|
||||||
|
- **THEN** its badge indicates an unknown or failed state rather than confirmed absence
|
||||||
|
|
||||||
|
#### Scenario: Successful SR snapshot is reused
|
||||||
|
- **WHEN** a subsequent cycle can reuse the completed SR result within the current page session
|
||||||
|
- **THEN** it does not repeat the completed query unless explicitly invalidated
|
||||||
|
|
||||||
|
### Requirement: Customer SR details use the resolved account identifier
|
||||||
|
Customer Service Requests SHALL resolve account summary first, use its `id` for the customer SR query and consolidate pages of open SRs. The dialog SHALL provide a loading skeleton, searchable sortable results, and guards against late results after closure.
|
||||||
|
|
||||||
|
#### Scenario: Account has distinct identifiers
|
||||||
|
- **WHEN** account summary returns different `id` and `registryId` values
|
||||||
|
- **THEN** customer SR retrieval uses `id`, not `registryId`
|
||||||
|
|
||||||
|
### Requirement: Internal SR recovery is bounded
|
||||||
|
Internal SR details SHALL report failed requests and use bounded, shared session recovery rather than continuously issuing requests or opening a visible recovery tab.
|
||||||
|
|
||||||
|
#### Scenario: Endpoint remains unavailable
|
||||||
|
- **WHEN** an Internal SR read continues to fail after its allowed recovery attempts
|
||||||
|
- **THEN** the view shows failure and does not keep sending an unbounded automatic request loop
|
||||||
|
|
||||||
|
### Requirement: SR creation validates before writing
|
||||||
|
Service Request creation SHALL require valid form inputs before submission, retain entered data on failure and prevent duplicate submissions while saving. Internal SR creation SHALL resolve parent SR and current resource information before linking the new record to its parent.
|
||||||
|
|
||||||
|
#### Scenario: Required fields are missing
|
||||||
|
- **WHEN** Save is clicked on an incomplete SR form
|
||||||
|
- **THEN** the missing fields are highlighted and no create request is sent
|
||||||
|
|
||||||
|
#### Scenario: Create request fails
|
||||||
|
- **WHEN** a submitted SR creation fails
|
||||||
|
- **THEN** the user receives error feedback and their form values remain available
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Establish authenticated Oracle access before dependent data operations while preserving credential boundaries and profile restrictions.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Valid identity precedes dependent data requests
|
||||||
|
The extension SHALL obtain a successful current-user response containing a valid user identifier, name, roles array and token before starting dependent opportunity or activity data requests. Concurrent identity consumers SHALL share acquisition, and invalid responses SHALL retry with backoff.
|
||||||
|
|
||||||
|
#### Scenario: HTML is returned with HTTP 200
|
||||||
|
- **WHEN** current-user acquisition receives a login document instead of the required JSON
|
||||||
|
- **THEN** dependent data requests remain blocked and identity acquisition retries
|
||||||
|
|
||||||
|
#### Scenario: Identity is already established
|
||||||
|
- **WHEN** the user changes Reference or reopens the main modal in the same page session
|
||||||
|
- **THEN** the extension reuses the successful identity without another current-user HTTP request
|
||||||
|
|
||||||
|
### Requirement: SPA recovery verifies the loaded session page
|
||||||
|
SPA session recovery SHALL open the configured bootstrap URL in an inactive auxiliary tab and verify page readiness before probing identity. Login forms, error documents and OAuth callbacks SHALL not count as a ready SPA page.
|
||||||
|
|
||||||
|
#### Scenario: Recovery navigation
|
||||||
|
- **WHEN** identity recovery requires a SPA session
|
||||||
|
- **THEN** navigation starts at `https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace`
|
||||||
|
- **AND** the extension preserves the originating browser context and closes its owned auxiliary tab when recovery finishes
|
||||||
|
|
||||||
|
#### Scenario: Rejected callback document
|
||||||
|
- **WHEN** recovery encounters an authorization-error callback
|
||||||
|
- **THEN** it restarts from the configured bootstrap URL instead of replaying that callback as a successful session
|
||||||
|
|
||||||
|
### Requirement: SR session preparation has independent progress
|
||||||
|
SR session preparation SHALL be able to run concurrently with SPA identity preparation, using the SeaaS external application URL. It SHALL expose completion or failure and allow a user retry after failure.
|
||||||
|
|
||||||
|
#### Scenario: SR preparation fails
|
||||||
|
- **WHEN** preparation through `https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/` fails or reaches its 70-second timeout
|
||||||
|
- **THEN** the extension offers retry rather than declaring the SR session ready
|
||||||
|
|
||||||
|
### Requirement: SALES_REP activity access is restricted
|
||||||
|
For a user whose roles include `SALES_REP`, the extension SHALL hide CRM SR and Workload Action badges and Manage Time access, and SHALL not issue their dependent activity or Manage Time data requests.
|
||||||
|
|
||||||
|
#### Scenario: Restricted user after successful authentication
|
||||||
|
- **WHEN** identity resolves with the `SALES_REP` role
|
||||||
|
- **THEN** opportunities remain available but CRM activity badges and Manage Time are unavailable
|
||||||
|
- **AND** attempting to open Manage Time does not load its data
|
||||||
|
|
||||||
|
### Requirement: Credentials are specific to each Oracle service
|
||||||
|
The extension SHALL preserve operation-specific credentials and cookie inclusion. It SHALL use the general Token Relay Bearer for Fusion queries and the appropriate SPA consumer authorization for each SPA service rather than substituting token families.
|
||||||
|
|
||||||
|
#### Scenario: Customer SR authorization
|
||||||
|
- **WHEN** customer service requests are loaded using SPA-ORANGE authorization
|
||||||
|
- **THEN** the raw authorization value is sent in `custsr-auth` without a Bearer prefix
|
||||||
|
|
||||||
|
#### Scenario: Token Relay does not finish
|
||||||
|
- **WHEN** a Token Relay request exceeds its request timeout
|
||||||
|
- **THEN** the attempt reports failure instead of remaining indefinitely pending in Debug Requests
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Identify workload actions owned by the signed-in user and allow eligible users to create actions for opportunity products.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Action enrichment respects prerequisite data
|
||||||
|
Workload Action enrichment SHALL use distinct product revenue identifiers after the product cycle and successful or reused CRM SR loading. Unavailable SR data SHALL leave action state unknown rather than treating it as an empty result.
|
||||||
|
|
||||||
|
#### Scenario: SR prerequisite fails
|
||||||
|
- **WHEN** CRM SR loading fails for a completed product cycle
|
||||||
|
- **THEN** action enrichment is blocked and its badges do not assert that no actions exist
|
||||||
|
|
||||||
|
### Requirement: Ownership determines the action badge
|
||||||
|
The action badge SHALL aggregate returned actions for the opportunity products and mark a match when an action owner equals the general Token Relay principal. Missing principal or failed lookups SHALL not count as confirmed nonmatches.
|
||||||
|
|
||||||
|
#### Scenario: Actions exist for other owners
|
||||||
|
- **WHEN** returned actions have no owner matching the known principal
|
||||||
|
- **THEN** the badge indicates no matching ownership while its tooltip retains the returned actions
|
||||||
|
|
||||||
|
### Requirement: Read attempts are bounded and partial coverage is disclosed
|
||||||
|
Each action lookup SHALL make at most five total attempts and stop on success or session supersession. The current lookup SHALL expose `hasNext` as additional unqueried actions rather than implying that all pages were loaded.
|
||||||
|
|
||||||
|
#### Scenario: More action pages exist
|
||||||
|
- **WHEN** a successful action response sets `hasNext=true`
|
||||||
|
- **THEN** the tooltip states that additional actions exist and pagination is not enabled
|
||||||
|
|
||||||
|
### Requirement: Action forms use validated lookup selections
|
||||||
|
The Action form SHALL load team members and lookup values with the workload credential, support searchable selections, and submit selected codes rather than arbitrary typed search text. Required inputs and the date range SHALL be validated before Save.
|
||||||
|
|
||||||
|
#### Scenario: Search text is not an option
|
||||||
|
- **WHEN** the user types a value without selecting a valid lookup result
|
||||||
|
- **THEN** the text is not submitted as a valid option value
|
||||||
|
|
||||||
|
### Requirement: Action creation is explicit and preserves failure state
|
||||||
|
Saving a valid action SHALL send current-user attribution, UTC dates and a notes array, lock duplicate submission while pending and show a result toast. A failed create SHALL retain the form without automatically retrying the POST.
|
||||||
|
|
||||||
|
#### Scenario: Create fails
|
||||||
|
- **WHEN** the server rejects an action creation
|
||||||
|
- **THEN** the form remains available with the entered data and no automatic duplicate POST is issued
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
These are application-phase tasks for a documentation baseline. Creating these planning artifacts does not complete the documentation edits below. No task authorizes runtime feature changes, schema customization, a release, or live Oracle writes.
|
||||||
|
|
||||||
|
## 1. Reconcile the documented user experience
|
||||||
|
|
||||||
|
- [x] 1.1 Update README.md Opportunity Views to describe the current Calendar, coordinated Customer/Owner filters, filter isolation and persisted sorting; verify against `tests/opportunityCalendar.test.cjs`, `tests/dependentFilters.test.cjs`, `tests/allOpportunityFilters.test.cjs` and `tests/tableSorting.test.cjs` with the existing Node test runner.
|
||||||
|
- [x] 1.2 Update README.md product/workload, Booking and projection descriptions and remove active AI-summary descriptions; verify the relevant behavior against `tests/badgeIcons.test.cjs`, `tests/ramp-projection.test.cjs` and `tests/workload-forecast-update.test.cjs`, and confirm no active AI API/module claim remains in README.md.
|
||||||
|
- [x] 1.3 Update README.md Organization Charts with stored alphanumeric PartyNumber, transient email, on-demand contact refresh, creation and paginated contact opportunities; verify against `tests/orgChart.test.cjs` and `tests/accountContact.test.cjs` without sending real contact writes.
|
||||||
|
- [x] 1.4 Update README.md customer insights and shell coverage with Rate Card order filtering, printing, favorite shortcuts/hints and draggable modal headers; verify against the relevant source and `tests/ratecard.test.cjs`, `tests/bookmarkShortcut.test.cjs`, `tests/bookmarkHint.test.cjs` and `tests/dialogDrag.test.cjs`, recording any visual behavior that remains untested in an installed browser.
|
||||||
|
|
||||||
|
## 2. Reconcile authentication and asynchronous loading
|
||||||
|
|
||||||
|
- [x] 2.1 Update README.md authentication to use the current SPA bootstrap URL, identity reuse, independent SR session preparation and SALES_REP restrictions including Manage Time; verify against `tests/currentUser.test.cjs`, `tests/srSessionPreparation.test.cjs` and `tests/manageTime.test.cjs`.
|
||||||
|
- [x] 2.2 Correct README.md SR/Actions sequencing and saved-search contracts, including progressive SR badges and allowed activity cache-miss requests for cached products; verify against `tests/srRequestPriority.test.cjs`, `tests/crmServiceRequests.test.cjs`, `tests/activityProgress.test.cjs` and `tests/productActivities.test.cjs`.
|
||||||
|
- [x] 2.3 Revise docs/architecture.md runtime contexts, generated manifest permissions/hosts, service credentials, saved searches and timeout/stale-response behavior; verify each changed contract against `scripts/build.mjs`, composition roots and relevant service/auth sources, and run `tests/architecture.test.cjs` and `tests/buildOutput.test.cjs`.
|
||||||
|
|
||||||
|
## 3. Reconcile persistence and establish documentation navigation
|
||||||
|
|
||||||
|
- [x] 3.1 Update docs/architecture.md storage ownership and cache lifetimes for preferences, debug, identity/session state, products, workloads, activities and organization charts; verify each storage location/key against source and `tests/activityCache.test.cjs`, `tests/formRequestCache.test.cjs`, `tests/workloads.test.cjs` and `tests/refreshAll.test.cjs`.
|
||||||
|
- [x] 3.2 Correct README.md and docs/architecture.md debug descriptions to include lazy rendering, page-one reset, persistence across modal closure and the distinction between masking and stored originals; verify against `tests/debugStorage.test.cjs` and `tests/debugTabs.test.cjs` and remove blanket claims that all credentials remain only in memory.
|
||||||
|
- [x] 3.3 Add concise OpenSpec navigation to README.md and link the architecture documentation to the capability baseline, clearly identifying this change as proposed until archival; verify link targets, retain `# Opportunities Extension`, keep historical notes in RELEASE_NOTES.md and confirm no schema/config change is included.
|
||||||
|
|
||||||
|
## 4. Validate the integrated baseline
|
||||||
|
|
||||||
|
- [x] 4.1 Review all twelve capability specifications against the final documented behavior and evidence map; verify every requirement has a WHEN/THEN scenario, every proposed capability has one matching spec file, and no runtime enhancement is concealed as an existing contract. Record unresolved discrepancies rather than silently changing code.
|
||||||
|
- [x] 4.2 Run `openspec validate document-project-baseline --strict` and `node scripts/build.mjs --check`; verify strict validation succeeds and generated browser packages are unchanged and current.
|
||||||
|
- [x] 4.3 Run `node scripts/test.cjs` for final integration verification and `git diff --check`; record results and explicitly distinguish synthetic coverage from any unperformed installed-browser or live Oracle checks. Confirm the final change is confined to approved documentation and OpenSpec artifacts, with version and release notes unchanged.
|
||||||
|
|
||||||
|
## Workflow follow-up
|
||||||
|
|
||||||
|
- After application and review, use `$openspec-archive-change document-project-baseline` to promote the baseline specifications and archive the change. Archival is not a prerequisite for completing the tracked tasks.
|
||||||
|
- Confirm `openspec list --specs --json` lists the twelve promoted capabilities, no generated Purpose placeholder remains, and README navigation resolves to the durable specs.
|
||||||
|
- Use capability-specific delta changes for future behavior updates; do not rewrite the baseline from release history or silently replace it with an unreviewed implementation snapshot.
|
||||||
42
openspec/config.yaml
Normal file
42
openspec/config.yaml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
|
||||||
|
context: |
|
||||||
|
Opportunities Extension supports Oracle Fusion workflows in Chromium/Edge and Firefox.
|
||||||
|
Write OpenSpec artifacts and extension-authored UI text in English; preserve service-provided names.
|
||||||
|
Use openspec/specs/ for behavioral contracts, docs/architecture.md for execution and storage
|
||||||
|
boundaries, README.md for setup and usage, and RELEASE_NOTES.md for release history.
|
||||||
|
Preserve both browser targets and the existing extension design system unless the change
|
||||||
|
explicitly proposes a compatibility or presentation change.
|
||||||
|
When a release is requested, include a valid Firefox XPI; XPI packaging is Firefox-only.
|
||||||
|
Keep the README main title as Opportunities Extension and release history in RELEASE_NOTES.md.
|
||||||
|
OpenSpec archival and product release are separate operations.
|
||||||
|
|
||||||
|
rules:
|
||||||
|
proposal:
|
||||||
|
- Identify affected existing capabilities and distinguish observed behavior from proposed changes.
|
||||||
|
- State scope, non-goals and compatibility impact; do not introduce product requirements for tooling-only changes.
|
||||||
|
design:
|
||||||
|
- Reference the relevant architecture contracts instead of duplicating their inventories.
|
||||||
|
- For affected integrations, describe request ordering, credentials, pagination, retries and stale-response handling.
|
||||||
|
- For affected persistence, describe ownership, identity scope, invalidation and migration or rollback needs.
|
||||||
|
- For UI changes, identify shared components and relevant loading, empty, failure and theme behavior.
|
||||||
|
specs:
|
||||||
|
- Describe observable behavior with testable scenarios; keep implementation steps and module wiring in design and tasks.
|
||||||
|
- Use synthetic identifiers and examples; never embed real tokens, cookies or personal contact data.
|
||||||
|
tasks:
|
||||||
|
- Tie each task to a concrete deliverable and include verification proportional to the affected behavior.
|
||||||
|
- For source changes, include relevant synthetic tests, regeneration of affected browser outputs and a build consistency check.
|
||||||
|
- Record installed-browser and live Oracle checks separately from synthetic tests, including checks not performed.
|
||||||
|
- Include version, release notes and Firefox XPI work only when release delivery is part of the requested scope.
|
||||||
|
|
||||||
|
operations:
|
||||||
|
apply:
|
||||||
|
guidance:
|
||||||
|
- Edit source modules and regenerate distributions through the build script; do not hand-edit generated bundles.
|
||||||
|
- Use synthetic data for automated checks and avoid incidental writes to live Oracle services during validation.
|
||||||
|
- Run focused checks for the change; use node scripts/test.cjs for integration verification of runtime changes and node scripts/build.mjs --check for generated-output consistency.
|
||||||
|
- Report what changed, verification results and material limitations without claiming unperformed browser or live-service validation.
|
||||||
|
archive:
|
||||||
|
guidance:
|
||||||
|
- Check documentation links when moving a change and preserve the distinction between archived evidence and current specifications.
|
||||||
|
- Summarize task completion, specification synchronization and the final archive location; do not treat archival as a product release.
|
||||||
0
openspec/specs/.gitkeep
Normal file
0
openspec/specs/.gitkeep
Normal file
53
openspec/specs/customer-insights/spec.md
Normal file
53
openspec/specs/customer-insights/spec.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# customer-insights Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Provide customer-specific Installed Base and consumption exploration without disrupting the main opportunity view.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Installed Base uses the registry identifier
|
||||||
|
Installed Base SHALL first resolve account summary and query using its `registryId` with the SAIB authorization. It SHALL continue pagination until `hasMore=false`, and reject invalid or stalled pages instead of presenting a partial list as complete.
|
||||||
|
|
||||||
|
#### Scenario: More installed items exist
|
||||||
|
- **WHEN** an Installed Base page returns `hasMore=true`
|
||||||
|
- **THEN** subsequent pages are loaded before the complete result is presented
|
||||||
|
|
||||||
|
### Requirement: Installed Base avoids duplicate customer presentation
|
||||||
|
Installed Base SHALL show the customer in the modal header, omit the Customer column, and provide searchable sortable data with loading skeletons.
|
||||||
|
|
||||||
|
#### Scenario: User searches installed items
|
||||||
|
- **WHEN** search text is entered
|
||||||
|
- **THEN** loaded rows and the result count update locally without another account query
|
||||||
|
|
||||||
|
### Requirement: Consumption follows the selected plan
|
||||||
|
Customer consumption SHALL resolve available account plans and load trends for the selected plan with quarterly, monthly, weekly and daily periods. Plan detail loading SHALL be independent of trend loading and failed details SHALL not block the chart.
|
||||||
|
|
||||||
|
#### Scenario: User switches plans
|
||||||
|
- **WHEN** the selected plan changes while an earlier plan request is pending
|
||||||
|
- **THEN** late results from the earlier plan do not replace the selected plan's view
|
||||||
|
|
||||||
|
### Requirement: Projection details preserve safe order navigation
|
||||||
|
Consumption plan details SHALL expose Forfeit and Overage information with nested line details in interactive tooltips. Only HTTPS order links on `accountdashboard.oracle.com` SHALL be clickable.
|
||||||
|
|
||||||
|
#### Scenario: Unsafe order URL
|
||||||
|
- **WHEN** a returned order URL uses a different host or unsupported scheme
|
||||||
|
- **THEN** the detail remains readable without offering that URL as a clickable order link
|
||||||
|
|
||||||
|
### Requirement: Rate Card queries the active plan
|
||||||
|
Rate Card SHALL query using the selected plan's `subPlanNum` and OCICONS authorization, show plan/customer context and provide search, sorting and multi-selection of distinct Order Numbers. The current request SHALL use a 10000-record limit without implying unsupported cursor pagination.
|
||||||
|
|
||||||
|
#### Scenario: Order filter isolates one choice
|
||||||
|
- **WHEN** all order numbers are selected and the user clicks one order
|
||||||
|
- **THEN** only that order remains selected and search continues to combine with the order filter
|
||||||
|
|
||||||
|
#### Scenario: Rate Card closes while loading
|
||||||
|
- **WHEN** the drawer closes before its request completes
|
||||||
|
- **THEN** the late result does not reopen or overwrite a different active plan view
|
||||||
|
|
||||||
|
### Requirement: Customer tables sort and search by meaningful values
|
||||||
|
Customer table search SHALL match case-insensitively and accent-insensitively across displayed values. Sorting SHALL use numeric or date values where applicable, and restore each supported table's saved ordering.
|
||||||
|
|
||||||
|
#### Scenario: Numeric amount ordering
|
||||||
|
- **WHEN** the user sorts a customer table by a monetary column
|
||||||
|
- **THEN** rows are ordered by numeric amount rather than by the formatted text
|
||||||
49
openspec/specs/extension-shell/spec.md
Normal file
49
openspec/specs/extension-shell/spec.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# extension-shell Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Provide predictable entry points, navigation, modal interaction and printing across the supported browser extension targets.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Supported Fusion pages expose the extension
|
||||||
|
The extension SHALL provide its entry tile on the configured Oracle Fusion hcmUI and fscmUI FuseWelcome pages. A bookmarked URL with the extension shortcut marker SHALL open the main modal without requiring a click on the Sales group.
|
||||||
|
|
||||||
|
#### Scenario: Fusion rewrites the URL
|
||||||
|
- **WHEN** the user follows the shortcut URL and Fusion subsequently replaces its query string
|
||||||
|
- **THEN** the captured shortcut intent still permits the extension to open once when ready
|
||||||
|
|
||||||
|
### Requirement: Favorites use an explicit folder choice
|
||||||
|
Ctrl+Shift+F within the extension SHALL open a searchable browser-bookmark folder picker. Saving SHALL use the chosen writable folder and extension shortcut URL rather than silently choosing an arbitrary location.
|
||||||
|
|
||||||
|
#### Scenario: User selects a bookmark folder
|
||||||
|
- **WHEN** the user confirms a writable folder and saves
|
||||||
|
- **THEN** the extension shortcut is saved in that folder
|
||||||
|
|
||||||
|
### Requirement: Bookmark discovery keeps Last update first
|
||||||
|
When the shortcut is not bookmarked, the header SHALL show a prominent shortcut hint for 20 seconds. Last update SHALL remain the first badge and other badges SHALL appear to its right.
|
||||||
|
|
||||||
|
#### Scenario: Favorite is saved while the hint is visible
|
||||||
|
- **WHEN** the shortcut is successfully added to bookmarks
|
||||||
|
- **THEN** the favorite hint is removed without waiting for the remaining timeout
|
||||||
|
|
||||||
|
### Requirement: Non-full-page modals can be dragged by their header
|
||||||
|
Non-full-page modals SHALL support dragging from noninteractive areas of their title bar, allowing up to 70 percent of their width and height beyond viewport edges. Header controls SHALL retain their own interactions and full-page views SHALL not become draggable.
|
||||||
|
|
||||||
|
#### Scenario: Drag exceeds viewport edge
|
||||||
|
- **WHEN** the user drags a movable modal far outside the viewport
|
||||||
|
- **THEN** movement is clamped so at least 30 percent of each modal dimension remains within the allowed bounds
|
||||||
|
|
||||||
|
### Requirement: Shared presentation remains consistent
|
||||||
|
Modal titles, spacing, loading skeletons and table presentation SHALL follow the extension's shared visual patterns in light and dark themes. User-facing labels SHALL use English, with service-provided names preserved.
|
||||||
|
|
||||||
|
#### Scenario: Customer detail is opened while loading
|
||||||
|
- **WHEN** the user opens a customer table before its data arrives
|
||||||
|
- **THEN** its title and loading skeleton use the shared modal styling
|
||||||
|
|
||||||
|
### Requirement: Print output reflects the current opportunity view
|
||||||
|
The print action beside Booking/Workload filters SHALL produce a landscape-oriented browser print layout containing active filter summaries, indicators and the opportunity list with page padding.
|
||||||
|
|
||||||
|
#### Scenario: All customers and owners are selected
|
||||||
|
- **WHEN** the user opens the print layout with unrestricted Customer and Owner selections
|
||||||
|
- **THEN** it labels them `All customers` and `All owners` instead of expanding every selected name
|
||||||
53
openspec/specs/manage-time/spec.md
Normal file
53
openspec/specs/manage-time/spec.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# manage-time Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Let authorized users review and maintain weekly time entries with validated hours and explicit, traceable persistence.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Manage Time requires profile and session readiness
|
||||||
|
Manage Time SHALL remain unavailable to `SALES_REP` users. For eligible users, its launcher SHALL remain disabled until SR session readiness, and data request entry points SHALL enforce the profile gate before loading time data.
|
||||||
|
|
||||||
|
#### Scenario: Restricted profile with a ready session
|
||||||
|
- **WHEN** SR session preparation succeeds for a user with `SALES_REP`
|
||||||
|
- **THEN** Manage Time remains hidden and opening it does not send its data requests
|
||||||
|
|
||||||
|
### Requirement: Week navigation preserves the user's work
|
||||||
|
Manage Time SHALL restore its selected reference date, navigate in seven-day increments and retain local drafts while moving between weeks within the modal. Late responses for a different week or closed modal SHALL not replace active values.
|
||||||
|
|
||||||
|
#### Scenario: Navigate away and back
|
||||||
|
- **WHEN** the user edits an unsaved week, navigates to another week and returns
|
||||||
|
- **THEN** the earlier week's local edits remain available
|
||||||
|
|
||||||
|
### Requirement: Entry selections are dependent and searchable
|
||||||
|
Service Request, Activity and Task Type selections SHALL support autocomplete. Activities SHALL correspond to the chosen SR, task types SHALL match Service or Non Service rows, and duplicate SR selection SHALL be rejected with row feedback.
|
||||||
|
|
||||||
|
#### Scenario: Duplicate SR
|
||||||
|
- **WHEN** a user selects an SR already used by another row
|
||||||
|
- **THEN** the previous selection is restored and the existing row is highlighted
|
||||||
|
|
||||||
|
### Requirement: Hours are validated before persistence
|
||||||
|
Each entered daily hour value SHALL be numeric and within the inclusive range 0 through 24. The view SHALL show daily totals and reject invalid rows before any affected write.
|
||||||
|
|
||||||
|
#### Scenario: Invalid daily hours
|
||||||
|
- **WHEN** a user attempts to save a row containing 25 hours for one day
|
||||||
|
- **THEN** validation prevents that invalid time entry from being submitted
|
||||||
|
|
||||||
|
### Requirement: Save controls creation updates and deletions
|
||||||
|
Save SHALL create new entries with POST, update changed persisted entries with PUT and apply queued persisted deletions with DELETE. Removing an unsaved row SHALL remain local. Writes SHALL use the required session and current-resource context.
|
||||||
|
|
||||||
|
#### Scenario: Persisted row is removed before Save
|
||||||
|
- **WHEN** a user removes a persisted row without saving
|
||||||
|
- **THEN** the deletion remains queued and no DELETE is sent yet
|
||||||
|
|
||||||
|
#### Scenario: Partial save failure
|
||||||
|
- **WHEN** some entry operations succeed and another operation fails
|
||||||
|
- **THEN** successful results remain reflected locally and unfinished work remains available for retry without duplicating confirmed writes
|
||||||
|
|
||||||
|
### Requirement: Loading and failures are local to the time view
|
||||||
|
Manage Time SHALL display skeleton rows while loading and provide retry on data failure without discarding the user's current draft values.
|
||||||
|
|
||||||
|
#### Scenario: Week retrieval fails
|
||||||
|
- **WHEN** the active week's request fails
|
||||||
|
- **THEN** the modal exposes the failure and retry while protecting existing edits
|
||||||
46
openspec/specs/opportunity-calendar/spec.md
Normal file
46
openspec/specs/opportunity-calendar/spec.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# opportunity-calendar Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Present the filtered opportunities by close date in a vertically navigable calendar bounded by the chosen Reference.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Calendar is a Reference-bounded monthly view
|
||||||
|
Calendar SHALL show a continuous monthly view restricted to the selected Reference, with vertical scrolling and no month/week switch or previous/next navigation buttons. Switching between Overview and Calendar SHALL not fetch opportunities again.
|
||||||
|
|
||||||
|
#### Scenario: Reference spans multiple months
|
||||||
|
- **WHEN** the user opens Calendar for a multi-month Reference
|
||||||
|
- **THEN** all months in the Reference remain reachable by vertical scrolling, including months with no opportunities
|
||||||
|
|
||||||
|
### Requirement: Every daily opportunity has a card
|
||||||
|
Each day SHALL show cards for all matching opportunities with a scrollable daily region when necessary. A hidden-event overflow link SHALL not replace the extra cards.
|
||||||
|
|
||||||
|
#### Scenario: Many opportunities close on one day
|
||||||
|
- **WHEN** the day's cards exceed its available height
|
||||||
|
- **THEN** the user can scroll within the day to reach all cards and the count reflects all matching opportunities
|
||||||
|
|
||||||
|
### Requirement: Initial positioning includes adjacent weeks
|
||||||
|
When today is within Reference, Calendar SHALL position around the current week and adjust visible week sizing to include the preceding and following weeks where the Reference permits. Dates outside Reference SHALL remain excluded.
|
||||||
|
|
||||||
|
#### Scenario: Current week crosses a month boundary
|
||||||
|
- **WHEN** Calendar positions around today and the three-week interval crosses into another month
|
||||||
|
- **THEN** the rendered portions of those weeks are included in the positioning calculation
|
||||||
|
|
||||||
|
### Requirement: Dates retain their calendar-day meaning
|
||||||
|
Calendar SHALL use `EffectiveDate` as a date-only close date without timezone shifts. Opportunities with missing or invalid close dates SHALL remain available in a separate list, and fiscal week labels SHALL use Sunday-start weeks and a June-start fiscal year.
|
||||||
|
|
||||||
|
#### Scenario: API date includes an offset
|
||||||
|
- **WHEN** an opportunity close date contains a timezone offset
|
||||||
|
- **THEN** its card retains the API calendar day instead of shifting to an adjacent day
|
||||||
|
|
||||||
|
#### Scenario: Missing close date
|
||||||
|
- **WHEN** a filtered opportunity has no valid close date
|
||||||
|
- **THEN** it is accessible through the undated-opportunities list
|
||||||
|
|
||||||
|
### Requirement: Calendar details are read-only
|
||||||
|
Opportunity cards SHALL show stage information and open read-only details with the corresponding opportunity link. Calendar labels and controls SHALL be in English.
|
||||||
|
|
||||||
|
#### Scenario: User selects a card
|
||||||
|
- **WHEN** an opportunity card is clicked
|
||||||
|
- **THEN** its details are shown without changing the opportunity in Oracle
|
||||||
65
openspec/specs/opportunity-exploration/spec.md
Normal file
65
openspec/specs/opportunity-exploration/spec.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# opportunity-exploration Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Let users explore Oracle opportunities within a fiscal Reference using consistent filters, totals and sortable tabular data.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Queries use the user's default saved search
|
||||||
|
Opportunity queries SHALL resolve the unique saved search marked `isDefault=true` and use its `savedSearchNumber` in `copiedFrom` for every page. Missing or ambiguous default searches SHALL prevent a dependent opportunity query.
|
||||||
|
|
||||||
|
#### Scenario: Valid default search
|
||||||
|
- **WHEN** saved searches return exactly one valid default
|
||||||
|
- **THEN** the opportunity payload uses `queries/<savedSearchNumber>` and reuses it throughout pagination
|
||||||
|
|
||||||
|
#### Scenario: No valid default
|
||||||
|
- **WHEN** no unique default saved search is returned
|
||||||
|
- **THEN** the extension reports the failure instead of using a hard-coded saved-search identifier
|
||||||
|
|
||||||
|
### Requirement: Opportunities display before background enrichment
|
||||||
|
The extension SHALL collect the opportunity result pages before displaying the ready list, then start Service Request and product enrichment without waiting for them to finish. Responses from an obsolete Reference request SHALL not replace the active result.
|
||||||
|
|
||||||
|
#### Scenario: SR remains pending
|
||||||
|
- **WHEN** opportunity pagination completes and Service Request loading remains pending
|
||||||
|
- **THEN** the opportunity table is already usable
|
||||||
|
|
||||||
|
#### Scenario: Reference changes during loading
|
||||||
|
- **WHEN** an older query completes after a newer Reference query has started
|
||||||
|
- **THEN** the older result does not overwrite the active view
|
||||||
|
|
||||||
|
### Requirement: Reference uses Oracle fiscal periods
|
||||||
|
The selected Reference SHALL determine the query date range using a June-through-May fiscal year. Opportunity type selections SHALL also constrain the query; an empty type selection SHALL show an explanatory empty state without sending an opportunity query.
|
||||||
|
|
||||||
|
#### Scenario: Fiscal first quarter
|
||||||
|
- **WHEN** Reference resolves to the first fiscal quarter
|
||||||
|
- **THEN** the selected period covers June through August with the next quarter boundary excluded
|
||||||
|
|
||||||
|
### Requirement: Filters are coordinated across views
|
||||||
|
Overview and Calendar SHALL share search, Customer, Owner, Status, Stage, eligible SR/Action filters and Booking/Workload filters. Customer options SHALL narrow by selected Owners and Owner options by selected Customers without additional API calls.
|
||||||
|
|
||||||
|
#### Scenario: Customer narrows owner choices
|
||||||
|
- **WHEN** a customer associated with two owners is selected
|
||||||
|
- **THEN** the Owner choices contain the corresponding owners and widening the customer selection restores applicable choices
|
||||||
|
|
||||||
|
#### Scenario: All-selected item is clicked
|
||||||
|
- **WHEN** the user clicks an item while all choices in its multiselect or filter-badge group are selected
|
||||||
|
- **THEN** only that item remains selected and subsequent clicks can add other choices
|
||||||
|
|
||||||
|
#### Scenario: Booking isolation
|
||||||
|
- **WHEN** both Booking and Workload are selected and Booking is clicked
|
||||||
|
- **THEN** only Booking opportunities remain visible in both views
|
||||||
|
|
||||||
|
### Requirement: Overview preserves table meaning and ordering
|
||||||
|
Overview SHALL restore its last sort column and direction, display customer `PartyName`, label win probability `Prob.`, and give Name and Customer greater width priority. Monetary cells and their headers SHALL align right; other headers SHALL align left.
|
||||||
|
|
||||||
|
#### Scenario: Overview reopens
|
||||||
|
- **WHEN** the user reopens Overview after selecting a descending Amount sort
|
||||||
|
- **THEN** that ordering is restored and the displayed amount values determine the order
|
||||||
|
|
||||||
|
### Requirement: Indicators reflect the filtered opportunities
|
||||||
|
The opportunity count and stage amounts SHALL follow the active filters without issuing additional queries solely to calculate the displayed indicators.
|
||||||
|
|
||||||
|
#### Scenario: Local filter changes
|
||||||
|
- **WHEN** the user narrows the loaded list using a Customer filter
|
||||||
|
- **THEN** the count and stage indicators update with the filtered opportunities
|
||||||
68
openspec/specs/organization-charts/spec.md
Normal file
68
openspec/specs/organization-charts/spec.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# organization-charts Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Maintain customer organization charts locally while enriching people with Oracle contact information and related opportunities.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Charts are scoped and stored locally
|
||||||
|
Charts SHALL persist JSON-compatible records in extension-owned IndexedDB, scoped by user and customer in the browser profile. Records SHALL include revision and update metadata; stale saves SHALL be rejected. Email SHALL not persist in chart nodes.
|
||||||
|
|
||||||
|
#### Scenario: Concurrent chart change
|
||||||
|
- **WHEN** a user saves an outdated revision after another window has saved the chart
|
||||||
|
- **THEN** the save is rejected with guidance to reopen the current chart
|
||||||
|
|
||||||
|
#### Scenario: Browser profile is different
|
||||||
|
- **WHEN** the same user accesses the customer in another browser profile
|
||||||
|
- **THEN** this local chart is not automatically synchronized to that profile
|
||||||
|
|
||||||
|
### Requirement: Hierarchy editing preserves remaining people
|
||||||
|
The chart SHALL support adding people, changing their manager, notes, branch expansion/collapse, zoom and panning. It SHALL reject invalid hierarchies, support up to 500 people and 60 levels, and promote direct reports to the removed person's manager when a person is deleted.
|
||||||
|
|
||||||
|
#### Scenario: Manager is deleted
|
||||||
|
- **WHEN** a user deletes a person with direct reports
|
||||||
|
- **THEN** those reports remain in the chart under the deleted person's manager
|
||||||
|
|
||||||
|
#### Scenario: Unsaved chart is closed
|
||||||
|
- **WHEN** the user closes a chart with unsaved changes
|
||||||
|
- **THEN** the extension requests confirmation before discarding those changes
|
||||||
|
|
||||||
|
### Requirement: Contact autocomplete is debounced and customer-scoped
|
||||||
|
Name autocomplete SHALL search contacts after a 350 ms typing pause, filter by the customer's PartyId, display PartyName and JobTitle, and ignore superseded results. Empty queries and opening the chart alone SHALL not issue contact searches.
|
||||||
|
|
||||||
|
#### Scenario: User keeps typing
|
||||||
|
- **WHEN** another character arrives before the search delay expires
|
||||||
|
- **THEN** the pending search is superseded rather than sending one query per character
|
||||||
|
|
||||||
|
### Requirement: Person details refresh contact data on demand
|
||||||
|
Opening a stored person SHALL query contacts using the person's Name as keywords and match by contact PartyId. A matching result SHALL refresh Name and Job Title while preserving notes and reporting relationships; Email SHALL remain transient. Job Title SHALL be eligible for chart persistence.
|
||||||
|
|
||||||
|
#### Scenario: Contact data changed
|
||||||
|
- **WHEN** a matching contact response has a new name or job title
|
||||||
|
- **THEN** the working chart updates those fields and retains the person's notes and manager
|
||||||
|
|
||||||
|
### Requirement: Contact PartyNumber is preserved as an opaque identifier
|
||||||
|
Chart nodes SHALL retain the contact's PartyNumber, including alphanumeric values. Opening person details SHALL persist a returned PartyNumber; opportunity lookup SHALL use an existing stored value and attempt resolution when it is missing.
|
||||||
|
|
||||||
|
#### Scenario: Alphanumeric number is already saved
|
||||||
|
- **WHEN** opportunities are requested for a person with PartyNumber `A4KCS2P`
|
||||||
|
- **THEN** that exact value is used in `OpportunityContact.ContactPartyNumber`, without numeric conversion
|
||||||
|
|
||||||
|
### Requirement: Contact opportunity tables load all pages
|
||||||
|
The person card SHALL provide an opportunities action that opens `Opportunities | <name>`, shows a skeleton while loading and follows `hasMore` until all pages are collected. The resulting table SHALL be sortable and link each Name to its corresponding opportunity in a new tab.
|
||||||
|
|
||||||
|
#### Scenario: Contact has more than one result page
|
||||||
|
- **WHEN** the opportunities response sets `hasMore=true`
|
||||||
|
- **THEN** the next offset is queried using the same contact PartyNumber until the result is complete
|
||||||
|
|
||||||
|
### Requirement: New contacts are created explicitly
|
||||||
|
The contact-creation form SHALL require First Name, Last Name and Email, map email/phone consent to their corresponding Do/DoNot flags and obtain account identifiers from the selected customer. Provided phone components SHALL be combined as country code, hyphen and number. Save SHALL submit directly without a payload-preview step.
|
||||||
|
|
||||||
|
#### Scenario: Optional phone is omitted
|
||||||
|
- **WHEN** required contact fields are valid but a phone number is not filled
|
||||||
|
- **THEN** that absent phone value is not included as a populated contact field
|
||||||
|
|
||||||
|
#### Scenario: Phone components are entered
|
||||||
|
- **WHEN** country code `55` and phone number `99999999999` are provided
|
||||||
|
- **THEN** the submitted raw phone value is `55-99999999999`
|
||||||
46
openspec/specs/preferences-cache/spec.md
Normal file
46
openspec/specs/preferences-cache/spec.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# preferences-cache Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Preserve user preferences and reuse eligible data without allowing expired or invalidated results to silently replace fresh information.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Preferences survive reopening
|
||||||
|
The extension SHALL persist supported filter preferences, theme, opportunity type selections and cache duration in the browser profile. Invalid or unavailable stored values SHALL fall back to usable defaults or in-memory state.
|
||||||
|
|
||||||
|
#### Scenario: Stored preference cannot be read
|
||||||
|
- **WHEN** browser storage is unavailable or a saved preference is invalid
|
||||||
|
- **THEN** the extension remains usable with its fallback rather than failing to open
|
||||||
|
|
||||||
|
### Requirement: Sort preferences belong to individual tables
|
||||||
|
Supported sortable tables SHALL retain their own last sort key and direction without changing other tables' preferences. Product detail tables SHALL remain excluded from this sorting behavior.
|
||||||
|
|
||||||
|
#### Scenario: Two tables use different sorts
|
||||||
|
- **WHEN** the user sorts Installed Base by amount and contact opportunities by close date
|
||||||
|
- **THEN** reopening each table restores its own saved ordering
|
||||||
|
|
||||||
|
### Requirement: Cache reuse respects validity and identity boundaries
|
||||||
|
Product, workload and activity caches SHALL apply their configured lifetimes and existing identity/account/product-update scopes. A missing or expired activity cache SHALL not be represented as a confirmed empty result.
|
||||||
|
|
||||||
|
#### Scenario: Workload token rotates
|
||||||
|
- **WHEN** the same user's account workload data is still valid but the authorization token changes
|
||||||
|
- **THEN** the account data can be reused without treating the token string as a different customer identity
|
||||||
|
|
||||||
|
### Requirement: Only eligible reads are reused
|
||||||
|
Form request caching SHALL reuse only known read operations, share concurrent reads and invalidate applicable entries after successful writes. Create, update and delete operations SHALL not be served from a response cache.
|
||||||
|
|
||||||
|
#### Scenario: Repeated write
|
||||||
|
- **WHEN** the user explicitly submits a new valid write operation
|
||||||
|
- **THEN** the operation goes through the write transport rather than returning a cached success
|
||||||
|
|
||||||
|
### Requirement: Refresh all invalidates reusable data safely
|
||||||
|
Refresh all SHALL invalidate product/activity and related cached data before reloading the active Reference, preserve user preferences and authentication, and prevent pre-invalidation responses from repopulating invalidated cache entries. Cache invalidation errors SHALL be reported.
|
||||||
|
|
||||||
|
#### Scenario: Old product request finishes after refresh
|
||||||
|
- **WHEN** an old request completes after its cache has been invalidated
|
||||||
|
- **THEN** its result does not restore the invalidated cache snapshot
|
||||||
|
|
||||||
|
#### Scenario: Activity updates arrive
|
||||||
|
- **WHEN** only background activity lookup results change
|
||||||
|
- **THEN** those lookups do not independently advance the product Last update timestamp
|
||||||
57
openspec/specs/product-workloads/spec.md
Normal file
57
openspec/specs/product-workloads/spec.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# product-workloads Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Enrich opportunities with products and workload information and support review and explicit updates of consumption projections.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Product enrichment runs in the background
|
||||||
|
Product loading SHALL update opportunity product counts and progress without blocking the displayed opportunity list. Product detail tables SHALL retain their source order rather than applying the general table sorting preference.
|
||||||
|
|
||||||
|
#### Scenario: Some products are still loading
|
||||||
|
- **WHEN** opportunities are visible and their products have not all arrived
|
||||||
|
- **THEN** completed product results update their related opportunities while remaining products continue loading
|
||||||
|
|
||||||
|
### Requirement: Workload retrieval is shared by customer
|
||||||
|
Workload retrieval SHALL consolidate all result pages for each distinct customer account, reuse eligible cached results and correlate workloads to products by revenue identifier. Concurrent consumers of the same account SHALL share loading.
|
||||||
|
|
||||||
|
#### Scenario: Multiple opportunities share a customer
|
||||||
|
- **WHEN** their products require workloads for the same account
|
||||||
|
- **THEN** one shared account retrieval supplies the matching products and continues while the response indicates more pages
|
||||||
|
|
||||||
|
### Requirement: Booking classification requires loaded products without Workload names
|
||||||
|
An opportunity with a nonempty loaded product list SHALL display a distinct Booking badge when every product has a blank or `-` Workload name. If any product has a Workload name, the existing ramp badge behavior SHALL remain.
|
||||||
|
|
||||||
|
#### Scenario: No products have an assigned Workload
|
||||||
|
- **WHEN** all loaded products have blank or `-` Workload names
|
||||||
|
- **THEN** Booking replaces Ramp Months and the opportunity matches the Booking filter
|
||||||
|
|
||||||
|
#### Scenario: Mixed product assignments
|
||||||
|
- **WHEN** at least one product has a Workload name
|
||||||
|
- **THEN** the opportunity retains its ramp behavior and is not classified as Booking
|
||||||
|
|
||||||
|
### Requirement: Projection access is independent of a difference alert
|
||||||
|
Clicking a ramp badge SHALL open consumption projection even when forecast and calculated amounts match. Difference alerts SHALL be placed between ramp and SR badges; product alerts without a workload record SHALL indicate unavailability with disabled styling.
|
||||||
|
|
||||||
|
#### Scenario: Forecast matches calculation
|
||||||
|
- **WHEN** the user clicks a ramp badge for a product with no forecast difference
|
||||||
|
- **THEN** Consumption projection still opens
|
||||||
|
|
||||||
|
### Requirement: Projection comparison uses calculated monthly values
|
||||||
|
Consumption projection SHALL calculate monthly amounts using adjusted ACR, ramp months and first-month proration, and compare forecast minus calculated amounts at cent precision.
|
||||||
|
|
||||||
|
#### Scenario: Monthly discrepancy
|
||||||
|
- **WHEN** a calculated monthly amount differs from its forecast after rounding to cents
|
||||||
|
- **THEN** the comparison shows the difference and enables the corresponding alert
|
||||||
|
|
||||||
|
### Requirement: Estimate updates require confirmation
|
||||||
|
Update estimates SHALL ask for confirmation before writing calculated values to SPA, prevent duplicate submissions and report success or failure. Successful updates SHALL refresh the comparison and invalidate affected workload cache data.
|
||||||
|
|
||||||
|
#### Scenario: Confirmation is cancelled
|
||||||
|
- **WHEN** the user declines the update confirmation
|
||||||
|
- **THEN** no forecast update is sent
|
||||||
|
|
||||||
|
#### Scenario: Confirmed update succeeds
|
||||||
|
- **WHEN** the user confirms and the update request succeeds
|
||||||
|
- **THEN** the comparison reflects the new values and subsequent workload reads do not reuse the invalidated values
|
||||||
53
openspec/specs/request-debugging/spec.md
Normal file
53
openspec/specs/request-debugging/spec.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# request-debugging Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Make extension HTTP activity inspectable without pre-rendering an ever-growing request interface or confusing cached data with new network operations.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: HTTP activity has a visible lifecycle
|
||||||
|
Debug Requests SHALL record request identity, method, URL, timing, headers, body, response and final status when available. Running requests SHALL transition to success or failure, cache hits SHALL not create fictitious HTTP requests, and entries SHALL be grouped by request category.
|
||||||
|
|
||||||
|
#### Scenario: Request is pending
|
||||||
|
- **WHEN** a logged request has started but not completed
|
||||||
|
- **THEN** it is displayed as running with its start time and later updates with completion or failure
|
||||||
|
|
||||||
|
### Requirement: Request history is persisted per tab context
|
||||||
|
Request records SHALL be stored in page localStorage under a tab-specific namespace. Closing the main modal SHALL preserve them. Starting page one of a new opportunity query SHALL reset that history; later pages of the same query SHALL retain it.
|
||||||
|
|
||||||
|
#### Scenario: Modal closes and reopens
|
||||||
|
- **WHEN** the main modal closes and reopens without a new opportunity query
|
||||||
|
- **THEN** the existing debug history remains available
|
||||||
|
|
||||||
|
#### Scenario: New query begins
|
||||||
|
- **WHEN** the first opportunity page is about to be sent
|
||||||
|
- **THEN** old history is cleared for that tab without deleting preferences or another tab's debug records
|
||||||
|
|
||||||
|
### Requirement: Debug rendering is deferred
|
||||||
|
The hidden debug view SHALL avoid constructing request-detail DOM. Opening the view SHALL render request summaries and expanding a request SHALL construct its details, preserving expansion and scroll state across updates where applicable.
|
||||||
|
|
||||||
|
#### Scenario: Requests accumulate while debug is closed
|
||||||
|
- **WHEN** requests complete while the debug panel is hidden
|
||||||
|
- **THEN** records are captured without mounting their detail elements in the hidden panel
|
||||||
|
|
||||||
|
### Requirement: Debug access stays above other extension views
|
||||||
|
Ctrl+Shift+D SHALL toggle Debug Requests as a full-page extension view above other extension modals. Closing it SHALL return to the underlying view without opening a browser tab or native fullscreen mode.
|
||||||
|
|
||||||
|
#### Scenario: Debug opens over person details
|
||||||
|
- **WHEN** the user presses Ctrl+Shift+D while a nested modal is open
|
||||||
|
- **THEN** Debug Requests appears in front of that modal
|
||||||
|
|
||||||
|
### Requirement: Sensitive-value masking is a display control
|
||||||
|
Debug SHALL hide sensitive values by default and support explicit reveal of retained originals. Documentation SHALL distinguish masking from storage protection: captured debug records can retain sensitive response values and masking SHALL not be described as encryption or guaranteed redaction at rest.
|
||||||
|
|
||||||
|
#### Scenario: Identity token is captured
|
||||||
|
- **WHEN** the identity response includes a token
|
||||||
|
- **THEN** normal debug display hides the token and the explicit visibility control can reveal its retained value
|
||||||
|
|
||||||
|
### Requirement: Debug failures do not interrupt application requests
|
||||||
|
Unavailable or full localStorage SHALL leave records available in memory with explanatory feedback. Debug timers and persistence errors SHALL not prevent the underlying network request from starting or completing.
|
||||||
|
|
||||||
|
#### Scenario: Storage quota is exhausted
|
||||||
|
- **WHEN** persisting a request record fails due to localStorage quota
|
||||||
|
- **THEN** the request outcome remains available in memory and the application request continues normally
|
||||||
57
openspec/specs/service-requests/spec.md
Normal file
57
openspec/specs/service-requests/spec.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# service-requests Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Expose service requests linked to opportunities and customers, with progressive status badges, detail views and explicit record creation.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: CRM SR queries resolve their saved search
|
||||||
|
Before the first CRM Service Request page, the extension SHALL retrieve SR search resources using the general Token Relay Bearer and use the first item's valid `savedSearchNumber` as `queries/<savedSearchNumber>` in `copiedFrom`.
|
||||||
|
|
||||||
|
#### Scenario: Search resources have no identifier
|
||||||
|
- **WHEN** the first item has no valid saved-search number
|
||||||
|
- **THEN** the dependent CRM SR query fails explicitly instead of using a fixed fallback
|
||||||
|
|
||||||
|
### Requirement: SR enrichment is progressive and nonblocking
|
||||||
|
For eligible users, CRM SR pages SHALL load after opportunities are displayed and update their linked badges in the background. The query SHALL select pending-delivery team SRs and match opportunities through the returned opportunity number.
|
||||||
|
|
||||||
|
#### Scenario: First SR page arrives
|
||||||
|
- **WHEN** more SR pages remain after the first successful page
|
||||||
|
- **THEN** information received so far can update the visible opportunities without waiting for all pages
|
||||||
|
|
||||||
|
### Requirement: Failed SR loading remains distinguishable from no matches
|
||||||
|
The extension SHALL distinguish loading, failed and confirmed empty SR results. Pagination SHALL detect lack of progress, retry each failed page at most five total attempts, and stop work superseded by a newer query.
|
||||||
|
|
||||||
|
#### Scenario: SR lookup fails
|
||||||
|
- **WHEN** a lookup cannot establish whether an opportunity has matching SRs
|
||||||
|
- **THEN** its badge indicates an unknown or failed state rather than confirmed absence
|
||||||
|
|
||||||
|
#### Scenario: Successful SR snapshot is reused
|
||||||
|
- **WHEN** a subsequent cycle can reuse the completed SR result within the current page session
|
||||||
|
- **THEN** it does not repeat the completed query unless explicitly invalidated
|
||||||
|
|
||||||
|
### Requirement: Customer SR details use the resolved account identifier
|
||||||
|
Customer Service Requests SHALL resolve account summary first, use its `id` for the customer SR query and consolidate pages of open SRs. The dialog SHALL provide a loading skeleton, searchable sortable results, and guards against late results after closure.
|
||||||
|
|
||||||
|
#### Scenario: Account has distinct identifiers
|
||||||
|
- **WHEN** account summary returns different `id` and `registryId` values
|
||||||
|
- **THEN** customer SR retrieval uses `id`, not `registryId`
|
||||||
|
|
||||||
|
### Requirement: Internal SR recovery is bounded
|
||||||
|
Internal SR details SHALL report failed requests and use bounded, shared session recovery rather than continuously issuing requests or opening a visible recovery tab.
|
||||||
|
|
||||||
|
#### Scenario: Endpoint remains unavailable
|
||||||
|
- **WHEN** an Internal SR read continues to fail after its allowed recovery attempts
|
||||||
|
- **THEN** the view shows failure and does not keep sending an unbounded automatic request loop
|
||||||
|
|
||||||
|
### Requirement: SR creation validates before writing
|
||||||
|
Service Request creation SHALL require valid form inputs before submission, retain entered data on failure and prevent duplicate submissions while saving. Internal SR creation SHALL resolve parent SR and current resource information before linking the new record to its parent.
|
||||||
|
|
||||||
|
#### Scenario: Required fields are missing
|
||||||
|
- **WHEN** Save is clicked on an incomplete SR form
|
||||||
|
- **THEN** the missing fields are highlighted and no create request is sent
|
||||||
|
|
||||||
|
#### Scenario: Create request fails
|
||||||
|
- **WHEN** a submitted SR creation fails
|
||||||
|
- **THEN** the user receives error feedback and their form values remain available
|
||||||
56
openspec/specs/session-authentication/spec.md
Normal file
56
openspec/specs/session-authentication/spec.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# session-authentication Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Establish authenticated Oracle access before dependent data operations while preserving credential boundaries and profile restrictions.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Valid identity precedes dependent data requests
|
||||||
|
The extension SHALL obtain a successful current-user response containing a valid user identifier, name, roles array and token before starting dependent opportunity or activity data requests. Concurrent identity consumers SHALL share acquisition, and invalid responses SHALL retry with backoff.
|
||||||
|
|
||||||
|
#### Scenario: HTML is returned with HTTP 200
|
||||||
|
- **WHEN** current-user acquisition receives a login document instead of the required JSON
|
||||||
|
- **THEN** dependent data requests remain blocked and identity acquisition retries
|
||||||
|
|
||||||
|
#### Scenario: Identity is already established
|
||||||
|
- **WHEN** the user changes Reference or reopens the main modal in the same page session
|
||||||
|
- **THEN** the extension reuses the successful identity without another current-user HTTP request
|
||||||
|
|
||||||
|
### Requirement: SPA recovery verifies the loaded session page
|
||||||
|
SPA session recovery SHALL open the configured bootstrap URL in an inactive auxiliary tab and verify page readiness before probing identity. Login forms, error documents and OAuth callbacks SHALL not count as a ready SPA page.
|
||||||
|
|
||||||
|
#### Scenario: Recovery navigation
|
||||||
|
- **WHEN** identity recovery requires a SPA session
|
||||||
|
- **THEN** navigation starts at `https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace`
|
||||||
|
- **AND** the extension preserves the originating browser context and closes its owned auxiliary tab when recovery finishes
|
||||||
|
|
||||||
|
#### Scenario: Rejected callback document
|
||||||
|
- **WHEN** recovery encounters an authorization-error callback
|
||||||
|
- **THEN** it restarts from the configured bootstrap URL instead of replaying that callback as a successful session
|
||||||
|
|
||||||
|
### Requirement: SR session preparation has independent progress
|
||||||
|
SR session preparation SHALL be able to run concurrently with SPA identity preparation, using the SeaaS external application URL. It SHALL expose completion or failure and allow a user retry after failure.
|
||||||
|
|
||||||
|
#### Scenario: SR preparation fails
|
||||||
|
- **WHEN** preparation through `https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/` fails or reaches its 70-second timeout
|
||||||
|
- **THEN** the extension offers retry rather than declaring the SR session ready
|
||||||
|
|
||||||
|
### Requirement: SALES_REP activity access is restricted
|
||||||
|
For a user whose roles include `SALES_REP`, the extension SHALL hide CRM SR and Workload Action badges and Manage Time access, and SHALL not issue their dependent activity or Manage Time data requests.
|
||||||
|
|
||||||
|
#### Scenario: Restricted user after successful authentication
|
||||||
|
- **WHEN** identity resolves with the `SALES_REP` role
|
||||||
|
- **THEN** opportunities remain available but CRM activity badges and Manage Time are unavailable
|
||||||
|
- **AND** attempting to open Manage Time does not load its data
|
||||||
|
|
||||||
|
### Requirement: Credentials are specific to each Oracle service
|
||||||
|
The extension SHALL preserve operation-specific credentials and cookie inclusion. It SHALL use the general Token Relay Bearer for Fusion queries and the appropriate SPA consumer authorization for each SPA service rather than substituting token families.
|
||||||
|
|
||||||
|
#### Scenario: Customer SR authorization
|
||||||
|
- **WHEN** customer service requests are loaded using SPA-ORANGE authorization
|
||||||
|
- **THEN** the raw authorization value is sent in `custsr-auth` without a Bearer prefix
|
||||||
|
|
||||||
|
#### Scenario: Token Relay does not finish
|
||||||
|
- **WHEN** a Token Relay request exceeds its request timeout
|
||||||
|
- **THEN** the attempt reports failure instead of remaining indefinitely pending in Debug Requests
|
||||||
42
openspec/specs/workload-actions/spec.md
Normal file
42
openspec/specs/workload-actions/spec.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# workload-actions Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Identify workload actions owned by the signed-in user and allow eligible users to create actions for opportunity products.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Action enrichment respects prerequisite data
|
||||||
|
Workload Action enrichment SHALL use distinct product revenue identifiers after the product cycle and successful or reused CRM SR loading. Unavailable SR data SHALL leave action state unknown rather than treating it as an empty result.
|
||||||
|
|
||||||
|
#### Scenario: SR prerequisite fails
|
||||||
|
- **WHEN** CRM SR loading fails for a completed product cycle
|
||||||
|
- **THEN** action enrichment is blocked and its badges do not assert that no actions exist
|
||||||
|
|
||||||
|
### Requirement: Ownership determines the action badge
|
||||||
|
The action badge SHALL aggregate returned actions for the opportunity products and mark a match when an action owner equals the general Token Relay principal. Missing principal or failed lookups SHALL not count as confirmed nonmatches.
|
||||||
|
|
||||||
|
#### Scenario: Actions exist for other owners
|
||||||
|
- **WHEN** returned actions have no owner matching the known principal
|
||||||
|
- **THEN** the badge indicates no matching ownership while its tooltip retains the returned actions
|
||||||
|
|
||||||
|
### Requirement: Read attempts are bounded and partial coverage is disclosed
|
||||||
|
Each action lookup SHALL make at most five total attempts and stop on success or session supersession. The current lookup SHALL expose `hasNext` as additional unqueried actions rather than implying that all pages were loaded.
|
||||||
|
|
||||||
|
#### Scenario: More action pages exist
|
||||||
|
- **WHEN** a successful action response sets `hasNext=true`
|
||||||
|
- **THEN** the tooltip states that additional actions exist and pagination is not enabled
|
||||||
|
|
||||||
|
### Requirement: Action forms use validated lookup selections
|
||||||
|
The Action form SHALL load team members and lookup values with the workload credential, support searchable selections, and submit selected codes rather than arbitrary typed search text. Required inputs and the date range SHALL be validated before Save.
|
||||||
|
|
||||||
|
#### Scenario: Search text is not an option
|
||||||
|
- **WHEN** the user types a value without selecting a valid lookup result
|
||||||
|
- **THEN** the text is not submitted as a valid option value
|
||||||
|
|
||||||
|
### Requirement: Action creation is explicit and preserves failure state
|
||||||
|
Saving a valid action SHALL send current-user attribution, UTC dates and a notes array, lock duplicate submission while pending and show a result toast. A failed create SHALL retain the form without automatically retrying the POST.
|
||||||
|
|
||||||
|
#### Scenario: Create fails
|
||||||
|
- **WHEN** the server rejects an action creation
|
||||||
|
- **THEN** the form remains available with the entered data and no automatic duplicate POST is issued
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "opportunities-extension",
|
"name": "opportunities-extension",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"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 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"
|
"test": "node scripts/test.cjs",
|
||||||
|
"preview": "node scripts/preview.mjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, readFile } 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";
|
||||||
|
import output from "./write-output.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 spaPageScript = composition.composeScript("spa-page");
|
||||||
|
const shortcutScript = await readFile(path.join(rootDir, "src/content/shortcut-intent.js"), "utf8");
|
||||||
const checkOnly = process.argv.includes("--check");
|
const checkOnly = process.argv.includes("--check");
|
||||||
|
|
||||||
const baseManifest = {
|
const baseManifest = {
|
||||||
manifest_version: 3,
|
manifest_version: 3,
|
||||||
name: "Opportunities Extension",
|
name: "Opportunities Extension",
|
||||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
version: "1.0.1",
|
version: "1.1.0",
|
||||||
permissions: [
|
permissions: [
|
||||||
|
"bookmarks",
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage"
|
"storage",
|
||||||
|
"webRequest",
|
||||||
|
"declarativeNetRequestWithHostAccess"
|
||||||
],
|
],
|
||||||
host_permissions: [
|
host_permissions: [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*"
|
"https://spa.oracle.com/*",
|
||||||
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
content_scripts: [
|
content_scripts: [
|
||||||
|
{
|
||||||
|
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
|
||||||
|
js: ["shortcut-intent.js"],
|
||||||
|
all_frames: false,
|
||||||
|
run_at: "document_start"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
matches: [
|
matches: [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
||||||
@@ -31,6 +44,12 @@ const baseManifest = {
|
|||||||
js: ["content.js"],
|
js: ["content.js"],
|
||||||
all_frames: true,
|
all_frames: true,
|
||||||
run_at: "document_idle"
|
run_at: "document_idle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matches: ["https://spa.oracle.com/*"],
|
||||||
|
js: ["spa-page.js"],
|
||||||
|
all_frames: false,
|
||||||
|
run_at: "document_end"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -61,23 +80,26 @@ const targets = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!checkOnly) {
|
|
||||||
await rm(distDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const manifestJson = `${JSON.stringify(target.manifest, null, 2)}\n`;
|
const manifestJson = `${JSON.stringify(target.manifest, null, 2)}\n`;
|
||||||
JSON.parse(manifestJson);
|
JSON.parse(manifestJson);
|
||||||
|
|
||||||
if (checkOnly) {
|
if (checkOnly) {
|
||||||
|
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript, "spa-page.js": spaPageScript, "shortcut-intent.js": shortcutScript };
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputDir = path.join(distDir, target.name);
|
const outputDir = path.join(distDir, target.name);
|
||||||
await mkdir(outputDir, { recursive: true });
|
await mkdir(outputDir, { recursive: true });
|
||||||
await writeFile(path.join(outputDir, "manifest.json"), manifestJson);
|
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
|
||||||
await writeFile(path.join(outputDir, "content.js"), contentScript);
|
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript);
|
||||||
await writeFile(path.join(outputDir, "background.js"), backgroundScript);
|
await output.writeOutput(path.join(outputDir, "spa-page.js"), spaPageScript);
|
||||||
|
await output.writeOutput(path.join(outputDir, "shortcut-intent.js"), shortcutScript);
|
||||||
|
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checkOnly) {
|
if (!checkOnly) {
|
||||||
|
|||||||
39
scripts/compose.cjs
Normal file
39
scripts/compose.cjs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const vm = require("node:vm");
|
||||||
|
const sources = require("./sources.cjs");
|
||||||
|
const vendorSources = { content: ["src/vendor/fullcalendar/index.global.min.js", "src/vendor/d3-hierarchy/d3-hierarchy.min.js"], background: [] };
|
||||||
|
|
||||||
|
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 hierarchySource = fs.readFileSync(path.join(root, vendorSources.content[1]), "utf8");
|
||||||
|
const hierarchyLicense = fs.readFileSync(path.join(root, "src/vendor/d3-hierarchy/LICENSE"), "utf8");
|
||||||
|
const iconLicense = fs.readFileSync(path.join(root, "src/vendor/lucide/LICENSE"), "utf8");
|
||||||
|
parts.unshift(`/* ${iconLicense} */\nlet organizationLayout;\nfunction loadOrganizationLayout() {\nif (!organizationLayout) { const exports = {}; const module = { exports };\n/* ${hierarchyLicense} */\n${hierarchySource}\norganizationLayout = exports; }\nreturn organizationLayout;\n}`);
|
||||||
|
const calendarLibrary = fs.readFileSync(path.join(root, vendorSources.content[0]), "utf8");
|
||||||
|
const calendarLicense = fs.readFileSync(path.join(root, "src/vendor/fullcalendar/LICENSE.md"), "utf8");
|
||||||
|
parts.unshift(`function loadOpportunityCalendarLibrary() {\n/* ${calendarLicense} */\n${calendarLibrary}\nreturn FullCalendar;\n}`);
|
||||||
|
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
|
||||||
|
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css", "src/content/styles/dialogs.css")
|
||||||
|
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||||
|
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
||||||
|
}
|
||||||
|
const factory = { content: "createContentApplication", background: "createBackgroundApplication", "spa-page": "createSpaPage" }[target];
|
||||||
|
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, vendorSources };
|
||||||
46
scripts/preview.mjs
Normal file
46
scripts/preview.mjs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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 === "/org-storage-fixture.js") {
|
||||||
|
const files = ["../src/features/org-chart/model.js", "../src/features/org-chart/storage.js"];
|
||||||
|
const parts = [];
|
||||||
|
for (const file of files) parts.push(await readFile(new URL(file, import.meta.url), "utf8"));
|
||||||
|
response.setHeader("Content-Type", "text/javascript");
|
||||||
|
response.end(parts.join("\n") + "\nwindow.fixtureOrgStorage = createOrgChartStorage({ environment: { indexedDB }, model: createOrgChartModel() });");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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}/`);
|
||||||
|
});
|
||||||
131
scripts/sources.cjs
Normal file
131
scripts/sources.cjs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
module.exports = {
|
||||||
|
"spa-page": ["src/auth/spa-page.js"],
|
||||||
|
"content": [
|
||||||
|
"src/features/org-chart/model.js",
|
||||||
|
"src/features/org-chart/icons.js",
|
||||||
|
"src/features/org-chart/calling-codes.js",
|
||||||
|
"src/features/org-chart/view.js",
|
||||||
|
"src/services/fusion/account-contact.js",
|
||||||
|
"src/features/preferences/table-sorting.js",
|
||||||
|
"src/shared/time-dates.js",
|
||||||
|
"src/features/manage-time/view.js",
|
||||||
|
"src/shared/messages.js",
|
||||||
|
"src/shared/debug-body.js",
|
||||||
|
"src/content/contracts.js",
|
||||||
|
"src/auth/session-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/preferences/request-cache.js",
|
||||||
|
"src/features/products/content-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/features/opportunities/print.js",
|
||||||
|
"src/features/opportunities/badge-content.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/content/dialog-drag.js",
|
||||||
|
"src/auth/content-session.js",
|
||||||
|
"src/auth/current-user.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/services/fusion/crm-service-requests.js",
|
||||||
|
"src/features/opportunities/crm-service-requests.js",
|
||||||
|
"src/features/opportunities/product-activities.js",
|
||||||
|
"src/features/opportunities/refresh-all.js",
|
||||||
|
"src/features/opportunities/activity-cache.js",
|
||||||
|
"src/features/opportunities/activity-progress.js",
|
||||||
|
"src/features/opportunities/activity-filters.js",
|
||||||
|
"src/features/opportunities/crm-sr-popover.js",
|
||||||
|
"src/features/opportunities/crm-sr-form.js",
|
||||||
|
"src/features/opportunities/internal-sr-form.js",
|
||||||
|
"src/features/opportunities/searchable-select.js",
|
||||||
|
"src/features/opportunities/internal-sr-session.js",
|
||||||
|
"src/features/opportunities/sr-session-preparation.js",
|
||||||
|
"src/features/opportunities/workload-actions.js",
|
||||||
|
"src/features/opportunities/workload-action-form.js",
|
||||||
|
"src/features/opportunities/workload-action-options.js",
|
||||||
|
"src/features/opportunities/fiscal-periods.js",
|
||||||
|
"src/features/opportunities/table.js",
|
||||||
|
"src/features/opportunities/calendar.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/workloads.js",
|
||||||
|
"src/features/products/details.js",
|
||||||
|
"src/features/products/ramp-projection.js",
|
||||||
|
"src/features/products/forecast-update.js",
|
||||||
|
"src/features/products/ramp-projection-view.js",
|
||||||
|
"src/features/consumption/plans-action.js",
|
||||||
|
"src/features/install-base/action.js",
|
||||||
|
"src/features/install-base/view.js",
|
||||||
|
"src/features/opportunities/customer-table-search.js",
|
||||||
|
"src/features/service-requests/action.js",
|
||||||
|
"src/features/service-requests/view.js",
|
||||||
|
"src/features/consumption/plans-view.js",
|
||||||
|
"src/features/consumption/header-view.js",
|
||||||
|
"src/features/ratecard/model.js",
|
||||||
|
"src/features/ratecard/controller.js",
|
||||||
|
"src/features/ratecard/view.js",
|
||||||
|
"src/features/consumption/plan-detail-controller.js",
|
||||||
|
"src/features/consumption/plan-detail-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/features/org-chart/model.js",
|
||||||
|
"src/features/org-chart/storage.js",
|
||||||
|
"src/shared/time-dates.js",
|
||||||
|
"src/services/fusion/time-entries.js",
|
||||||
|
"src/shared/messages.js",
|
||||||
|
"src/shared/debug-body.js",
|
||||||
|
"src/background/contracts.js",
|
||||||
|
"src/features/products/cache-state.js",
|
||||||
|
"src/auth/consumer-session.js",
|
||||||
|
"src/auth/spa-credentials.js",
|
||||||
|
"src/auth/sr-request-headers.js",
|
||||||
|
"src/services/spa/accounts.js",
|
||||||
|
"src/services/spa/install-base.js",
|
||||||
|
"src/services/spa/customer-service-requests.js",
|
||||||
|
"src/services/spa/workload-actions.js",
|
||||||
|
"src/services/spa/workloads.js",
|
||||||
|
"src/features/products/workloads-cache.js",
|
||||||
|
"src/services/spa/action-options.js",
|
||||||
|
"src/services/fusion/sr-details.js",
|
||||||
|
"src/services/fusion/sr-create.js",
|
||||||
|
"src/services/spa/current-user.js",
|
||||||
|
"src/services/spa/page-client.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;
|
||||||
273
scripts/verify-org-chart.cjs
Normal file
273
scripts/verify-org-chart.cjs
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
// Synthetic browser verification; install Playwright separately or provide NODE_PATH.
|
||||||
|
// No Oracle requests, credentials or real customer data are used.
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const server = spawn(process.execPath, ["scripts/preview.mjs"], { cwd: path.resolve(__dirname, ".."), stdio: ["ignore", "pipe", "inherit"] });
|
||||||
|
let browser;
|
||||||
|
const output = fs.mkdtempSync(path.join(os.tmpdir(), "org-chart-verification-"));
|
||||||
|
try {
|
||||||
|
const url = await new Promise((resolve, reject) => {
|
||||||
|
server.once("error", reject); server.once("exit", code => reject(new Error("Preview exited: " + code)));
|
||||||
|
server.stdout.on("data", chunk => { const found = String(chunk).match(/http:\/\/127\.0\.0\.1:\d+\//); if (found) resolve(found[0]); });
|
||||||
|
});
|
||||||
|
browser = await chromium.launch({ headless: true, ...(process.env.TEST_BROWSER_CHANNEL ? { channel: process.env.TEST_BROWSER_CHANNEL } : {}) });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const errors = []; page.on("pageerror", error => errors.push(error.message));
|
||||||
|
// Any external request is a fixture regression, not a permitted live test.
|
||||||
|
await page.route("**/*", route => new URL(route.request().url()).origin === new URL(url).origin ? route.continue() : route.abort());
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
await page.goto(url);
|
||||||
|
await page.evaluate(() => new Promise((resolve,reject) => {
|
||||||
|
const request=indexedDB.open("opportunities-extension-organizations",1);
|
||||||
|
request.onupgradeneeded=()=>request.result.createObjectStore("charts",{keyPath:"key"});
|
||||||
|
request.onerror=()=>reject(request.error);
|
||||||
|
request.onsuccess=()=>{
|
||||||
|
const db=request.result, tx=db.transaction("charts","readwrite");
|
||||||
|
tx.objectStore("charts").put({key:"legacy-migration",schemaVersion:1,revision:3,nodes:[{id:"legacy",parentId:null,name:"Legacy",role:"Old title",emailAddress:"old@example.invalid",notes:"Retain notes",collapsed:false}]});
|
||||||
|
tx.oncomplete=()=>{db.close();resolve();}; tx.onerror=()=>reject(tx.error);
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
const open = () => page.getByRole("button", { name: "View customer organization chart", exact: true }).first().click();
|
||||||
|
const dialog = page.locator(".oe-org-dialog");
|
||||||
|
const button = label => dialog.getByRole("button", { name: label, exact: true });
|
||||||
|
const editor = page.locator(".oe-org-editor");
|
||||||
|
async function addPerson(name, role, notes = "", parent) {
|
||||||
|
await button(parent ? "Add report to " + parent : "Add person").click();
|
||||||
|
const search = editor.getByRole("combobox", { name: "Name", exact: true });
|
||||||
|
await search.fill(name);
|
||||||
|
await page.locator("#oe-org-name-options").getByRole("option").filter({ hasText: name }).click();
|
||||||
|
assert.equal(await editor.getByRole("textbox", { name: "Job Title", exact: true }).inputValue(), role);
|
||||||
|
assert.equal(await editor.getByRole("textbox", { name: "Job Title", exact: true }).evaluate(input => input.readOnly), true);
|
||||||
|
await editor.getByRole("textbox", { name: "Notes", exact: true }).fill(notes);
|
||||||
|
await editor.getByRole("button", { name: "Add person", exact: true }).click();
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
await button("Save chart").click();
|
||||||
|
await page.waitForFunction(() => document.querySelector(".oe-org-footer .oe-org-status")?.textContent.startsWith("Saved locally:"));
|
||||||
|
}
|
||||||
|
await open();
|
||||||
|
await dialog.getByRole("heading", { name: "No people yet" }).waitFor();
|
||||||
|
assert.deepEqual(await page.evaluate(()=>window.fixtureContactQueries || []),[]);
|
||||||
|
await button("Add person").click();
|
||||||
|
const nameSearch=editor.getByRole("combobox",{name:"Name",exact:true});
|
||||||
|
await editor.getByRole("button", { name: "Create Contact", exact: true }).click();
|
||||||
|
const contactDialog = page.locator(".oe-contact-dialog");
|
||||||
|
await contactDialog.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
|
assert.equal(await contactDialog.locator('[aria-invalid="true"]').count(), 3);
|
||||||
|
assert.equal(await contactDialog.locator('#oe-contact-validation').textContent(), "Complete the highlighted required fields with valid values.");
|
||||||
|
await contactDialog.getByRole("textbox", { name: "First Name", exact: true }).fill("Test");
|
||||||
|
assert.equal(await contactDialog.locator('[aria-invalid="true"]').count(), 2);
|
||||||
|
assert.equal(await contactDialog.locator("#oe-contact-workCountry option").count(), 246);
|
||||||
|
assert.equal(await contactDialog.locator("#oe-contact-mobileCountry option").count(), 246);
|
||||||
|
const country = contactDialog.getByRole("combobox", { name: "Work Phone country code", exact: true });
|
||||||
|
await country.fill("Brazil");
|
||||||
|
await contactDialog.getByRole("option", { name: "BR · Brazil (+55)", exact: true }).click();
|
||||||
|
assert.equal(await contactDialog.locator("#oe-contact-workCountry").inputValue(), "BR");
|
||||||
|
assert.equal(await contactDialog.locator("#oe-contact-mobileCountry").inputValue(), "");
|
||||||
|
assert.equal(await contactDialog.locator('input[name="primaryPhone"]').count(), 0);
|
||||||
|
await page.screenshot({path:path.join(output,"create-contact.png")});
|
||||||
|
await contactDialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
assert.equal(await editor.getByRole("button", { name: "Create Contact", exact: true }).evaluate(item => item === document.activeElement), true);
|
||||||
|
await nameSearch.fill("A"); await nameSearch.fill("Al"); await nameSearch.fill("Alex");
|
||||||
|
await page.locator("#oe-org-name-options [role=option]").waitFor();
|
||||||
|
assert.deepEqual(await page.evaluate(()=>window.fixtureContactQueries),["Alex"]);
|
||||||
|
await page.evaluate(()=>{window.fixtureContactCompleted=[]; window.fixtureContactDelay={Alex:1400};});
|
||||||
|
await nameSearch.fill("Alex");
|
||||||
|
await page.waitForFunction(()=>window.fixtureContactQueries.length===2);
|
||||||
|
await nameSearch.fill("Taylor");
|
||||||
|
await page.locator("#oe-org-name-options [role=option]").filter({hasText:"Taylor Chen"}).waitFor();
|
||||||
|
await page.waitForFunction(()=>window.fixtureContactCompleted.includes("Alex"));
|
||||||
|
assert.equal(await page.locator("#oe-org-name-options [role=option]").filter({hasText:"Alex Morgan"}).count(),0);
|
||||||
|
await page.evaluate(()=>{window.fixtureContactsFailure=true; window.fixtureContactDelay={};});
|
||||||
|
await nameSearch.fill("Alex");
|
||||||
|
await page.locator("#oe-org-name-options [role=status]").filter({hasText:"failed with status 503"}).waitFor();
|
||||||
|
await page.evaluate(()=>window.fixtureContactsFailure=false);
|
||||||
|
await nameSearch.fill("Jordan");
|
||||||
|
await page.locator("#oe-org-name-options [role=option]").filter({hasText:"Jordan Silva"}).waitFor();
|
||||||
|
await editor.getByRole("button", {name:"Cancel",exact:true}).click();
|
||||||
|
await button("Add person").click();
|
||||||
|
await editor.getByRole("combobox", { name: "Name", exact: true }).fill("Not a contact");
|
||||||
|
await editor.getByRole("button", { name: "Add person", exact: true }).click();
|
||||||
|
assert.equal(await page.locator(".oe-org-node").count(), 0);
|
||||||
|
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
await addPerson("Alex Morgan", "Chief Technology Officer", "Decision maker for the cloud program.");
|
||||||
|
await addPerson("Taylor Chen", "Infrastructure Director", "Owns the migration roadmap.", "Alex Morgan");
|
||||||
|
await addPerson("Jordan Silva", "Data and Analytics Director", "Coordinates the analytics team.", "Alex Morgan");
|
||||||
|
await addPerson("Sam Rivera", "Cloud Architect", "Technical workshop: November 12.", "Taylor Chen");
|
||||||
|
const queriesBeforeOpportunities = await page.evaluate(() => window.fixtureContactQueries.length);
|
||||||
|
await page.evaluate(() => { window.fixtureOpportunitiesDelay = 400; });
|
||||||
|
await button("View opportunities for Alex Morgan").click();
|
||||||
|
const contactOpportunities = page.locator(".oe-contact-opportunities");
|
||||||
|
await contactOpportunities.locator('table[aria-busy="true"]').waitFor();
|
||||||
|
assert.equal(await contactOpportunities.locator("tbody .oe-contact-skeleton").count(),35);
|
||||||
|
await contactOpportunities.getByText("6 opportunities", {exact:true}).waitFor();
|
||||||
|
assert.equal(await contactOpportunities.locator(".oe-contact-skeleton").count(),0);
|
||||||
|
await page.evaluate(() => { window.fixtureOpportunitiesDelay = 0; });
|
||||||
|
assert.equal(await page.evaluate(() => window.fixtureContactQueries.length), queriesBeforeOpportunities, "Opportunity lookup must reuse the person's PartyNumber without searching contacts");
|
||||||
|
assert.equal(await contactOpportunities.locator("tbody tr").count(), 6);
|
||||||
|
const presentation = await contactOpportunities.locator("table").evaluate(table => ({
|
||||||
|
marker: table.getAttribute("data-table-layout"),
|
||||||
|
textHeader: getComputedStyle(table.querySelector("th")).textAlign,
|
||||||
|
moneyHeader: getComputedStyle(table.querySelector('th[data-align="money"]')).textAlign,
|
||||||
|
moneyCell: getComputedStyle(table.querySelector('td[data-align="money"]')).textAlign,
|
||||||
|
nowrap: getComputedStyle(table.querySelector(".opportunities-extension-sort-label")).whiteSpace
|
||||||
|
}));
|
||||||
|
assert.deepEqual(presentation,{marker:"standard",textHeader:"left",moneyHeader:"right",moneyCell:"right",nowrap:"nowrap"});
|
||||||
|
await page.screenshot({path:path.join(output,"shared-table.png")});
|
||||||
|
assert.equal(await contactOpportunities.getByRole("link", {name:"Contact opportunity 0",exact:true}).getAttribute("target"), "_blank");
|
||||||
|
assert.ok((await contactOpportunities.getByRole("link", {name:"Contact opportunity 0",exact:true}).getAttribute("href")).endsWith("?puid=CONTACT0"));
|
||||||
|
assert.deepEqual((await page.evaluate(()=>window.fixtureOpportunitiesByContact)).map(url=>new URL(url).searchParams.get("offset")), ["0","5"]);
|
||||||
|
await contactOpportunities.getByRole("button",{name:"Sort by OptyNumber",exact:true}).click();
|
||||||
|
await contactOpportunities.getByRole("button",{name:"Sort by OptyNumber, ascending",exact:true}).click();
|
||||||
|
assert.equal(await contactOpportunities.locator("tbody tr").first().locator("td").first().textContent(),"CONTACT5");
|
||||||
|
assert.equal(new URL((await page.evaluate(()=>window.fixtureOpportunitiesByContact))[0]).searchParams.get("q"), "(OpportunityContact.ContactPartyNumber='18000')");
|
||||||
|
await contactOpportunities.getByRole("button", {name:"Close opportunities",exact:true}).click();
|
||||||
|
await button("View opportunities for Alex Morgan").click();
|
||||||
|
await contactOpportunities.getByText("6 opportunities",{exact:true}).waitFor();
|
||||||
|
assert.equal(await contactOpportunities.locator("tbody tr").first().locator("td").first().textContent(),"CONTACT5");
|
||||||
|
await contactOpportunities.getByRole("button", {name:"Close opportunities",exact:true}).click();
|
||||||
|
await button("Fit chart to view").click();
|
||||||
|
assert.equal(await page.locator(".oe-org-node").count(), 4);
|
||||||
|
await save();
|
||||||
|
await button("Edit Alex Morgan").hover();
|
||||||
|
assert.equal(await page.locator(".oe-org-tooltip").innerText(), "Decision maker for the cloud program.");
|
||||||
|
assert.equal(await dialog.getByRole("button", { name: /inline/i }).count(), 0);
|
||||||
|
await button("Edit Alex Morgan").click();
|
||||||
|
await page.waitForFunction(() => document.querySelector("#oe-org-emailAddress").value === "contact0@example.invalid");
|
||||||
|
assert.equal(await editor.getByRole("combobox", { name: "Name", exact: true }).inputValue(), "Alex Morgan");
|
||||||
|
await editor.getByRole("textbox", { name: "Notes", exact: true }).fill("Updated contact notes");
|
||||||
|
await button("Apply changes").click();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.fixtureOriginalOrgSave = window.fixtureOrgStorage.save;
|
||||||
|
window.fixtureOrgStorage.save = async () => { throw new Error("Synthetic storage failure"); };
|
||||||
|
});
|
||||||
|
await button("Save chart").click();
|
||||||
|
await page.locator(".oe-org-status.oe-org-error").filter({ hasText: "Synthetic storage failure" }).waitFor();
|
||||||
|
assert.equal(await button("Save chart").isEnabled(), true);
|
||||||
|
await page.evaluate(() => { window.fixtureOrgStorage.save = window.fixtureOriginalOrgSave; });
|
||||||
|
await button("Collapse reports for Taylor Chen").click();
|
||||||
|
assert.equal(await page.locator(".oe-org-node").count(), 3);
|
||||||
|
await save();
|
||||||
|
await page.reload(); await open();
|
||||||
|
await button("Expand reports for Taylor Chen").waitFor();
|
||||||
|
assert.equal(await page.locator(".oe-org-node").count(), 3);
|
||||||
|
await button("Edit Alex Morgan").click();
|
||||||
|
await page.waitForFunction(() => document.querySelector("#oe-org-role").value === "Chief Technology Officer");
|
||||||
|
await editor.getByRole("button", {name:"Cancel",exact:true}).click();
|
||||||
|
await button("Expand reports for Taylor Chen").click();
|
||||||
|
await button("Edit Taylor Chen").click();
|
||||||
|
await page.waitForFunction(() => document.querySelector("#oe-org-emailAddress").value === "contact1@example.invalid");
|
||||||
|
assert.deepEqual(await editor.locator("#oe-org-parent option").allTextContents(), ["No manager (top level)", "Alex Morgan - Chief Technology Officer", "Jordan Silva - Data and Analytics Director"]);
|
||||||
|
await editor.getByRole("textbox", { name: "Notes", exact: true }).fill("Unapplied note");
|
||||||
|
await button("Close organization chart").click();
|
||||||
|
await page.getByRole("button", { name: "Keep editing", exact: true }).click();
|
||||||
|
assert.equal(await editor.getByRole("textbox", { name: "Notes", exact: true }).inputValue(), "Unapplied note");
|
||||||
|
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
// Reparent through the searchable select, then restore the original manager.
|
||||||
|
await button("Edit Sam Rivera").click();
|
||||||
|
await editor.getByRole("combobox", { name: "Reports to", exact: true }).click();
|
||||||
|
await page.getByRole("option", { name: "Jordan Silva - Data and Analytics Director", exact: true }).click();
|
||||||
|
await editor.getByRole("button", { name: "Apply changes", exact: true }).click();
|
||||||
|
await button("Edit Sam Rivera").click();
|
||||||
|
await editor.getByRole("combobox", { name: "Reports to", exact: true }).click();
|
||||||
|
await page.getByRole("option", { name: "Taylor Chen - Infrastructure Director", exact: true }).click();
|
||||||
|
await editor.getByRole("button", { name: "Apply changes", exact: true }).click();
|
||||||
|
await button("Fit chart to view").click();
|
||||||
|
await save();
|
||||||
|
const boxes = await page.locator(".oe-org-node").evaluateAll(nodes => nodes.map(node => { const r = node.getBoundingClientRect(); return { x:r.x,y:r.y,w:r.width,h:r.height }; }));
|
||||||
|
for (let a=0;a<boxes.length;a++) for(let b=a+1;b<boxes.length;b++) {
|
||||||
|
const p=boxes[a],q=boxes[b]; assert.ok(p.x+p.w<=q.x || q.x+q.w<=p.x || p.y+p.h<=q.y || q.y+q.h<=p.y, "Cards must not overlap");
|
||||||
|
}
|
||||||
|
await page.mouse.move(5,5);
|
||||||
|
await page.screenshot({ path: path.join(output, "light.png") });
|
||||||
|
await page.locator(".opportunities-extension-modal").first().evaluate(node => node.dataset.theme="dark");
|
||||||
|
await button("Edit Jordan Silva").click();
|
||||||
|
await page.keyboard.press("Control+d");
|
||||||
|
const debug = page.locator(".opportunities-extension-debug-panel");
|
||||||
|
await debug.getByRole("button", { name: "Close debug", exact: true }).waitFor();
|
||||||
|
assert.equal(await debug.evaluate(node => !node.closest("[inert]") && node.parentElement.classList.contains("opportunities-extension-modal")), true);
|
||||||
|
assert.equal(await page.evaluate(() => !!document.elementFromPoint(innerWidth / 2, innerHeight / 2).closest(".opportunities-extension-debug-panel")), true);
|
||||||
|
await debug.getByRole("button", { name: "Close debug", exact: true }).click();
|
||||||
|
assert.equal(await editor.isVisible(), true);
|
||||||
|
await editor.getByRole("button", { name: "Delete person", exact: true }).click();
|
||||||
|
await page.keyboard.press("Control+d");
|
||||||
|
assert.equal(await page.evaluate(() => !!document.elementFromPoint(innerWidth / 2, innerHeight / 2).closest(".opportunities-extension-debug-panel")), true);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
assert.equal(await page.getByRole("alertdialog").isVisible(), true);
|
||||||
|
await page.getByRole("alertdialog").getByRole("button", { name: "Keep editing", exact: true }).click();
|
||||||
|
await page.screenshot({ path: path.join(output, "dark-editor.png") });
|
||||||
|
await page.setViewportSize({width:390,height:844});
|
||||||
|
await page.screenshot({ path: path.join(output, "mobile-editor.png") });
|
||||||
|
const bounds = await dialog.boundingBox(); assert.ok(bounds.x>=0 && bounds.x+bounds.width<=390 && bounds.y+bounds.height<=844);
|
||||||
|
await page.setViewportSize({width:1440,height:960});
|
||||||
|
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
await button("Delete Taylor Chen").click();
|
||||||
|
await page.getByRole("alertdialog").getByRole("button", { name: "Keep editing", exact: true }).click();
|
||||||
|
assert.equal(await page.locator(".oe-org-node").count(), 4);
|
||||||
|
await button("Delete Taylor Chen").click();
|
||||||
|
await page.getByRole("alertdialog").getByRole("button", { name: "Delete person", exact: true }).click();
|
||||||
|
await save();
|
||||||
|
const storageResult = await page.evaluate(async () => {
|
||||||
|
const identity={principal:"example@example.invalid",customerId:"100000810210835"};
|
||||||
|
const store=window.fixtureOrgStorage, chart=(await store.load(identity)).chart;
|
||||||
|
const sam=chart.nodes.find(n=>n.name==="Sam Rivera"), alex=chart.nodes.find(n=>n.name==="Alex Morgan");
|
||||||
|
let conflict=false; try { await store.save({...identity,nodes:[],revision:0}); } catch { conflict=true; }
|
||||||
|
return {conflict,promoted:sam.parentId===alex.id,notes:sam.notes,contactPartyId:sam.contactPartyId,
|
||||||
|
independentUser:(await store.load({...identity,principal:"different@example.invalid"})).chart.nodes.length,
|
||||||
|
independentCustomer:(await store.load({...identity,customerId:"another-customer"})).chart.nodes.length,
|
||||||
|
afterConflict:(await store.load(identity)).chart.nodes.length};
|
||||||
|
});
|
||||||
|
assert.deepEqual(storageResult,{conflict:true,promoted:true,notes:"Technical workshop: November 12.",contactPartyId:"2003",independentUser:0,independentCustomer:0,afterConflict:3});
|
||||||
|
const rawRecords = await page.evaluate(() => new Promise((resolve,reject) => {
|
||||||
|
const request=indexedDB.open("opportunities-extension-organizations",3);
|
||||||
|
request.onerror=()=>reject(request.error);
|
||||||
|
request.onsuccess=()=>{
|
||||||
|
const db=request.result, tx=db.transaction("charts","readonly"), all=tx.objectStore("charts").getAll();
|
||||||
|
all.onsuccess=()=>{resolve(all.result);db.close();}; all.onerror=()=>reject(all.error);
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
assert.ok(rawRecords.every(record=>record.nodes.every(node=>!("role" in node)&&!("emailAddress" in node)&&typeof node.jobTitle === "string")));
|
||||||
|
assert.equal(rawRecords.find(record=>record.key==="legacy-migration").nodes[0].notes,"Retain notes");
|
||||||
|
assert.equal(rawRecords.find(record=>record.key==="legacy-migration").revision,3);
|
||||||
|
assert.equal(rawRecords.find(record=>record.key==="legacy-migration").nodes[0].jobTitle,"Old title");
|
||||||
|
await button("Close organization chart").click();
|
||||||
|
await page.getByRole("button", { name: "View customer organization chart", exact: true }).nth(1).click();
|
||||||
|
await button("Edit Sam Rivera").waitFor(); // same customer, different opportunity
|
||||||
|
const queriesBefore=await page.evaluate(()=>window.fixtureContactQueries.filter(q=>q==="Sam Rivera").length);
|
||||||
|
await button("Edit Sam Rivera").click();
|
||||||
|
await page.waitForFunction(()=>document.querySelector("#oe-org-emailAddress").value==="contact3@example.invalid");
|
||||||
|
await editor.getByRole("button",{name:"Cancel",exact:true}).click();
|
||||||
|
await button("Edit Sam Rivera").click();
|
||||||
|
await page.waitForFunction(()=>document.querySelector("#oe-org-emailAddress").value==="contact3@example.invalid");
|
||||||
|
assert.equal(await page.evaluate(()=>window.fixtureContactQueries.filter(q=>q==="Sam Rivera").length),queriesBefore+2);
|
||||||
|
assert.equal(await button("Save chart").isDisabled(),true);
|
||||||
|
await editor.getByRole("button",{name:"Cancel",exact:true}).click();
|
||||||
|
await page.evaluate(()=>{
|
||||||
|
window.fixtureContactOverrides={"2003":{PartyName:"Samuel Rivera",JobTitle:"Principal Architect"}};
|
||||||
|
window.fixtureContactDelay={"Sam Rivera":250};
|
||||||
|
});
|
||||||
|
await button("Edit Sam Rivera").click();
|
||||||
|
await editor.getByRole("textbox",{name:"Notes",exact:true}).fill("Pending note preserved");
|
||||||
|
await page.waitForFunction(()=>document.querySelector("#oe-org-role").value==="Principal Architect");
|
||||||
|
assert.equal(await editor.getByRole("combobox",{name:"Name",exact:true}).inputValue(),"Samuel Rivera");
|
||||||
|
assert.equal(await editor.getByRole("textbox",{name:"Notes",exact:true}).inputValue(),"Pending note preserved");
|
||||||
|
assert.equal(await button("Edit Samuel Rivera").count(),1);
|
||||||
|
await save();
|
||||||
|
const updated=await page.evaluate(async()=>{
|
||||||
|
const chart=(await window.fixtureOrgStorage.load({principal:"example@example.invalid",customerId:"100000810210835"})).chart;
|
||||||
|
return chart.nodes.find(node=>node.contactPartyId==="2003");
|
||||||
|
});
|
||||||
|
assert.equal(updated.name,"Samuel Rivera");assert.equal(updated.jobTitle,"Principal Architect");
|
||||||
|
assert.equal(updated.notes,"Pending note preserved");assert.equal("emailAddress" in updated,false);
|
||||||
|
await button("Close organization chart").click();
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log("PASS: contact autocomplete, chart creation, reparenting, editing, notes, collapse, reload, failed-save recovery, unsaved protection, deletion, account/user isolation and revision conflict.");
|
||||||
|
console.log("Screenshots: " + output);
|
||||||
|
} finally { await browser?.close(); server.kill(); }
|
||||||
|
})().catch(error => { console.error(error); process.exitCode=1; });
|
||||||
20
scripts/write-output.cjs
Normal file
20
scripts/write-output.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const fs = require("node:fs/promises");
|
||||||
|
const { randomUUID } = require("node:crypto");
|
||||||
|
|
||||||
|
async function writeOutput(file, contents, io = fs) {
|
||||||
|
try {
|
||||||
|
if (await io.readFile(file, "utf8") === contents) return;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
// Replace a complete file; never remove or truncate a script the browser may load.
|
||||||
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
await io.writeFile(temporary, contents, { encoding: "utf8", flag: "wx" });
|
||||||
|
await io.rename(temporary, file);
|
||||||
|
} finally {
|
||||||
|
await io.rm(temporary, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { writeOutput };
|
||||||
417
src/auth/consumer-session.js
Normal file
417
src/auth/consumer-session.js
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
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,
|
||||||
|
tabsUpdate,
|
||||||
|
tabsRemove,
|
||||||
|
responseHeaders,
|
||||||
|
cookieChanges,
|
||||||
|
inspectPage,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
wait
|
||||||
|
}) {
|
||||||
|
const sessionTabs = new Map();
|
||||||
|
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,
|
||||||
|
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
|
||||||
|
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", ""),
|
||||||
|
workloadActions: extractTokenServiceAuthorization(tokenResponse, "WWB-TS", "Wwb-Provider-Authorization")
|
||||||
|
},
|
||||||
|
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 isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
|
||||||
|
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
|
||||||
|
const retryReason = isSessionUnauthorized
|
||||||
|
? `HTTP ${error.httpStatus} response requires SPA session recovery`
|
||||||
|
: "HTTP 200 response did not contain valid JSON";
|
||||||
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
|
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
|
if (needsSessionRecovery) {
|
||||||
|
debugEntry.metadata.retryLimitReached = "true";
|
||||||
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
|
debugEntry.error = isHtmlFallback
|
||||||
|
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
|
||||||
|
: `requestConsumerTokenService failed with status ${error.httpStatus} 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 = retryReason;
|
||||||
|
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionTab) await waitForConsumerTokenSession(sessionTab, debugEntry, retryDelayMs);
|
||||||
|
else 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 closeConsumerTokenSessionTab(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = await tabsCreate(details);
|
||||||
|
if (Number.isInteger(tab?.id)) {
|
||||||
|
const state = { failedStatus: 0, reloads: 0, lastReloadAt: -Infinity, readyVersion: 0, consumedVersion: 0, closed: false };
|
||||||
|
const signal = reason => {
|
||||||
|
state.readyVersion++;
|
||||||
|
state.readyReason = reason;
|
||||||
|
state.wake?.();
|
||||||
|
};
|
||||||
|
state.listener = details => {
|
||||||
|
if (details.tabId !== tab.id
|
||||||
|
|| !/^https:\/\/spa\.oracle\.com\//.test(details.url)) return;
|
||||||
|
if (details.type === "main_frame") {
|
||||||
|
if (details.statusCode === 401 || details.statusCode === 403) {
|
||||||
|
state.failedStatus = details.statusCode;
|
||||||
|
state.consumedVersion = state.readyVersion;
|
||||||
|
state.wake?.();
|
||||||
|
} else if (details.statusCode >= 200 && details.statusCode < 300) {
|
||||||
|
state.failedStatus = 0;
|
||||||
|
if (!details.url.startsWith("https://spa.oracle.com/cloudgate/")) signal("spa-document-response");
|
||||||
|
}
|
||||||
|
} else if (details.type === "xmlhttprequest" && details.statusCode === 200
|
||||||
|
&& details.url.split("?")[0] === "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"
|
||||||
|
&& details.responseHeaders?.some(header => header.name.toLowerCase() === "content-type" && /application\/json/i.test(header.value))) {
|
||||||
|
state.failedStatus = 0;
|
||||||
|
signal("spa-identity-json-response");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
state.cookieListener = change => {
|
||||||
|
const cookie = change.cookie;
|
||||||
|
if (change.removed || !cookie?.value || !cookie.secure || !cookie.httpOnly
|
||||||
|
|| cookie.domain?.replace(/^\./, "") !== "spa.oracle.com" || !/session/i.test(cookie.name)
|
||||||
|
|| (cookie.expirationDate != null && cookie.expirationDate <= Date.now() / 1000)
|
||||||
|
|| (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId)) return;
|
||||||
|
signal("spa-session-cookie-changed");
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
responseHeaders?.addListener(state.listener, { urls: ["https://spa.oracle.com/*"], types: ["main_frame", "xmlhttprequest"] }, ["responseHeaders"]);
|
||||||
|
cookieChanges?.addListener(state.cookieListener);
|
||||||
|
sessionTabs.set(tab.id, state);
|
||||||
|
} catch (error) {
|
||||||
|
responseHeaders?.removeListener?.(state.listener);
|
||||||
|
cookieChanges?.removeListener(state.cookieListener);
|
||||||
|
await tabsRemove(tab.id).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverConsumerTokenSessionTab(tab, debugEntry, reason = "") {
|
||||||
|
const state = sessionTabs.get(tab.id);
|
||||||
|
if (!state || (!state.failedStatus && !reason) || Date.now() - state.lastReloadAt < 2000) return false;
|
||||||
|
const status = state.failedStatus;
|
||||||
|
state.failedStatus = 0;
|
||||||
|
state.consumedVersion = state.readyVersion;
|
||||||
|
state.lastReloadAt = Date.now();
|
||||||
|
state.reloads++;
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.sessionBootstrapReloadCount = String(state.reloads);
|
||||||
|
debugEntry.metadata.sessionBootstrapReloadReason = reason || `SPA navigation returned HTTP ${status}`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Restart at the entry point; OAuth callback URLs may contain single-use state.
|
||||||
|
await tabsUpdate(tab.id, { url: CUSTOMER_TOKEN_SESSION_URL, active: false });
|
||||||
|
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = "restarted-at-spa-home";
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
state.failedStatus = status;
|
||||||
|
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = `restart-failed: ${error.message || "unknown error"}`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSpaPage(tab, debugEntry) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const deadline = startedAt + 120000;
|
||||||
|
let restartedDocument = null;
|
||||||
|
let restarts = 0;
|
||||||
|
if (debugEntry) {
|
||||||
|
delete debugEntry.metadata.nextRetryDelayMs;
|
||||||
|
debugEntry.metadata.sessionWaitTimeoutMs = "120000";
|
||||||
|
debugEntry.metadata.sessionWaitReason = "inspecting-spa-document";
|
||||||
|
}
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const state = sessionTabs.get(tab.id);
|
||||||
|
if (state?.closed) break;
|
||||||
|
let page;
|
||||||
|
try { page = await inspectPage(tab); } catch { /* Cross-origin SSO pages have no SPA receiver. */ }
|
||||||
|
if (debugEntry) debugEntry.metadata.spaPageState = page?.status || "loading-or-login";
|
||||||
|
const documentId = typeof page?.documentId === "string" ? page.documentId : null;
|
||||||
|
const alreadyRead = documentId && documentId === tab.spaDocumentId;
|
||||||
|
if (state?.failedStatus || page?.status === "error" || alreadyRead) {
|
||||||
|
if (restarts < 3 && (state?.failedStatus || documentId !== restartedDocument)) {
|
||||||
|
const previousReloads = state?.reloads || 0;
|
||||||
|
const restarted = await recoverConsumerTokenSessionTab(tab, debugEntry,
|
||||||
|
state?.failedStatus ? "" : alreadyRead ? "Identity rejected in this SPA document" : "SPA document displays an authorization error");
|
||||||
|
restarts += (state?.reloads || 0) - previousReloads;
|
||||||
|
if (restarted) restartedDocument = documentId;
|
||||||
|
}
|
||||||
|
} else if (page?.status === "ready" && documentId) {
|
||||||
|
// Confirm the parsed application document without waiting for visual assets.
|
||||||
|
await wait(100);
|
||||||
|
let confirmed;
|
||||||
|
try { confirmed = await inspectPage(tab); } catch { /* A redirect started during validation. */ }
|
||||||
|
if (confirmed?.status === "ready" && confirmed.documentId === documentId && !state?.failedStatus) {
|
||||||
|
tab.spaDocumentId = documentId;
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.sessionWaitReason = "spa-document-verified";
|
||||||
|
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await wait(1000);
|
||||||
|
}
|
||||||
|
const error = new Error("SPA page did not finish authentication. No further identity request was sent.");
|
||||||
|
error.code = "SPA_PAGE_NOT_READY";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForConsumerTokenSession(tab, debugEntry, retryDelay) {
|
||||||
|
const state = sessionTabs.get(tab.id);
|
||||||
|
if (!state || !responseHeaders?.addListener || !setTimeout) return wait(retryDelay);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const deadline = startedAt + 20000;
|
||||||
|
if (debugEntry) {
|
||||||
|
delete debugEntry.metadata.nextRetryDelayMs;
|
||||||
|
debugEntry.metadata.sessionWaitTimeoutMs = "20000";
|
||||||
|
}
|
||||||
|
let restarts = 0;
|
||||||
|
let reason = "timeout-fallback";
|
||||||
|
// Do not issue API probes while SSO redirects are still establishing the session.
|
||||||
|
while (!state.closed && Date.now() < deadline) {
|
||||||
|
if (state.failedStatus) {
|
||||||
|
if (restarts >= 3) {
|
||||||
|
await wait(Math.max(0, deadline - Date.now()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await recoverConsumerTokenSessionTab(tab, debugEntry);
|
||||||
|
restarts++;
|
||||||
|
if (state.failedStatus) await wait(2000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (state.readyVersion > state.consumedVersion) {
|
||||||
|
await wait(250);
|
||||||
|
if (state.failedStatus) continue;
|
||||||
|
state.consumedVersion = state.readyVersion;
|
||||||
|
reason = state.readyReason;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let timer;
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => {
|
||||||
|
state.wake = resolve;
|
||||||
|
timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout?.(timer);
|
||||||
|
state.wake = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (debugEntry) {
|
||||||
|
debugEntry.metadata.sessionWaitReason = reason;
|
||||||
|
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeConsumerTokenSessionTab(id) {
|
||||||
|
const state = sessionTabs.get(id);
|
||||||
|
sessionTabs.delete(id);
|
||||||
|
if (state) {
|
||||||
|
state.closed = true;
|
||||||
|
state.wake?.();
|
||||||
|
responseHeaders?.removeListener?.(state.listener);
|
||||||
|
cookieChanges?.removeListener(state.cookieListener);
|
||||||
|
}
|
||||||
|
await tabsRemove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab, recoverConsumerTokenSessionTab, waitForConsumerTokenSession, waitForSpaPage, closeConsumerTokenSessionTab };
|
||||||
|
}
|
||||||
59
src/auth/content-session.js
Normal file
59
src/auth/content-session.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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.tokenRelayRequest = null;
|
||||||
|
authSessionState.accessToken = "";
|
||||||
|
authSessionState.principal = "";
|
||||||
|
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 };
|
||||||
|
}
|
||||||
34
src/auth/current-user.js
Normal file
34
src/auth/current-user.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes, environment }) {
|
||||||
|
const { setTimeout } = environment;
|
||||||
|
let pending = null;
|
||||||
|
let allowed = false;
|
||||||
|
let userId = "";
|
||||||
|
function ensure() {
|
||||||
|
if (!pending) {
|
||||||
|
pending = (async () => {
|
||||||
|
let retryDelay = 1000;
|
||||||
|
// Keep a single pending identity request until the response is usable.
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const response = await sendRuntimeMessage({ type: messageTypes.requestCurrentUser });
|
||||||
|
appendBackgroundRequestLog(response?.debugRequests);
|
||||||
|
const user = response?.user;
|
||||||
|
if (response?.ok === true && typeof user?.id === "string" && user.id.trim()
|
||||||
|
&& typeof user.name === "string" && user.name.trim()
|
||||||
|
&& Array.isArray(user.roles) && user.roles.every(role => typeof role === "string" && role.trim())) {
|
||||||
|
userId = user.id;
|
||||||
|
allowed = !user.roles.includes("SALES_REP");
|
||||||
|
return allowed;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
appendBackgroundRequestLog(error?.debugRequests);
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||||
|
retryDelay = Math.min(retryDelay * 2, 10000);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
return { ensure, allowsCrmActivities: () => allowed, getUserId: () => userId };
|
||||||
|
}
|
||||||
9
src/auth/session-state.js
Normal file
9
src/auth/session-state.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
function createAuthSessionState({ AUTH_STATUS } = {}) {
|
||||||
|
return {
|
||||||
|
tokenRelayRequest: null,
|
||||||
|
consumerTokenServiceRequest: null,
|
||||||
|
accessToken: "",
|
||||||
|
principal: "",
|
||||||
|
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 };
|
||||||
|
}
|
||||||
52
src/auth/spa-page.js
Normal file
52
src/auth/spa-page.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
function createSpaPage() {
|
||||||
|
function start() {
|
||||||
|
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
|
||||||
|
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
|
||||||
|
if (!runtime) return;
|
||||||
|
const documentId = `${performance.timeOrigin}:${location.pathname}`;
|
||||||
|
let identityRequest = null;
|
||||||
|
|
||||||
|
function inspect() {
|
||||||
|
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
|
||||||
|
let status = "loading";
|
||||||
|
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
|
||||||
|
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|
||||||
|
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|
||||||
|
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
|
||||||
|
else if ((document.readyState === "interactive" || document.readyState === "complete") && text
|
||||||
|
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
|
||||||
|
return { status, documentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.onMessage.addListener((message, sender, reply) => {
|
||||||
|
if (sender.id !== runtime.id) return false;
|
||||||
|
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
|
||||||
|
reply(inspect());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
|
||||||
|
const page = inspect();
|
||||||
|
if (page.status !== "ready" || page.documentId !== message.documentId) {
|
||||||
|
reply({ error: "SPA page is not ready or has navigated." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A document is probed at most once; failed identity reads require a fresh navigation.
|
||||||
|
if (!identityRequest) identityRequest = (async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 15000);
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
|
||||||
|
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
|
||||||
|
});
|
||||||
|
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
|
||||||
|
contentType: response.headers.get("content-type") || "", body: await response.text() };
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error.message || "Unable to read SPA identity." };
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
})();
|
||||||
|
identityRequest.then(reply);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { start };
|
||||||
|
}
|
||||||
131
src/auth/sr-request-headers.js
Normal file
131
src/auth/sr-request-headers.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
function createSrRequestHeaders({ runtimeApi }) {
|
||||||
|
const origin = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
const appPath = "/ic/builder/rt/SeaaS_initiation/";
|
||||||
|
const internalPath = "/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
|
||||||
|
const storageKey = "opportunitiesExtension.srAppBuilderClientId.v1";
|
||||||
|
const maxAge = 60 * 60 * 1000;
|
||||||
|
let observed = null;
|
||||||
|
let started = false;
|
||||||
|
function valid(record) {
|
||||||
|
return record?.origin === origin && typeof record.value === "string"
|
||||||
|
&& /^[\x21-\x7e]{1,2048}$/.test(record.value)
|
||||||
|
&& Number.isFinite(record.observedAt) && record.observedAt <= Date.now()
|
||||||
|
&& Date.now() - record.observedAt < maxAge;
|
||||||
|
}
|
||||||
|
function observeHeaders(rawUrl, headers) {
|
||||||
|
let url;
|
||||||
|
try { url = new URL(rawUrl); } catch { return; }
|
||||||
|
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management|oalset_timeentrymobile)\/live(?:\/|;)/.test(url.pathname)) return;
|
||||||
|
// Read only the client ID, not cookies, bodies or authorization.
|
||||||
|
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
|
||||||
|
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
|
||||||
|
const record = { origin, value: value?.trim(), observedAt: Date.now() };
|
||||||
|
if (!valid(record)) return;
|
||||||
|
observed = record;
|
||||||
|
// Session storage survives service-worker suspension without persisting across browser sessions.
|
||||||
|
runtimeApi.storage?.session?.set({ [storageKey]: record }).catch(() => {});
|
||||||
|
}
|
||||||
|
function start() {
|
||||||
|
if (started || !runtimeApi?.webRequest) return;
|
||||||
|
started = true;
|
||||||
|
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`, `${origin}/ic/builder/rt/oalset_timeentrymobile/*`], types: ["xmlhttprequest"] };
|
||||||
|
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
|
||||||
|
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
|
||||||
|
}, filter, ["requestHeaders"]);
|
||||||
|
runtimeApi.webRequest.onHeadersReceived?.addListener(details => {
|
||||||
|
if (!details.incognito) observeHeaders(details.url, details.responseHeaders);
|
||||||
|
}, filter, ["responseHeaders"]);
|
||||||
|
}
|
||||||
|
async function previewTokenHeaders(url) {
|
||||||
|
const target = new URL(url);
|
||||||
|
if (target.origin !== origin || !target.pathname.startsWith(appPath) || !/\/Catalogue_c(?:\/\d+)?$/.test(target.pathname)) {
|
||||||
|
throw new Error("Unexpected Service Request token URL.");
|
||||||
|
}
|
||||||
|
return sessionHeaders(target.origin, "application/vnd.oracle.adf.action+json");
|
||||||
|
}
|
||||||
|
async function sessionHeaders(targetOrigin, contentType) {
|
||||||
|
if (!valid(observed)) {
|
||||||
|
try {
|
||||||
|
const stored = await runtimeApi.storage?.session?.get(storageKey);
|
||||||
|
if (valid(stored?.[storageKey]) && !valid(observed)) observed = stored[storageKey];
|
||||||
|
} catch { /* A missing capture must not prevent trying the other required headers. */ }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
Accept: "application/json", "Content-Type": contentType,
|
||||||
|
Authorization: "Session", Origin: targetOrigin,
|
||||||
|
...(valid(observed) ? { "x-appbuilder-client-id": observed.value } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function tokenHeaders(url, serviceRequestUrl) {
|
||||||
|
const headers = await previewTokenHeaders(url);
|
||||||
|
if (!/\/Catalogue_c\/\d+$/.test(new URL(url).pathname)) throw new Error("Missing Catalogue Id.");
|
||||||
|
const serviceTarget = new URL(serviceRequestUrl);
|
||||||
|
const isServiceRequest = serviceTarget.pathname.startsWith(appPath) && serviceTarget.pathname.endsWith("/set/seaas/mgmt/serviceRequests");
|
||||||
|
if (serviceTarget.origin !== headers.Origin || !isServiceRequest || serviceTarget.search || serviceTarget.hash) {
|
||||||
|
throw new Error("Unexpected Service Request URL.");
|
||||||
|
}
|
||||||
|
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
|
||||||
|
throw new Error("Reload the extension to enable the Service Request token header permissions.");
|
||||||
|
}
|
||||||
|
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
|
||||||
|
const catalogUrl = url.slice(0, url.lastIndexOf("/") + 1);
|
||||||
|
const escapedCatalogUrl = catalogUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const escapedServiceUrl = serviceTarget.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const ruleIds = [7201, 7202];
|
||||||
|
await runtimeApi.declarativeNetRequest.updateSessionRules({
|
||||||
|
removeRuleIds: ruleIds,
|
||||||
|
addRules: [`^${escapedCatalogUrl}[0-9]+$`, `^${escapedServiceUrl}$`].map((regexFilter, index) => ({
|
||||||
|
id: ruleIds[index], priority: 1,
|
||||||
|
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
|
||||||
|
condition: { regexFilter, initiatorDomains: [extensionDomain],
|
||||||
|
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
async function internalServiceRequestHeaders(url) {
|
||||||
|
const target = new URL(url);
|
||||||
|
if (target.origin !== origin || target.pathname !== internalPath || target.search || target.hash) {
|
||||||
|
throw new Error("Unexpected Internal Service Request URL.");
|
||||||
|
}
|
||||||
|
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
|
||||||
|
throw new Error("Reload the extension to enable the Internal Service Request header permissions.");
|
||||||
|
}
|
||||||
|
const headers = await sessionHeaders(target.origin, "application/json");
|
||||||
|
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
|
||||||
|
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
await runtimeApi.declarativeNetRequest.updateSessionRules({
|
||||||
|
removeRuleIds: [7203],
|
||||||
|
addRules: [{
|
||||||
|
id: 7203, priority: 1,
|
||||||
|
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
|
||||||
|
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [extensionDomain],
|
||||||
|
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
async function timeEntriesHeaders(url) {
|
||||||
|
const target = new URL(url);
|
||||||
|
const path = "/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries";
|
||||||
|
const isDelete = target.pathname.startsWith(`${path}/`) && /^[1-9]\d*$/.test(target.pathname.slice(path.length + 1));
|
||||||
|
if (target.origin !== origin || (target.pathname !== path && !isDelete) || target.search || target.hash) {
|
||||||
|
throw new Error("Unexpected time entries URL.");
|
||||||
|
}
|
||||||
|
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
|
||||||
|
throw new Error("Reload the extension to enable the time entry header permissions.");
|
||||||
|
}
|
||||||
|
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
await runtimeApi.declarativeNetRequest.updateSessionRules({
|
||||||
|
removeRuleIds: [isDelete ? 7205 : 7204],
|
||||||
|
addRules: [{
|
||||||
|
id: isDelete ? 7205 : 7204, priority: 1,
|
||||||
|
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: target.origin }] },
|
||||||
|
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [new URL(runtimeApi.runtime.getURL("/")).hostname],
|
||||||
|
requestMethods: isDelete ? ["delete"] : ["post", "put"], resourceTypes: ["xmlhttprequest"] }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
return sessionHeaders(target.origin, "application/json");
|
||||||
|
}
|
||||||
|
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, timeEntriesHeaders, observeResponseHeaders: observeHeaders };
|
||||||
|
}
|
||||||
168
src/auth/token-relay.js
Normal file
168
src/auth/token-relay.js
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
function createAuthTokenRelay({
|
||||||
|
TOKEN_RELAY_URL,
|
||||||
|
authSessionState,
|
||||||
|
createDebugBody,
|
||||||
|
getXsrfToken,
|
||||||
|
logRequestFailure,
|
||||||
|
logRequestStart,
|
||||||
|
logRequestSuccess,
|
||||||
|
maskToken,
|
||||||
|
readResponseBodyForDebug,
|
||||||
|
refreshXsrfCookie,
|
||||||
|
renderDebugPanel,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { fetch } = environment;
|
||||||
|
async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) {
|
||||||
|
if (url === TOKEN_RELAY_URL) authSessionState.principal = "";
|
||||||
|
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);
|
||||||
|
const controller = environment.AbortController ? new environment.AbortController() : null;
|
||||||
|
let timeoutId;
|
||||||
|
const timeout = new Promise((_, reject) => {
|
||||||
|
if (!environment.setTimeout) return;
|
||||||
|
timeoutId = environment.setTimeout(() => {
|
||||||
|
reject(new Error("Token relay timed out after 30 seconds. Please try again."));
|
||||||
|
controller?.abort();
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await Promise.race([fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
...(controller ? { signal: controller.signal } : {}),
|
||||||
|
headers: {
|
||||||
|
"x-xsrf-token": xsrfTokenSource.token
|
||||||
|
}
|
||||||
|
}), timeout]);
|
||||||
|
|
||||||
|
const responseBody = await Promise.race([readResponseBodyForDebug(response), timeout]);
|
||||||
|
|
||||||
|
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 Promise.race([response.json(), timeout]);
|
||||||
|
} catch (error) {
|
||||||
|
if (controller?.signal.aborted) throw 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, responseBody);
|
||||||
|
|
||||||
|
if (url === TOKEN_RELAY_URL) {
|
||||||
|
authSessionState.principal = typeof responseData.principal === "string" ? responseData.principal.trim() : "";
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
} finally {
|
||||||
|
if (timeoutId !== undefined) environment.clearTimeout?.(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name = "requestTokenRelay", url = TOKEN_RELAY_URL) {
|
||||||
|
return logRequestStart({
|
||||||
|
name,
|
||||||
|
method: "GET",
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
"x-xsrf-token": 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 };
|
||||||
|
}
|
||||||
1298
src/background.js
1298
src/background.js
File diff suppressed because it is too large
Load Diff
220
src/background/application.js
Normal file
220
src/background/application.js
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
function createBackgroundApplication(overrides = {}) {
|
||||||
|
const environment = {
|
||||||
|
indexedDB: globalThis.indexedDB,
|
||||||
|
fetch: globalThis.fetch,
|
||||||
|
browser: globalThis.browser,
|
||||||
|
chrome: globalThis.chrome,
|
||||||
|
setTimeout: globalThis.setTimeout,
|
||||||
|
clearTimeout: globalThis.clearTimeout,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
const messageTypes = createMessageTypes();
|
||||||
|
const orgChartStorage = createOrgChartStorage({ environment, model: createOrgChartModel() });
|
||||||
|
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
|
||||||
|
const contracts = createBackgroundContracts();
|
||||||
|
const spaPageClient = createSpaPageClient({
|
||||||
|
sendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args),
|
||||||
|
getHttp: () => httpBackgroundJson
|
||||||
|
});
|
||||||
|
const spaCurrentUser = createSpaCurrentUser({
|
||||||
|
sessionStorage: (environment.browser || environment.chrome)?.storage?.session,
|
||||||
|
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||||
|
fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs),
|
||||||
|
openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender),
|
||||||
|
waitForSession: (tab, entry) => authConsumerSession.waitForSpaPage(tab, entry),
|
||||||
|
closeSessionTab: id => authConsumerSession.closeConsumerTokenSessionTab(id),
|
||||||
|
sessionUrl: contracts.CUSTOMER_TOKEN_SESSION_URL
|
||||||
|
});
|
||||||
|
const requestCurrentUser = sender => spaCurrentUser.requestCurrentUser(sender);
|
||||||
|
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),
|
||||||
|
tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args),
|
||||||
|
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
|
||||||
|
responseHeaders: (environment.browser || environment.chrome)?.webRequest?.onHeadersReceived,
|
||||||
|
cookieChanges: (environment.browser || environment.chrome)?.cookies?.onChanged,
|
||||||
|
inspectPage: tab => spaPageClient.inspect(tab),
|
||||||
|
setTimeout: environment.setTimeout,
|
||||||
|
clearTimeout: environment.clearTimeout,
|
||||||
|
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 spaInstallBase = createSpaInstallBase({
|
||||||
|
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
|
||||||
|
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||||
|
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
|
||||||
|
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||||
|
});
|
||||||
|
const spaCustomerServiceRequests = createSpaCustomerServiceRequests({
|
||||||
|
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
|
||||||
|
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({
|
||||||
|
observeResponseHeaders: (...args) => srRequestHeaders.observeResponseHeaders(...args),
|
||||||
|
previewDebugBody: createDebugBodyPreview,
|
||||||
|
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({
|
||||||
|
createBackgroundDebugEntry: (...args) => httpBackgroundJson.createBackgroundDebugEntry(...args),
|
||||||
|
completeBackgroundDebugEntry: (...args) => httpBackgroundJson.completeBackgroundDebugEntry(...args),
|
||||||
|
createDebugBody: (...args) => httpBackgroundJson.createDebugBody(...args),
|
||||||
|
headersToObject: (...args) => httpBackgroundJson.headersToObject(...args),
|
||||||
|
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 requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||||
|
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
|
const workloadsCache = createWorkloadsCache({
|
||||||
|
fetchAllWorkloads: (...args) => spaWorkloads.fetchAllWorkloads(...args),
|
||||||
|
storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args),
|
||||||
|
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
|
||||||
|
});
|
||||||
|
const spaWorkloads = createSpaWorkloads({
|
||||||
|
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
|
||||||
|
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||||
|
getCachedWorkloads: (...args) => workloadsCache.get(...args),
|
||||||
|
normalizeCacheMaxAge: value => productsCache.normalizeCacheMaxAge(value)
|
||||||
|
});
|
||||||
|
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
||||||
|
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
|
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
||||||
|
const requestActionFindLookup = (...args) => spaActionOptions.requestActionFindLookup(...args);
|
||||||
|
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
||||||
|
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||||
|
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||||
|
const requestConsumptionPlanDetail = (...args) => spaConsumption.requestConsumptionPlanDetail(...args);
|
||||||
|
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
|
||||||
|
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||||
|
const runtimeApi = environment.browser || environment.chrome;
|
||||||
|
const srRequestHeaders = createSrRequestHeaders({ runtimeApi });
|
||||||
|
const fusionSrCreate = createFusionSrCreate({
|
||||||
|
runtimeApi,
|
||||||
|
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||||
|
tokenHeaders: (...args) => srRequestHeaders.tokenHeaders(...args),
|
||||||
|
internalServiceRequestHeaders: url => srRequestHeaders.internalServiceRequestHeaders(url),
|
||||||
|
previewTokenHeaders: url => srRequestHeaders.previewTokenHeaders(url)
|
||||||
|
});
|
||||||
|
const fusionSrDetails = createFusionSrDetails({
|
||||||
|
cookieChanges: runtimeApi?.cookies?.onChanged,
|
||||||
|
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
|
||||||
|
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
|
||||||
|
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
|
||||||
|
supportsFirefoxCookieStoreTabs: () => platformBackgroundApi.supportsFirefoxCookieStoreTabs(),
|
||||||
|
wait: (...args) => platformBackgroundApi.wait(...args),
|
||||||
|
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args)
|
||||||
|
});
|
||||||
|
function start() {
|
||||||
|
srRequestHeaders.start();
|
||||||
|
startBackgroundApplication({
|
||||||
|
loadOrganizationChart: message => orgChartStorage.load(message),
|
||||||
|
saveOrganizationChart: message => orgChartStorage.save(message),
|
||||||
|
requestWorkloads: message => spaWorkloads.requestWorkloads(message),
|
||||||
|
requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message),
|
||||||
|
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
|
||||||
|
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
||||||
|
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
||||||
|
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
|
||||||
|
requestTimeEntriesSave: message => timeEntries.requestTimeEntriesSave(message),
|
||||||
|
requestTimeEntriesDelete: message => timeEntries.requestTimeEntriesDelete(message),
|
||||||
|
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
|
||||||
|
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
|
||||||
|
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
|
||||||
|
requestActiveServiceRequests: message => timeEntries.requestActiveServiceRequests(message),
|
||||||
|
requestLookupTypePlatform: () => fusionSrDetails.requestLookupTypePlatform(),
|
||||||
|
requestPostInternalServiceRequest: message => fusionSrCreate.requestPostInternalServiceRequest(message),
|
||||||
|
requestInternalSrServices: message => fusionSrDetails.requestInternalSrServices(message),
|
||||||
|
requestPostServiceRequest: message => fusionSrCreate.requestPostServiceRequest(message), prepareSrSession: sender => fusionSrDetails.prepareSession(sender), requestSrLookupWithSession: (...args) => fusionSrDetails.requestLookupWithSession(...args), messageTypes, requestSrLookupLanguage: () => fusionSrDetails.requestSrLookupLanguage(), requestSrLookupTechPlatform: () => fusionSrDetails.requestSrLookupTechPlatform(), requestSrLookupOpportunityBusiness: message => fusionSrDetails.requestSrLookupOpportunityBusiness(message), requestSrLookupPrimaryPillar: () => fusionSrDetails.requestSrLookupPrimaryPillar(), requestInternalServiceRequest: (message, sender) => fusionSrDetails.requestInternalServiceRequest(message, sender), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
start, contracts,
|
||||||
|
productsCacheState,
|
||||||
|
authConsumerSession,
|
||||||
|
authSpaCredentials,
|
||||||
|
spaAccounts,
|
||||||
|
spaInstallBase,
|
||||||
|
spaCustomerServiceRequests,
|
||||||
|
spaConsumption,
|
||||||
|
spaResponseShapes,
|
||||||
|
httpBackgroundJson,
|
||||||
|
productsBackgroundJobs,
|
||||||
|
productsCache,
|
||||||
|
workloadsCache,
|
||||||
|
spaWorkloads,
|
||||||
|
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?ojr=whitespace";
|
||||||
|
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);
|
||||||
|
}
|
||||||
317
src/background/messages.js
Normal file
317
src/background/messages.js
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||||
|
function registerBackgroundMessages({
|
||||||
|
loadOrganizationChart,
|
||||||
|
saveOrganizationChart,
|
||||||
|
messageTypes,
|
||||||
|
requestWeekDetails,
|
||||||
|
requestTimeEntries,
|
||||||
|
requestTimeEntriesSave,
|
||||||
|
requestTimeEntriesDelete,
|
||||||
|
requestLookupTaskType,
|
||||||
|
requestServiceRequestExpand,
|
||||||
|
requestResourceUser,
|
||||||
|
requestActiveServiceRequests,
|
||||||
|
invalidateProductsCache,
|
||||||
|
invalidateResourceUser = async () => {},
|
||||||
|
getXsrfTokenCookie,
|
||||||
|
refreshXsrfCookie,
|
||||||
|
requestAccountPlans,
|
||||||
|
requestInstallBase,
|
||||||
|
requestCustomerServiceRequest,
|
||||||
|
requestWlActions,
|
||||||
|
requestWorkloads,
|
||||||
|
requestWorkloadForecastUpdate,
|
||||||
|
invalidateWorkloadsCache,
|
||||||
|
requestActionTeamMembers,
|
||||||
|
requestActionFindLookup,
|
||||||
|
requestMgmtAction,
|
||||||
|
requestPostServiceRequest,
|
||||||
|
requestPostInternalServiceRequest,
|
||||||
|
requestInternalServiceRequest,
|
||||||
|
requestSrLookupPrimaryPillar,
|
||||||
|
requestSrLookupWithSession,
|
||||||
|
prepareSrSession,
|
||||||
|
requestSrLookupLanguage,
|
||||||
|
requestSrLookupTechPlatform,
|
||||||
|
requestLookupTypePlatform,
|
||||||
|
requestInternalSrServices,
|
||||||
|
requestSrLookupOpportunityBusiness,
|
||||||
|
requestCurrentUser,
|
||||||
|
requestConsumerTokenService,
|
||||||
|
requestConsumptionTrend,
|
||||||
|
requestConsumptionPlanDetail,
|
||||||
|
requestRatecardByPlan,
|
||||||
|
requestOpptyProducts,
|
||||||
|
runtimeApi
|
||||||
|
}) {
|
||||||
|
const bookmarkPending = new Map();
|
||||||
|
async function getBookmarkFolders() {
|
||||||
|
const folders = [];
|
||||||
|
function visit(nodes, ancestors = []) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.url || node.unmodifiable) continue;
|
||||||
|
const path = node.parentId ? [...ancestors, node.title] : ancestors;
|
||||||
|
if (node.parentId) folders.push({ id: node.id, label: path.join(" / ") });
|
||||||
|
visit(node.children || [], path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(await runtimeApi.bookmarks.getTree());
|
||||||
|
return { ok: true, folders };
|
||||||
|
}
|
||||||
|
function addShortcutBookmark(parentId) {
|
||||||
|
if (bookmarkPending.has(parentId)) return bookmarkPending.get(parentId);
|
||||||
|
const pending = (async () => {
|
||||||
|
const { folders } = await getBookmarkFolders();
|
||||||
|
if (!folders.some(folder => folder.id === parentId)) throw new Error("Select an available bookmarks folder.");
|
||||||
|
const url = "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension";
|
||||||
|
const existing = await runtimeApi.bookmarks.search({ url });
|
||||||
|
if (existing.some(bookmark => bookmark.parentId === parentId)) return { ok: true, existing: true };
|
||||||
|
await runtimeApi.bookmarks.create({ title: "Opportunities Extension", url, parentId });
|
||||||
|
return { ok: true, existing: false };
|
||||||
|
})().finally(() => { bookmarkPending.delete(parentId); });
|
||||||
|
bookmarkPending.set(parentId, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
if (!message || !message.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.addShortcutBookmark) {
|
||||||
|
addShortcutBookmark(message.parentId).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.getBookmarkFolders) {
|
||||||
|
getBookmarkFolders().then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.getShortcutBookmarkStatus) {
|
||||||
|
runtimeApi.bookmarks.search({ url: "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension" })
|
||||||
|
.then(items => sendResponse({ ok: true, bookmarked: items.length > 0 }))
|
||||||
|
.catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
|
||||||
|
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
|
||||||
|
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.requestLookupTaskType) {
|
||||||
|
requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.requestTimeEntriesSave) {
|
||||||
|
requestTimeEntriesSave(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.requestTimeEntriesDelete) {
|
||||||
|
requestTimeEntriesDelete(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ([messageTypes.requestTimeEntries, messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
|
||||||
|
const request = message.type === messageTypes.requestTimeEntries ? requestTimeEntries : message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests;
|
||||||
|
request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.requestPostInternalServiceRequest) {
|
||||||
|
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.requestPostServiceRequest) {
|
||||||
|
requestPostServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.prepareSrSession) {
|
||||||
|
prepareSrSession(sender).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const srLookups = {
|
||||||
|
[messageTypes.requestLookupTypePlatform]: requestLookupTypePlatform,
|
||||||
|
[messageTypes.requestInternalSrServices]: requestInternalSrServices,
|
||||||
|
[messageTypes.requestSrLookupPrimaryPillar]: requestSrLookupPrimaryPillar,
|
||||||
|
[messageTypes.requestSrLookupLanguage]: requestSrLookupLanguage,
|
||||||
|
[messageTypes.requestSrLookupTechPlatform]: requestSrLookupTechPlatform,
|
||||||
|
[messageTypes.requestSrLookupOpportunityBusiness]: requestSrLookupOpportunityBusiness
|
||||||
|
};
|
||||||
|
if (Object.hasOwn(srLookups, message.type)) {
|
||||||
|
requestSrLookupWithSession(srLookups[message.type], message, sender).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
httpStatus: error.httpStatus, error: error.message, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.invalidateProductsCache) {
|
||||||
|
Promise.all([invalidateProductsCache(), invalidateWorkloadsCache()]).then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
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.requestCurrentUser) {
|
||||||
|
requestCurrentUser(sender).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, error: error.message || "Unable to load current user.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
|
||||||
|
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
|
||||||
|
request(message, sender).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([messageTypes.requestWorkloads, messageTypes.requestWorkloadForecastUpdate].includes(message.type)) {
|
||||||
|
const request = message.type === messageTypes.requestWorkloads ? requestWorkloads : requestWorkloadForecastUpdate;
|
||||||
|
request(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, error: error.message || "Unable to load workloads.", httpStatus: error.httpStatus,
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestWlActions) {
|
||||||
|
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, error: error.message || "Unable to load workload actions.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestCustomerServiceRequest) {
|
||||||
|
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, error: error.message || "Unable to load service requests.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestInstallBase) {
|
||||||
|
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, error: error.message || "Unable to load installed base.",
|
||||||
|
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.requestRatecardByPlan) {
|
||||||
|
requestRatecardByPlan(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, error: error.message || "Unable to load rate card.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestConsumptionPlanDetail) {
|
||||||
|
requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, error: error.message || "Unable to load consumption plan details.",
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
7075
src/content.js
7075
src/content.js
File diff suppressed because it is too large
Load Diff
928
src/content/application.js
Normal file
928
src/content/application.js
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
function createContentApplication(overrides = {}) {
|
||||||
|
const environment = {
|
||||||
|
document: globalThis.document,
|
||||||
|
window: globalThis.window,
|
||||||
|
navigator: globalThis.navigator,
|
||||||
|
crypto: globalThis.crypto,
|
||||||
|
get localStorage() { return globalThis.localStorage; },
|
||||||
|
fetch: globalThis.fetch && ((...args) => globalThis.fetch.call(undefined, ...args)),
|
||||||
|
browser: globalThis.browser,
|
||||||
|
chrome: globalThis.chrome,
|
||||||
|
MutationObserver: globalThis.MutationObserver,
|
||||||
|
Node: globalThis.Node,
|
||||||
|
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||||
|
performance: globalThis.performance,
|
||||||
|
cancelAnimationFrame: globalThis.cancelAnimationFrame,
|
||||||
|
setTimeout: globalThis.setTimeout && ((...args) => globalThis.setTimeout.call(undefined, ...args)),
|
||||||
|
clearTimeout: globalThis.clearTimeout && ((...args) => globalThis.clearTimeout.call(undefined, ...args)),
|
||||||
|
AbortController: globalThis.AbortController,
|
||||||
|
matchMedia: globalThis.matchMedia,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
const messageTypes = createMessageTypes();
|
||||||
|
const currentUser = createCurrentUser({
|
||||||
|
environment,
|
||||||
|
messageTypes,
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)
|
||||||
|
});
|
||||||
|
const contracts = createContentContracts();
|
||||||
|
const searchableSelect = createSearchableSelect({ environment });
|
||||||
|
const authSessionState = createAuthSessionState(contracts);
|
||||||
|
const opportunitiesQueryState = createOpportunitiesQueryState();
|
||||||
|
const tableSorting = createTableSorting({ environment });
|
||||||
|
const orgChartIcons = createOrgChartIcons({ environment });
|
||||||
|
const accountContact = createFusionAccountContact({ environment,
|
||||||
|
getToken: () => authContentSession.requestTokenRelayOnce(),
|
||||||
|
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
|
||||||
|
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
|
||||||
|
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
|
||||||
|
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args)
|
||||||
|
});
|
||||||
|
const orgChart = createOrgChartView({ environment, tableSorting, model: createOrgChartModel(),
|
||||||
|
requestQueryAccountContact: (...args) => accountContact.requestQueryAccountContact(...args),
|
||||||
|
requestPersistContact: (...args) => accountContact.requestPersistContact(...args),
|
||||||
|
requestOpportunitiesByContact: (...args) => accountContact.requestOpportunitiesByContact(...args),
|
||||||
|
loadLayout: loadOrganizationLayout, icon: orgChartIcons.icon, searchableSelect, modalId: contracts.MODAL_ID,
|
||||||
|
getPrincipal: () => currentUser.getUserId(), notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
|
async request(operation, data) {
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ ...data,
|
||||||
|
type: operation === "load" ? messageTypes.loadOrganizationChart : messageTypes.saveOrganizationChart });
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to access local organization charts.");
|
||||||
|
return result.chart;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const opportunitiesTableState = createOpportunitiesTableState();
|
||||||
|
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
||||||
|
const preferencesState = createPreferencesState(contracts);
|
||||||
|
const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
|
||||||
|
const productsContentState = createProductsContentState();
|
||||||
|
const consumptionPlansState = createConsumptionPlansState();
|
||||||
|
const consumptionTrendState = createConsumptionTrendState();
|
||||||
|
const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts);
|
||||||
|
const debugState = createDebugState(environment);
|
||||||
|
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({
|
||||||
|
getShortcutBookmarkStatus: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.getShortcutBookmarkStatus }),
|
||||||
|
openBookmarkPicker: () => opportunitiesLinks.chooseBookmarkFolder(
|
||||||
|
() => platformContentMessages.sendRuntimeMessage({ type: messageTypes.getBookmarkFolders }),
|
||||||
|
async parentId => {
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.addShortcutBookmark, parentId });
|
||||||
|
if (result?.ok) contentModal.hideBookmarkHint();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
createManageTimeButton: () => manageTime.createButton(),
|
||||||
|
prepareSrSession: () => srSessionPreparation.ensure(),
|
||||||
|
closeActionForm: (...args) => rampProjectionView.close(...args) || manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
|
||||||
|
mountOpportunityViews: (...args) => opportunityCalendar.mount(...args),
|
||||||
|
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
|
||||||
|
closeCrmSrPopover: () => crmSrPopover.close(),
|
||||||
|
ensureCurrentUser: () => currentUser.ensure(),
|
||||||
|
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),
|
||||||
|
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
|
||||||
|
closeServiceRequestsModal: (...args) => serviceRequestsView.closeServiceRequestsModal(...args),
|
||||||
|
closeOrganizationChart: () => orgChart.close(),
|
||||||
|
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
|
||||||
|
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
||||||
|
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
||||||
|
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
|
||||||
|
createRefreshAllButton: (...args) => refreshAll.createButton(...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 });
|
||||||
|
async function ensureManageTimeAccess() {
|
||||||
|
await currentUser.ensure();
|
||||||
|
if (!currentUser.allowsCrmActivities()) throw new Error("Manage Time is unavailable for this profile.");
|
||||||
|
}
|
||||||
|
const manageTime = createManageTime({ allowsManageTime: () => currentUser.allowsCrmActivities(), MODAL_ID: contracts.MODAL_ID, environment, tableSorting, searchableSelect, dates: createTimeDates(),
|
||||||
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
|
async requestDelete(id) {
|
||||||
|
await ensureManageTimeAccess();
|
||||||
|
await srSessionPreparation.ensure();
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesDelete, id });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to delete time entry.");
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
async requestSave(method, items) {
|
||||||
|
await ensureManageTimeAccess();
|
||||||
|
await srSessionPreparation.ensure();
|
||||||
|
await currentUser.ensure();
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesSave, method, items, emailAddress: currentUser.getUserId() });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to save time entries.");
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
async requestEntries(weekId) {
|
||||||
|
await ensureManageTimeAccess();
|
||||||
|
await srSessionPreparation.ensure();
|
||||||
|
await currentUser.ensure();
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntries, weekId, emailAddress: currentUser.getUserId() });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load time entries.");
|
||||||
|
return result.items;
|
||||||
|
},
|
||||||
|
async requestTaskTypes() {
|
||||||
|
await ensureManageTimeAccess();
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestLookupTaskType });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load Task Types.");
|
||||||
|
return result.items;
|
||||||
|
},
|
||||||
|
async requestExpand(srNumbers) {
|
||||||
|
await ensureManageTimeAccess();
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestServiceRequestExpand, srNumbers });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load activities.");
|
||||||
|
return result.items;
|
||||||
|
},
|
||||||
|
async requestServices(weekId) {
|
||||||
|
await ensureManageTimeAccess();
|
||||||
|
await srSessionPreparation.ensure();
|
||||||
|
await currentUser.ensure();
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestActiveServiceRequests, weekId, emailAddress: currentUser.getUserId() });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Requests.");
|
||||||
|
return result.items;
|
||||||
|
},
|
||||||
|
async requestWeek(dateReference) {
|
||||||
|
await ensureManageTimeAccess();
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestWeekDetails, dateReference });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load week details.");
|
||||||
|
return result.items;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const dialogDrag = createDialogDrag({ environment });
|
||||||
|
const contentStyles = createContentStyles({
|
||||||
|
enableDialogDrag: () => dialogDrag.install(),
|
||||||
|
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 authTokenRelay = createAuthTokenRelay({
|
||||||
|
authSessionState,
|
||||||
|
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({
|
||||||
|
requestCache: createFormRequestCache({ getHours: () => preferencesState.opptyProductsCacheHours, getUserId: () => currentUser.getUserId() }),
|
||||||
|
messageTypes,
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const opportunitiesController = createOpportunitiesController({
|
||||||
|
loadServiceRequests: (token, version) => opportunityCrmServiceRequests.load(token, version),
|
||||||
|
ensureCurrentUser: () => currentUser.ensure(),
|
||||||
|
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
||||||
|
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
|
||||||
|
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),
|
||||||
|
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
|
||||||
|
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
|
||||||
|
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
||||||
|
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
|
||||||
|
});
|
||||||
|
const fusionOpportunities = createFusionOpportunities({
|
||||||
|
resetDebugRequests: () => debugState.reset(),
|
||||||
|
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 fusionCrmServiceRequests = createFusionCrmServiceRequests({
|
||||||
|
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
|
||||||
|
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
|
||||||
|
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
|
||||||
|
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
|
||||||
|
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
||||||
|
maskToken: (...args) => debugContentLog.maskToken(...args), environment
|
||||||
|
});
|
||||||
|
const internalSrSession = createInternalSrSession({
|
||||||
|
environment,
|
||||||
|
query: async (srId, srNumber) => {
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId, srNumber });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const crmSrPopover = createCrmSrPopover({
|
||||||
|
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
|
||||||
|
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||||
|
requestDetails: (srId, srNumber) => internalSrSession.request(srId, srNumber),
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||||
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
||||||
|
});
|
||||||
|
const opportunityBadgeContent = createOpportunityBadgeContent({ environment });
|
||||||
|
async function requestSrFormLookup(type, fields = {}) {
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type, ...fields });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Request options.");
|
||||||
|
return result.items;
|
||||||
|
}
|
||||||
|
const internalSrForm = createInternalSrForm({
|
||||||
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
|
requestSave: async payload => {
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestPostInternalServiceRequest, payload, emailAddress: currentUser.getUserId() });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to create Internal Service Request.");
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
getOpportunity: number => number == null ? null : opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
|
||||||
|
MODAL_ID: contracts.MODAL_ID, environment, searchableSelect,
|
||||||
|
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||||
|
requestPlatform: () => requestSrFormLookup(messageTypes.requestLookupTypePlatform),
|
||||||
|
requestServices: parentServiceId => requestSrFormLookup(messageTypes.requestInternalSrServices, { parentServiceId, emailAddress: currentUser.getUserId() })
|
||||||
|
});
|
||||||
|
const crmSrForm = createCrmSrForm({
|
||||||
|
requestSave: async fields => {
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestPostServiceRequest, ...fields, emailAddress: currentUser.getUserId() });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to save Service Request.");
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
|
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
|
||||||
|
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||||
|
requestLanguage: () => requestSrFormLookup(messageTypes.requestSrLookupLanguage),
|
||||||
|
requestPlatform: () => requestSrFormLookup(messageTypes.requestSrLookupTechPlatform),
|
||||||
|
requestServices: () => requestSrFormLookup(messageTypes.requestSrLookupOpportunityBusiness, { emailAddress: currentUser.getUserId() }),
|
||||||
|
searchableSelect,
|
||||||
|
MODAL_ID: contracts.MODAL_ID, environment,
|
||||||
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
|
isMissing: number => opportunityCrmServiceRequests.getSummary(number)?.state === "missing",
|
||||||
|
createCloseIcon: () => contentIcons.createCloseIcon(),
|
||||||
|
requestPillars: () => requestSrFormLookup(messageTypes.requestSrLookupPrimaryPillar)
|
||||||
|
});
|
||||||
|
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
|
||||||
|
onStateChange: () => {
|
||||||
|
if (environment.document?.getElementById?.(contracts.OPPORTUNITIES_TABLE_ID)) opportunitiesTable.renderOpportunitiesTable();
|
||||||
|
},
|
||||||
|
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||||
|
createSrButton: number => crmSrForm.createButton(number),
|
||||||
|
closeSrForm: () => { internalSrForm.close(false); crmSrForm.close(false); },
|
||||||
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
|
activityCache,
|
||||||
|
reportProgress: (...args) => activityProgress.update("sr", ...args),
|
||||||
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
|
bindPopover: (...args) => crmSrPopover.bind(...args),
|
||||||
|
closePopover: (...args) => crmSrPopover.close(...args),
|
||||||
|
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
|
||||||
|
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
|
||||||
|
});
|
||||||
|
const opportunitiesPrint = createOpportunitiesPrint({ environment, getSnapshot: () => {
|
||||||
|
const state = opportunitiesTableState.opportunitiesTableState;
|
||||||
|
const items = opportunitiesFilters.getSortedOpportunities();
|
||||||
|
const filter = opportunitiesFilterState;
|
||||||
|
const selectedLabels = (options, selected, allLabel) => {
|
||||||
|
const matching = options.filter(option => selected.has(option.key));
|
||||||
|
if (options.length && matching.length === options.length) return allLabel;
|
||||||
|
return matching.map(option => option.label).join(", ") || "None";
|
||||||
|
};
|
||||||
|
const root = environment.document.getElementById(contracts.MODAL_ID);
|
||||||
|
const filters = [
|
||||||
|
["Reference", root?.querySelector(".opportunities-extension-form select")?.value || "-"],
|
||||||
|
["Customer", selectedLabels(opportunitiesFormatters.getCustomerOptions(), filter.selectedCustomerKeys, "All customers")],
|
||||||
|
["Owner", selectedLabels(opportunitiesFormatters.getOwnerOptions(), filter.selectedOwnerKeys, "All owners")],
|
||||||
|
["Search", filter.tableSearch || "All"],
|
||||||
|
["Status", [...filter.selectedStatuses].join(", ")],
|
||||||
|
["Stage", [...filter.selectedStages].join(", ")],
|
||||||
|
["Opportunity type", [...filter.selectedProductTypes].join(", ")]
|
||||||
|
];
|
||||||
|
for (const group of root?.querySelectorAll(".opportunities-extension-activity-filter-controls") || []) {
|
||||||
|
filters.push([group.getAttribute("aria-label").replace(/^Filter by /, ""),
|
||||||
|
[...group.querySelectorAll('[aria-pressed="true"]')].map(button => button.getAttribute("aria-label")).join("; ")]);
|
||||||
|
}
|
||||||
|
const totals = [{ label: "Total Opportunities", value: `${items.length}/${state.items.length}`, detail: "Filtered / total" }];
|
||||||
|
for (const stage of contracts.STAGE_OPTIONS) {
|
||||||
|
const stageItems = items.filter(item => item.ForecastGroup_c === stage);
|
||||||
|
const amount = stageItems.reduce((total, item) => {
|
||||||
|
const value = Number(item.PrimaryRevenue?.RevnAmount);
|
||||||
|
return total + (Number.isFinite(value) ? value : 0);
|
||||||
|
}, 0);
|
||||||
|
totals.push({ label: stage, value: opportunitiesDashboard.createStageAmountFormatter(stageItems)(amount), detail: `${stageItems.length} opportunities` });
|
||||||
|
}
|
||||||
|
return { status: state.status, filters, totals, columns,
|
||||||
|
rows: items.map(item => columns.map(column => opportunitiesFormatters.displayOpportunityValue(column, item))) };
|
||||||
|
} });
|
||||||
|
const opportunityCalendar = createOpportunityCalendar({
|
||||||
|
createPrintButton: () => opportunitiesPrint.createButton(),
|
||||||
|
productTypeSelection: opportunitiesFilterState.selectedProductTypes,
|
||||||
|
onProductTypeChange: () => opportunitiesTable.renderOpportunitiesTable(),
|
||||||
|
createViewIcon: view => contentIcons.createViewIcon(view),
|
||||||
|
environment, loadLibrary: () => loadOpportunityCalendarLibrary(),
|
||||||
|
getState: () => opportunitiesTableState.opportunitiesTableState,
|
||||||
|
getItems: () => opportunitiesFilters.getSortedOpportunities(),
|
||||||
|
getVersion: () => opportunitiesQueryState.periodRequestVersion,
|
||||||
|
getRange: () => opportunitiesQueryState.dateRange,
|
||||||
|
formatRevenue: item => opportunitiesFormatters.formatRevenue(item),
|
||||||
|
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||||
|
getOwnerName: item => opportunitiesFormatters.getOwnerName(item),
|
||||||
|
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
||||||
|
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||||
|
createCloseIcon: () => contentIcons.createCloseIcon(),
|
||||||
|
createStageBadge: value => opportunitiesLinks.createStageBadge(value),
|
||||||
|
createStatusBadge: value => opportunitiesLinks.createStatusBadge(value),
|
||||||
|
opportunityUrl: contracts.OPPORTUNITY_DETAIL_URL, modalId: contracts.MODAL_ID,
|
||||||
|
closeSrPopover: () => crmSrPopover.close()
|
||||||
|
});
|
||||||
|
const opportunitiesTable = createOpportunitiesTable({
|
||||||
|
tableSorting,
|
||||||
|
renderCalendar: (...args) => opportunityCalendar.render(...args),
|
||||||
|
renderActivityFilters: () => activityFilters.render(),
|
||||||
|
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
||||||
|
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
|
||||||
|
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),
|
||||||
|
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
|
||||||
|
createServiceRequestsButton: (...args) => serviceRequestsAction.createServiceRequestsButton(...args),
|
||||||
|
createOrganizationChartButton: (...args) => orgChart.createButton(...args),
|
||||||
|
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||||
|
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
||||||
|
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
||||||
|
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
|
||||||
|
createProjectionIndicator: (...args) => rampProjectionView.createOpportunityIndicator(...args),
|
||||||
|
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...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({
|
||||||
|
matchesProductType: item => {
|
||||||
|
const selected = opportunitiesFilterState.selectedProductTypes;
|
||||||
|
if (selected.size === 2) return true;
|
||||||
|
const result = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
|
||||||
|
if (!result || result.error) return false;
|
||||||
|
return selected.has(productsDetails.isBooking(result.items) ? "Booking" : "Workload");
|
||||||
|
},
|
||||||
|
matchesActivities: item => activityFilters.matches(item),
|
||||||
|
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,
|
||||||
|
searchableSelect,
|
||||||
|
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 refreshAll = createRefreshAll({
|
||||||
|
createRefreshIcon: () => contentIcons.createRefreshIcon(),
|
||||||
|
messageTypes, activityCache, opportunitiesQueryState, productsContentState,
|
||||||
|
consumptionPlansState, consumptionTrendState, environment,
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
reload: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||||
|
onFailure: error => opportunitiesTable.setOpportunitiesTableState({ status: "error", message: error.message || "Unable to refresh cached data." }),
|
||||||
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
|
resetRelatedData: () => {
|
||||||
|
opportunityCrmServiceRequests.reset();
|
||||||
|
opportunityWorkloadActions.reset();
|
||||||
|
productWorkloads.reset();
|
||||||
|
rampProjectionView.close(false);
|
||||||
|
consumptionPlansView.closeAccountPlansModal();
|
||||||
|
installBaseView.closeInstallBaseModal();
|
||||||
|
serviceRequestsView.closeServiceRequestsModal();
|
||||||
|
installBaseAction.resetRequests();
|
||||||
|
serviceRequestsAction.resetRequests();
|
||||||
|
ratecardController.resetRequests();
|
||||||
|
consumptionPlanDetailController.resetRequests();
|
||||||
|
productsContentJobs.renderProductsProgress();
|
||||||
|
opportunitiesTable.setOpportunitiesTableState({ status: "loading", message: "Refreshing data..." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment,
|
||||||
|
retrySession: () => srSessionPreparation.retry() });
|
||||||
|
const srSessionPreparation = createSrSessionPreparation({
|
||||||
|
async onReady() {
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestResourceUser, emailAddress: currentUser.getUserId() });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load resource user.");
|
||||||
|
},
|
||||||
|
ensureCurrentUser: () => currentUser.ensure(),
|
||||||
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
|
sendRequest: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.prepareSrSession }),
|
||||||
|
reportProgress: state => { activityProgress.update("session", state); manageTime.setReady(state === "complete"); },
|
||||||
|
appendLog: entries => debugContentLog.appendBackgroundRequestLog(entries)
|
||||||
|
});
|
||||||
|
const activityFilters = createActivityFilters({
|
||||||
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
|
getSrSummary: number => opportunityCrmServiceRequests.getSummary(number),
|
||||||
|
getActionSummary: number => opportunityWorkloadActions.getSummary(number),
|
||||||
|
renderTable: () => opportunitiesTable.renderOpportunitiesTable(),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const productActivities = createProductActivities({
|
||||||
|
productsContentState, opportunitiesQueryState,
|
||||||
|
loadCrm: (...args) => opportunityCrmServiceRequests.load(...args),
|
||||||
|
loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
|
||||||
|
blockActions: products => opportunityWorkloadActions.blockProducts(products),
|
||||||
|
render: () => {
|
||||||
|
productsContentJobs.renderProductsProgress();
|
||||||
|
productsContentJobs.queueProductsTableRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const productsContentJobs = createProductsContentJobs({
|
||||||
|
loadWorkloads: (...args) => productWorkloads.load(...args),
|
||||||
|
joinWorkloads: number => productWorkloads.joinProducts(number),
|
||||||
|
beginProductActivities: (...args) => productActivities.begin(...args),
|
||||||
|
completeProductActivities: (...args) => productActivities.complete(...args),
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
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 productWorkloads = createProductWorkloads({
|
||||||
|
productsContentState, opportunitiesQueryState, preferencesState, messageTypes,
|
||||||
|
requestConsumerTokenServiceOnce: () => authContentSession.requestConsumerTokenServiceOnce(),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
getPrincipal: () => currentUser.getUserId() || authSessionState.principal,
|
||||||
|
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
|
||||||
|
});
|
||||||
|
const workloadActionOptions = createWorkloadActionOptions({
|
||||||
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
messageTypes
|
||||||
|
});
|
||||||
|
const workloadActionForm = createWorkloadActionForm({
|
||||||
|
searchableSelect,
|
||||||
|
onActionSaved: (...args) => opportunityWorkloadActions.applySavedAction(...args),
|
||||||
|
saveAction: (...args) => workloadActionOptions.save(...args),
|
||||||
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
|
getCurrentUserId: () => currentUser.getUserId(),
|
||||||
|
requestTeamMembers: (...args) => workloadActionOptions.requestTeamMembers(...args),
|
||||||
|
requestLookup: (...args) => workloadActionOptions.requestLookup(...args),
|
||||||
|
getProducts: number => productsContentState.opptyProductsByNumber.get(String(number))?.items || [],
|
||||||
|
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
|
||||||
|
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
|
isMissingAction: number => opportunityWorkloadActions.getSummary(number)?.state === "missing",
|
||||||
|
createCloseIcon: () => contentIcons.createCloseIcon(),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const opportunityWorkloadActions = createOpportunityWorkloadActions({
|
||||||
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
|
createActionButton: number => workloadActionForm.createButton(number),
|
||||||
|
closeActionForm: (...args) => workloadActionForm.close(...args),
|
||||||
|
activityCache,
|
||||||
|
reportProgress: (...args) => activityProgress.update("actions", ...args),
|
||||||
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
|
authSessionState,
|
||||||
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender(),
|
||||||
|
productsContentState, messageTypes, environment
|
||||||
|
});
|
||||||
|
const productsDetails = createProductsDetails({
|
||||||
|
createProjectionIndicator: (...args) => rampProjectionView.createIndicator(...args),
|
||||||
|
openProjection: (products, opportunity, trigger) => rampProjectionView.open(products[0], opportunity, trigger, products),
|
||||||
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
|
OPPORTUNITIES_COLUMNS: columns,
|
||||||
|
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||||
|
productsContentState,
|
||||||
|
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const rampProjection = createRampProjection();
|
||||||
|
const workloadForecastUpdate = createWorkloadForecastUpdate({
|
||||||
|
compare: product => rampProjection.compare(product),
|
||||||
|
ensureCurrentUser: () => currentUser.ensure(),
|
||||||
|
getCurrentUserId: () => currentUser.getUserId(),
|
||||||
|
requestAuthorization: () => authContentSession.requestConsumerTokenServiceOnce(),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
messageTypes,
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
productsContentState,
|
||||||
|
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
|
||||||
|
});
|
||||||
|
const rampProjectionView = createRampProjectionView({
|
||||||
|
tableSorting,
|
||||||
|
saveEstimates: product => workloadForecastUpdate.save(product),
|
||||||
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
compare: product => rampProjection.compare(product),
|
||||||
|
expandProducts: number => productsDetails.expandProducts(number),
|
||||||
|
createAlertIcon: () => contentIcons.createAlertIcon(),
|
||||||
|
createCloseIcon: () => contentIcons.createCloseIcon(),
|
||||||
|
formatAmount: (...args) => productsDetails.formatProductAmount(...args),
|
||||||
|
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const customerTableSearch = createCustomerTableSearch({ environment });
|
||||||
|
const installBaseView = createInstallBaseView({
|
||||||
|
tableSorting,
|
||||||
|
createSearch: (...args) => customerTableSearch.createSearch(...args),
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
|
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const installBaseAction = createInstallBaseAction({
|
||||||
|
messageTypes,
|
||||||
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
openInstallBaseModal: (...args) => installBaseView.openInstallBaseModal(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const serviceRequestsView = createServiceRequestsView({
|
||||||
|
tableSorting,
|
||||||
|
createSearch: (...args) => customerTableSearch.createSearch(...args),
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
|
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const serviceRequestsAction = createServiceRequestsAction({
|
||||||
|
messageTypes,
|
||||||
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
openServiceRequestsModal: (...args) => serviceRequestsView.openServiceRequestsModal(...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 consumptionPlanDetailView = createConsumptionPlanDetailView({
|
||||||
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const consumptionPlanDetailController = createConsumptionPlanDetailController({
|
||||||
|
messageTypes,
|
||||||
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
renderPlanDetailBadges: (...args) => consumptionPlanDetailView.renderPlanDetailBadges(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const ratecardModel = createRatecardModel();
|
||||||
|
const consumptionHeaderView = createConsumptionHeaderView({
|
||||||
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
|
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const ratecardView = createRatecardView({
|
||||||
|
tableSorting,
|
||||||
|
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||||
|
columns: ratecardModel.columns,
|
||||||
|
formatValue: (...args) => ratecardModel.formatValue(...args),
|
||||||
|
selectItems: (...args) => ratecardModel.selectItems(...args),
|
||||||
|
createPlanDetails: (...args) => consumptionHeaderView.createDetails(...args),
|
||||||
|
createConsumptionHeader: (...args) => consumptionHeaderView.createHeader(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const ratecardController = createRatecardController({
|
||||||
|
messageTypes,
|
||||||
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
openRatecardDrawer: (...args) => ratecardView.openRatecardDrawer(...args),
|
||||||
|
closePlanDetailPopover: (...args) => consumptionPlanDetailView.closePlanDetailPopover(...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,
|
||||||
|
createConsumptionHeader: (...args) => consumptionHeaderView.createHeader(...args),
|
||||||
|
createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args),
|
||||||
|
loadPlanDetailForPanel: (...args) => consumptionPlanDetailController.loadPlanDetailForPanel(...args),
|
||||||
|
closePlanDetailPopover: (...args) => consumptionPlanDetailView.closePlanDetailPopover(...args),
|
||||||
|
createRatecardButton: (...args) => ratecardController.createRatecardButton(...args),
|
||||||
|
closeRatecardDrawer: (...args) => ratecardView.closeRatecardDrawer(...args),
|
||||||
|
createSvgElement: (...args) => consumptionChart.createSvgElement(...args),
|
||||||
|
createPlanDetails: (...args) => consumptionHeaderView.createDetails(...args),
|
||||||
|
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
||||||
|
loadConsumptionTrendForPanel: (...args) => consumptionTrendController.loadConsumptionTrendForPanel(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const consumptionTrendController = createConsumptionTrendController({
|
||||||
|
createPlanDetailSection: (...args) => consumptionPlanDetailController.createPlanDetailSection(...args),
|
||||||
|
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({
|
||||||
|
previewDebugBody: createDebugBodyPreview,
|
||||||
|
debugState,
|
||||||
|
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
|
||||||
|
});
|
||||||
|
const debugContentView = createDebugContentView({
|
||||||
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
|
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 {
|
||||||
|
opportunityCalendar,
|
||||||
|
orgChart,
|
||||||
|
workloadActionForm,
|
||||||
|
activityFilters,
|
||||||
|
start, contracts,
|
||||||
|
opportunityWorkloadActions,
|
||||||
|
fusionCrmServiceRequests, opportunityCrmServiceRequests,
|
||||||
|
authSessionState,
|
||||||
|
opportunitiesQueryState,
|
||||||
|
opportunitiesTableState,
|
||||||
|
opportunitiesFilterState,
|
||||||
|
preferencesState,
|
||||||
|
productsContentState,
|
||||||
|
consumptionPlansState,
|
||||||
|
consumptionTrendState,
|
||||||
|
opportunitiesDashboardState,
|
||||||
|
debugState,
|
||||||
|
contentFusionPage,
|
||||||
|
contentModal,
|
||||||
|
contentTheme,
|
||||||
|
contentIcons,
|
||||||
|
contentStyles,
|
||||||
|
authContentSession,
|
||||||
|
currentUser,
|
||||||
|
authTokenRelay,
|
||||||
|
authXsrfContent,
|
||||||
|
platformContentMessages,
|
||||||
|
opportunitiesController,
|
||||||
|
fusionOpportunities,
|
||||||
|
opportunitiesFiscalPeriods,
|
||||||
|
opportunitiesTable,
|
||||||
|
opportunitiesDashboard,
|
||||||
|
opportunitiesFilters,
|
||||||
|
opportunitiesFormatters,
|
||||||
|
opportunitiesLinks,
|
||||||
|
preferencesStorage,
|
||||||
|
preferencesView,
|
||||||
|
productsContentJobs,
|
||||||
|
productWorkloads,
|
||||||
|
productActivities,
|
||||||
|
activityProgress,
|
||||||
|
srSessionPreparation,
|
||||||
|
refreshAll,
|
||||||
|
activityCache,
|
||||||
|
productsDetails,
|
||||||
|
rampProjection,
|
||||||
|
workloadForecastUpdate,
|
||||||
|
rampProjectionView,
|
||||||
|
consumptionPlansAction,
|
||||||
|
installBaseAction,
|
||||||
|
installBaseView,
|
||||||
|
serviceRequestsAction,
|
||||||
|
serviceRequestsView,
|
||||||
|
consumptionPlansView,
|
||||||
|
consumptionPlanDetailController,
|
||||||
|
consumptionPlanDetailView,
|
||||||
|
ratecardModel,
|
||||||
|
ratecardView,
|
||||||
|
ratecardController,
|
||||||
|
consumptionTrendController,
|
||||||
|
consumptionChart,
|
||||||
|
consumptionModel,
|
||||||
|
debugContentLog,
|
||||||
|
debugContentView
|
||||||
|
};
|
||||||
|
}
|
||||||
99
src/content/contracts.js
Normal file
99
src/content/contracts.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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)"
|
||||||
|
];
|
||||||
|
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 };
|
||||||
|
}
|
||||||
71
src/content/dialog-drag.js
Normal file
71
src/content/dialog-drag.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
function createDialogDrag({ environment }) {
|
||||||
|
const { document, window } = environment;
|
||||||
|
const panels = ".opportunities-extension-action-form-dialog, .opportunities-extension-preferences-dialog, .opportunities-extension-account-plans-dialog, .oe-org-confirm-panel";
|
||||||
|
const positions = new WeakMap();
|
||||||
|
let installed = false, drag = null;
|
||||||
|
function move(panel, dx, dy) {
|
||||||
|
const rect = panel.getBoundingClientRect();
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
const left = viewport?.offsetLeft || 0, top = viewport?.offsetTop || 0;
|
||||||
|
const width = viewport?.width || document.documentElement.clientWidth;
|
||||||
|
const height = viewport?.height || document.documentElement.clientHeight;
|
||||||
|
const x = Math.max(left - rect.width * 0.7, Math.min(rect.left + dx, left + width - rect.width * 0.3));
|
||||||
|
const y = Math.max(top - rect.height * 0.7, Math.min(rect.top + dy, top + height - rect.height * 0.3));
|
||||||
|
const previous = positions.get(panel) || { x: 0, y: 0 };
|
||||||
|
const next = { x: previous.x + x - rect.left, y: previous.y + y - rect.top };
|
||||||
|
positions.set(panel, next);
|
||||||
|
panel.style.position = "relative";
|
||||||
|
panel.style.left = `${next.x}px`;
|
||||||
|
panel.style.top = `${next.y}px`;
|
||||||
|
panel.setAttribute("data-dialog-moved", "true");
|
||||||
|
}
|
||||||
|
function stop() {
|
||||||
|
if (!drag) return;
|
||||||
|
const previous = drag;
|
||||||
|
drag = null;
|
||||||
|
previous.panel.removeAttribute("data-dialog-dragging");
|
||||||
|
if (previous.title.hasPointerCapture?.(previous.id)) previous.title.releasePointerCapture(previous.id);
|
||||||
|
}
|
||||||
|
function install() {
|
||||||
|
if (installed || window.__opportunitiesDialogDragInstalled || !document.addEventListener || !window.addEventListener) return;
|
||||||
|
installed = true;
|
||||||
|
window.__opportunitiesDialogDragInstalled = true;
|
||||||
|
document.addEventListener("pointerdown", event => {
|
||||||
|
if (event.button !== 0 || event.isPrimary === false) return;
|
||||||
|
const title = event.target.closest?.("header, h2, h3");
|
||||||
|
const panel = title?.closest(panels);
|
||||||
|
if (!panel || !panel.closest(".opportunities-extension-modal") || panel.closest("[inert], [hidden]") ||
|
||||||
|
event.target.closest("button, a, input, select, textarea, [role='button'], [contenteditable='true']")) return;
|
||||||
|
// Accept the whole title bar, but not headers inside the dialog's content.
|
||||||
|
const heading = panel.querySelector("h2, h3");
|
||||||
|
if (!heading || (heading !== title && !title.contains(heading))) return;
|
||||||
|
const rect = panel.getBoundingClientRect();
|
||||||
|
if (rect.width >= document.documentElement.clientWidth - 2 && rect.height >= document.documentElement.clientHeight - 2) return;
|
||||||
|
stop();
|
||||||
|
event.preventDefault();
|
||||||
|
drag = { title, panel, id: event.pointerId, x: event.clientX, y: event.clientY };
|
||||||
|
panel.setAttribute("data-dialog-dragging", "true");
|
||||||
|
title.setPointerCapture?.(event.pointerId);
|
||||||
|
});
|
||||||
|
document.addEventListener("pointermove", event => {
|
||||||
|
if (!drag || event.pointerId !== drag.id) return;
|
||||||
|
if (!drag.panel.isConnected || drag.panel.closest("[hidden], [inert]")) { stop(); return; }
|
||||||
|
event.preventDefault();
|
||||||
|
move(drag.panel, event.clientX - drag.x, event.clientY - drag.y);
|
||||||
|
drag.x = event.clientX; drag.y = event.clientY;
|
||||||
|
});
|
||||||
|
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) {
|
||||||
|
document.addEventListener(name, event => { if (drag?.id === event.pointerId) stop(); });
|
||||||
|
}
|
||||||
|
window.addEventListener("blur", stop);
|
||||||
|
function resize() {
|
||||||
|
stop();
|
||||||
|
for (const panel of document.querySelectorAll('[data-dialog-moved="true"]')) {
|
||||||
|
if (panel.getClientRects().length) move(panel, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
window.visualViewport?.addEventListener("resize", resize);
|
||||||
|
}
|
||||||
|
return { install };
|
||||||
|
}
|
||||||
124
src/content/fusion-page.js
Normal file
124
src/content/fusion-page.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
function createContentFusionPage({
|
||||||
|
ADD_ICON_SELECTOR,
|
||||||
|
ALLOWED_PATHS,
|
||||||
|
SALES_GROUP_SELECTOR,
|
||||||
|
TILE_ID,
|
||||||
|
TILE_LABEL_ID,
|
||||||
|
appendPath,
|
||||||
|
openOpportunitiesModal,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { Node, document, window } = environment;
|
||||||
|
let shortcutOpened = false;
|
||||||
|
function openShortcutIfRequested() {
|
||||||
|
if (shortcutOpened || !isAllowedPage()) return;
|
||||||
|
if (window.top && window.top !== window) return;
|
||||||
|
let pending = false;
|
||||||
|
try {
|
||||||
|
const timestamp = Number(window.sessionStorage.getItem("opportunitiesExtension.shortcutIntent"));
|
||||||
|
pending = timestamp > 0 && Date.now() >= timestamp && Date.now() - timestamp < 10 * 60 * 1000;
|
||||||
|
window.sessionStorage.removeItem("opportunitiesExtension.shortcutIntent");
|
||||||
|
} catch { /* Storage may be disabled. */ }
|
||||||
|
if (!pending && window.location.hash !== "#opportunities-extension") return;
|
||||||
|
shortcutOpened = true;
|
||||||
|
openOpportunitiesModal();
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
// Opening a bookmarked shortcut must not depend on Sales being expanded
|
||||||
|
// or its tile insertion point being present in Fusion's lazy navigation.
|
||||||
|
openShortcutIfRequested();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
151
src/content/icons.js
Normal file
151
src/content/icons.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRefreshIcon() {
|
||||||
|
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", "M20 7v5h-5M4 17v-5h5M6.1 6.1A8 8 0 0 1 19.7 10M4.3 14a8 8 0 0 0 13.6 3.9");
|
||||||
|
path.setAttribute("fill", "none");
|
||||||
|
path.setAttribute("stroke", "currentColor");
|
||||||
|
path.setAttribute("stroke-width", "1.8");
|
||||||
|
path.setAttribute("stroke-linecap", "round");
|
||||||
|
path.setAttribute("stroke-linejoin", "round");
|
||||||
|
svg.append(path);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
function createViewIcon(view) {
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
svg.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
svg.setAttribute("focusable", "false");
|
||||||
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
const calendarFrame = "M8 2v4m8-4v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z";
|
||||||
|
path.setAttribute("d", view === "today" ? `${calendarFrame}M9 16l2 2 4-4`
|
||||||
|
: view === "week" ? `${calendarFrame}M6 14h12v4H6Z M10 14v4m4-4v4`
|
||||||
|
: view === "calendar"
|
||||||
|
? "M8 2v4m8-4v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2ZM7 14h2m6 0h2m-10 4h2m6 0h2"
|
||||||
|
: "M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1ZM3 9h18M9 9v12M3 15h18");
|
||||||
|
path.setAttribute("fill", "none");
|
||||||
|
path.setAttribute("stroke", "currentColor");
|
||||||
|
path.setAttribute("stroke-width", "1.8");
|
||||||
|
path.setAttribute("stroke-linecap", "round");
|
||||||
|
path.setAttribute("stroke-linejoin", "round");
|
||||||
|
svg.append(path);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
function createAlertIcon() {
|
||||||
|
/* Lucide triangle-alert (https://lucide.dev/icons/triangle-alert), ISC License.
|
||||||
|
* Copyright (c) 2026 Lucide Icons and Contributors
|
||||||
|
* Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
* purpose with or without fee is hereby granted, provided that the above
|
||||||
|
* copyright notice and this permission notice appear in all copies.
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||||
|
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||||
|
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
*
|
||||||
|
* Derived from Feather alert-triangle, MIT License.
|
||||||
|
* Copyright (c) 2013-present Cole Bemis
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
for (const [name, value] of Object.entries({ viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false",
|
||||||
|
fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" })) svg.setAttribute(name, value);
|
||||||
|
for (const d of ["m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3", "M12 9v4", "M12 17h.01"]) {
|
||||||
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
path.setAttribute("d", d);
|
||||||
|
svg.append(path);
|
||||||
|
}
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon, createViewIcon, createAlertIcon };
|
||||||
|
}
|
||||||
10
src/content/main.js
Normal file
10
src/content/main.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/** Preserve the existing document_idle initialization and observer behavior. */
|
||||||
|
function startContentApplication({ environment, page, styles, products }) {
|
||||||
|
if (!page.isAllowedPage()) return;
|
||||||
|
styles.ensureExtensionStyles();
|
||||||
|
products.ensureProductsBackgroundMessageHandler();
|
||||||
|
environment.window?.addEventListener?.("hashchange", () => page.insertTile());
|
||||||
|
if (page.insertTile()) return;
|
||||||
|
const observer = new environment.MutationObserver(() => page.insertTile());
|
||||||
|
observer.observe(environment.document.documentElement, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
622
src/content/modal.js
Normal file
622
src/content/modal.js
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
function createContentModal({
|
||||||
|
openBookmarkPicker,
|
||||||
|
getShortcutBookmarkStatus,
|
||||||
|
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,
|
||||||
|
closeInstallBaseModal,
|
||||||
|
closeServiceRequestsModal,
|
||||||
|
closeOrganizationChart,
|
||||||
|
closeActionForm,
|
||||||
|
closeCustomerFilterPanel,
|
||||||
|
closeOwnerFilterPanel,
|
||||||
|
closePreferencesModal,
|
||||||
|
createSettingsIcon,
|
||||||
|
createRefreshAllButton,
|
||||||
|
createManageTimeButton,
|
||||||
|
createActivityFilterControls,
|
||||||
|
mountOpportunityViews,
|
||||||
|
debugState,
|
||||||
|
ensureExtensionStyles,
|
||||||
|
getCustomerOptions,
|
||||||
|
getOwnerOptions,
|
||||||
|
getSavedOpportunityTypeValues,
|
||||||
|
getSavedOpptyProductsCacheHours,
|
||||||
|
getSavedTheme,
|
||||||
|
openPreferencesModal,
|
||||||
|
opportunitiesFilterState,
|
||||||
|
opportunitiesQueryState,
|
||||||
|
preferencesState,
|
||||||
|
renderCustomerFilter,
|
||||||
|
renderCustomerFilterList,
|
||||||
|
renderOpportunitiesTable,
|
||||||
|
renderOwnerFilter,
|
||||||
|
renderOwnerFilterList,
|
||||||
|
renderStageFilterButtons,
|
||||||
|
renderStatusFilterButtons,
|
||||||
|
requestOpportunitiesForPeriod,
|
||||||
|
closeCrmSrPopover,
|
||||||
|
requestTokenRelayOnce,
|
||||||
|
ensureCurrentUser,
|
||||||
|
prepareSrSession,
|
||||||
|
resetCustomerAndOwnerFiltersForPeriod,
|
||||||
|
resetOpportunitiesTable,
|
||||||
|
restoreFilterPreferences,
|
||||||
|
saveFilterPreferences,
|
||||||
|
toggleCustomerFilterPanel,
|
||||||
|
toggleDebugPanel,
|
||||||
|
toggleOwnerFilterPanel,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document, window, setTimeout, clearTimeout, matchMedia } = environment;
|
||||||
|
let authCompletion = null;
|
||||||
|
let bookmarkHintTimer = null;
|
||||||
|
let bookmarkHintVersion = 0;
|
||||||
|
function hideBookmarkHint() {
|
||||||
|
bookmarkHintVersion++;
|
||||||
|
if (bookmarkHintTimer !== null) clearTimeout(bookmarkHintTimer);
|
||||||
|
bookmarkHintTimer = null;
|
||||||
|
const hint = document.getElementById("opportunities-extension-bookmark-hint");
|
||||||
|
if (hint) hint.hidden = true;
|
||||||
|
}
|
||||||
|
async function refreshBookmarkHint() {
|
||||||
|
hideBookmarkHint();
|
||||||
|
const version = bookmarkHintVersion;
|
||||||
|
try {
|
||||||
|
const result = await getShortcutBookmarkStatus?.();
|
||||||
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
const hint = document.getElementById("opportunities-extension-bookmark-hint");
|
||||||
|
if (version !== bookmarkHintVersion || !modal || modal.hidden || !hint || !result?.ok || result.bookmarked) return;
|
||||||
|
hint.hidden = false;
|
||||||
|
bookmarkHintTimer = setTimeout(hideBookmarkHint, 20000);
|
||||||
|
} catch { /* A failed bookmarks check must not interrupt opportunities. */ }
|
||||||
|
}
|
||||||
|
function resetAuthCompletion() {
|
||||||
|
if (!authCompletion) return;
|
||||||
|
clearTimeout(authCompletion.waitTimer);
|
||||||
|
clearTimeout(authCompletion.hideTimer);
|
||||||
|
authCompletion.badge.setAttribute("data-fading", "false");
|
||||||
|
authCompletion = null;
|
||||||
|
}
|
||||||
|
function openOpportunitiesModal() {
|
||||||
|
ensureExtensionStyles();
|
||||||
|
const oldDebug = document.getElementById(DEBUG_PANEL_ID);
|
||||||
|
if (oldDebug) { oldDebug.hidden = true; oldDebug.textContent = ""; }
|
||||||
|
debugState.debugSecretsVisible = false;
|
||||||
|
|
||||||
|
const existingModal = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
|
if (existingModal) {
|
||||||
|
existingModal.hidden = false;
|
||||||
|
refreshBookmarkHint();
|
||||||
|
prepareSrSession?.();
|
||||||
|
existingModal.querySelector("select").focus();
|
||||||
|
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||||
|
ensureEscapeKeyHandler();
|
||||||
|
ensureCurrentUser().then(() => {
|
||||||
|
if (existingModal.hidden) return;
|
||||||
|
return requestTokenRelayOnce();
|
||||||
|
})
|
||||||
|
.then(() => renderOpportunitiesTable()).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.setAttribute("lang", "en");
|
||||||
|
authSessionState.tokenRelayRequest = null;
|
||||||
|
authSessionState.consumerTokenServiceRequest = null;
|
||||||
|
authSessionState.accessToken = "";
|
||||||
|
authSessionState.principal = "";
|
||||||
|
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");
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
const bookmarkHint = document.createElement("span");
|
||||||
|
bookmarkHint.id = "opportunities-extension-bookmark-hint";
|
||||||
|
bookmarkHint.className = "opportunities-extension-products-cache-timestamp";
|
||||||
|
bookmarkHint.setAttribute("role", "status");
|
||||||
|
bookmarkHint.textContent = "Save to favorites: Ctrl+Shift+F";
|
||||||
|
bookmarkHint.hidden = true;
|
||||||
|
subtitleRow.append(productsCacheTimestamp, bookmarkHint, authBadge, productsProgressBadge);
|
||||||
|
|
||||||
|
const titleBlock = document.createElement("div");
|
||||||
|
titleBlock.className = "opportunities-extension-title-row";
|
||||||
|
titleBlock.append(title, subtitleRow);
|
||||||
|
|
||||||
|
const closeButton = document.createElement("button");
|
||||||
|
closeButton.type = "button";
|
||||||
|
closeButton.className = "opportunities-extension-icon-button";
|
||||||
|
closeButton.setAttribute("aria-label", "Close");
|
||||||
|
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", "Open preferences");
|
||||||
|
preferencesButton.title = "Preferences";
|
||||||
|
preferencesButton.append(createSettingsIcon());
|
||||||
|
preferencesButton.addEventListener("click", openPreferencesModal);
|
||||||
|
|
||||||
|
const headerActions = document.createElement("div");
|
||||||
|
headerActions.className = "opportunities-extension-header-actions";
|
||||||
|
headerActions.append(themeButton, createRefreshAllButton(() => select.value), createManageTimeButton(), 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();
|
||||||
|
renderOwnerFilter();
|
||||||
|
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();
|
||||||
|
renderCustomerFilter();
|
||||||
|
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());
|
||||||
|
const badgeFilters = createActivityFilterControls(form, statusFilter, stageFilter);
|
||||||
|
form.append(field, customerFilter, ownerFilter, tableSearchField, badgeFilters);
|
||||||
|
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("data-table-layout", "standard");
|
||||||
|
opportunitiesTable.setAttribute("aria-label", "Opportunities");
|
||||||
|
tableSurface.append(opportunitiesTable);
|
||||||
|
|
||||||
|
mountOpportunityViews(body, 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);
|
||||||
|
refreshBookmarkHint();
|
||||||
|
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||||
|
ensureEscapeKeyHandler();
|
||||||
|
renderStageFilterButtons();
|
||||||
|
renderStatusFilterButtons();
|
||||||
|
renderCustomerFilter();
|
||||||
|
renderOwnerFilter();
|
||||||
|
renderOpportunitiesTable();
|
||||||
|
requestOpportunitiesForPeriod(select.value);
|
||||||
|
prepareSrSession?.();
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (authCompletion && (authCompletion.badge !== badge || badgeStatus !== AUTH_STATUS.authenticated)) resetAuthCompletion();
|
||||||
|
badge.hidden = Boolean(authCompletion?.hidden);
|
||||||
|
if (badgeStatus === AUTH_STATUS.authenticated && !authCompletion) {
|
||||||
|
const current = { badge, hidden: false, waitTimer: null, hideTimer: null };
|
||||||
|
authCompletion = current;
|
||||||
|
badge.setAttribute("data-fading", "false");
|
||||||
|
current.waitTimer = setTimeout(() => {
|
||||||
|
if (authCompletion !== current) return;
|
||||||
|
badge.setAttribute("data-fading", "true");
|
||||||
|
const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
current.hideTimer = setTimeout(() => {
|
||||||
|
if (authCompletion !== current) return;
|
||||||
|
current.hidden = true;
|
||||||
|
badge.hidden = true;
|
||||||
|
}, reducedMotion ? 0 : 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
if (closeOrganizationChart()) return;
|
||||||
|
hideBookmarkHint();
|
||||||
|
resetAuthCompletion();
|
||||||
|
closeCrmSrPopover();
|
||||||
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
closePreferencesModal();
|
||||||
|
closeAccountPlansModal();
|
||||||
|
closeInstallBaseModal();
|
||||||
|
closeServiceRequestsModal();
|
||||||
|
closeActionForm(false);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcutKey = event.key.toLowerCase();
|
||||||
|
if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey &&
|
||||||
|
(shortcutKey === "d" || shortcutKey === "f")) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.repeat) return;
|
||||||
|
if (shortcutKey === "d") toggleDebugPanel();
|
||||||
|
else openBookmarkPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (closeOrganizationChart()) { event.preventDefault(); return; }
|
||||||
|
if (closeActionForm()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (closeServiceRequestsModal()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (closeInstallBaseModal()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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, hideBookmarkHint, refreshBookmarkHint };
|
||||||
|
}
|
||||||
11
src/content/shortcut-intent.js
Normal file
11
src/content/shortcut-intent.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Capture the bookmark before Fusion rewrites its navigation URL.
|
||||||
|
(() => {
|
||||||
|
if (window.top !== window) return;
|
||||||
|
const capture = () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (url.hash !== "#opportunities-extension" && url.searchParams.get("opportunitiesExtension") !== "open") return;
|
||||||
|
try { window.sessionStorage.setItem("opportunitiesExtension.shortcutIntent", String(Date.now())); } catch { /* The URL remains a fallback. */ }
|
||||||
|
};
|
||||||
|
capture();
|
||||||
|
window.addEventListener("hashchange", capture);
|
||||||
|
})();
|
||||||
22
src/content/styles.js
Normal file
22
src/content/styles.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
function createContentStyles({
|
||||||
|
STYLE_ID,
|
||||||
|
enableDialogDrag,
|
||||||
|
redwoodStyles,
|
||||||
|
environment
|
||||||
|
}) {
|
||||||
|
const { document } = environment;
|
||||||
|
function ensureExtensionStyles() {
|
||||||
|
enableDialogDrag?.();
|
||||||
|
if (document.getElementById(STYLE_ID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent = redwoodStyles;
|
||||||
|
|
||||||
|
document.head.append(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ensureExtensionStyles };
|
||||||
|
}
|
||||||
108
src/content/styles/dialogs.css
Normal file
108
src/content/styles/dialogs.css
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/* Modal titles follow the customer Service Requests heading. Keep section and
|
||||||
|
calendar headings independent from dialog typography. */
|
||||||
|
.opportunities-extension-modal :is(.opportunities-extension-action-form-dialog,
|
||||||
|
.opportunities-extension-preferences-dialog, .opportunities-extension-account-plans-dialog,
|
||||||
|
.oe-org-confirm-panel) :is(header, h2, h3) {
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal [data-dialog-dragging="true"] :is(header, h2, h3) { cursor: grabbing; }
|
||||||
|
.opportunities-extension-modal #opportunities-extension-bookmark-hint {
|
||||||
|
background: #9a4600;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #b95b00;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] #opportunities-extension-bookmark-hint {
|
||||||
|
background: #ffca70;
|
||||||
|
color: #3b2400;
|
||||||
|
border-color: #ffd99a;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal :is(
|
||||||
|
.opportunities-extension-header,
|
||||||
|
.opportunities-extension-account-plans-header,
|
||||||
|
.opportunities-extension-preferences-header
|
||||||
|
) {
|
||||||
|
padding: 16px;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
/* The main heading, status badges and toolbar share one vertical center. */
|
||||||
|
.opportunities-extension-modal .opportunities-extension-header {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-title-row,
|
||||||
|
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-subtitle-row,
|
||||||
|
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-header-actions {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-subtitle-row {
|
||||||
|
margin-block: 0;
|
||||||
|
}
|
||||||
|
/* These headers live in panels that already supply the outer inset. */
|
||||||
|
.opportunities-extension-modal :is(.oe-org-dialog.oe-contact-dialog,
|
||||||
|
.oe-org-confirm-panel, .opportunities-extension-projection-confirm,
|
||||||
|
.opportunities-extension-debug-panel) { padding: 16px; }
|
||||||
|
.opportunities-extension-modal :is(.oe-org-editor-header,
|
||||||
|
.opportunities-extension-debug-panel-header) {
|
||||||
|
padding: 0;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal :is(
|
||||||
|
.opportunities-extension-account-plans-header,
|
||||||
|
.opportunities-extension-preferences-header,
|
||||||
|
.opportunities-extension-debug-panel-header
|
||||||
|
) p {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
/* Search controls share the 16px inset of their table frames. */
|
||||||
|
.opportunities-extension-modal :is(.opportunities-extension-customer-table-toolbar,
|
||||||
|
.opportunities-extension-ratecard-toolbar,
|
||||||
|
.opportunities-extension-ratecard-plan-details) { padding-inline: 16px; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-customer-table-toolbar {
|
||||||
|
padding-block: 12px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal .opportunities-extension-customer-table-search {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
height: 38px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal :is(
|
||||||
|
.opportunities-extension-header,
|
||||||
|
.opportunities-extension-account-plans-header,
|
||||||
|
.opportunities-extension-preferences-header,
|
||||||
|
.opportunities-extension-debug-panel-header,
|
||||||
|
.oe-org-editor-header
|
||||||
|
) :is(h1, h2, h3),
|
||||||
|
.opportunities-extension-modal .opportunities-extension-projection-confirm h2,
|
||||||
|
.opportunities-extension-modal .oe-org-confirm-panel h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 24px;
|
||||||
|
letter-spacing: normal;
|
||||||
|
color: #312d2a;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] :is(
|
||||||
|
.opportunities-extension-header,
|
||||||
|
.opportunities-extension-account-plans-header,
|
||||||
|
.opportunities-extension-preferences-header,
|
||||||
|
.opportunities-extension-debug-panel-header,
|
||||||
|
.oe-org-editor-header
|
||||||
|
) :is(h1, h2, h3),
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-confirm h2,
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .oe-org-confirm-panel h3 {
|
||||||
|
color: #f6f4f2;
|
||||||
|
}
|
||||||
3081
src/content/styles/redwood.css
Normal file
3081
src/content/styles/redwood.css
Normal file
File diff suppressed because it is too large
Load Diff
138
src/content/styles/tables.css
Normal file
138
src/content/styles/tables.css
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/* Shared data-table presentation. Calendar grids are intentionally not data tables. */
|
||||||
|
.opportunities-extension-modal :is(
|
||||||
|
.opportunities-extension-table-surface,
|
||||||
|
.opportunities-extension-install-base-content,
|
||||||
|
.opportunities-extension-service-requests-content,
|
||||||
|
.opportunities-extension-ratecard-viewport,
|
||||||
|
.opportunities-extension-projection-viewport,
|
||||||
|
.oe-time-grid,
|
||||||
|
.oe-contact-opportunities-table
|
||||||
|
) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--oe-table-heading-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
/* Unpadded dialogs need an inset on the scrolling frame itself. */
|
||||||
|
.opportunities-extension-modal :is(
|
||||||
|
.opportunities-extension-install-base-content,
|
||||||
|
.opportunities-extension-service-requests-content,
|
||||||
|
.opportunities-extension-ratecard-viewport
|
||||||
|
) { margin: 16px; }
|
||||||
|
/* Other frames already sit inside a padded body: keep a single gutter. */
|
||||||
|
.opportunities-extension-modal :is(
|
||||||
|
.opportunities-extension-body,
|
||||||
|
.opportunities-extension-projection-body,
|
||||||
|
.opportunities-extension-products-detail,
|
||||||
|
.oe-time-body,
|
||||||
|
.oe-org-dialog.oe-contact-opportunities
|
||||||
|
) { padding: 16px; }
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-products-table {
|
||||||
|
border: 1px solid var(--oe-table-heading-border);
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal {
|
||||||
|
--oe-table-heading: #faf9f8;
|
||||||
|
--oe-table-text: #312d2a;
|
||||||
|
--oe-table-border: #ebe8e5;
|
||||||
|
--oe-table-heading-border: #b8b2ad;
|
||||||
|
--oe-table-hover: #f5f4f2;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] {
|
||||||
|
--oe-table-heading: #333130;
|
||||||
|
--oe-table-text: #f6f4f2;
|
||||||
|
--oe-table-border: #3f3c39;
|
||||||
|
--oe-table-heading-border: #4e4a46;
|
||||||
|
--oe-table-hover: #35322f;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"]:not(.fc-scrollgrid) {
|
||||||
|
width: 100%; border-collapse: collapse; border-spacing: 0; table-layout: auto;
|
||||||
|
color: var(--oe-table-text); font-size: 12px; line-height: 1.35;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th {
|
||||||
|
padding: 10px 12px; height: 44px; vertical-align: middle; text-align: left;
|
||||||
|
white-space: nowrap; overflow-wrap: normal; word-break: normal;
|
||||||
|
font-size: 12px; font-weight: 700; color: var(--oe-table-text);
|
||||||
|
background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-heading-border);
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > td {
|
||||||
|
padding: 10px 12px; vertical-align: middle; text-align: left;
|
||||||
|
border-bottom: 1px solid var(--oe-table-border);
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr:hover { background: var(--oe-table-hover); }
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button,
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-ratecard-sort {
|
||||||
|
display: flex; align-items: center; justify-content: flex-start; gap: 8px;
|
||||||
|
width: 100%; min-width: max-content; min-height: 28px; padding: 4px 18px 4px 0;
|
||||||
|
border: 0; border-radius: 0; background: transparent; color: inherit;
|
||||||
|
text-align: left; white-space: nowrap; overflow-wrap: normal; font-weight: 700;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-sort-label { white-space: nowrap; overflow-wrap: normal; word-break: normal; }
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button::after { position: absolute; right: 0; left: auto; font-size: 12px; }
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-ratecard-sort-icon { margin-left: auto; }
|
||||||
|
/* Currency columns keep their heading and value on the same right edge. */
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] :is(th, td)[data-align="money"] { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th[data-align="money"] .opportunities-extension-sort-button,
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort { justify-content: flex-end; text-align: right; padding: 4px 0 4px 18px; }
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th[data-align="money"] .opportunities-extension-sort-button::after { left: 0; right: auto; }
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort-icon { order: -1; margin-left: 0; margin-right: auto; }
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] td[data-align="money"] > span[class*="skeleton"] { margin-left: auto; }
|
||||||
|
/* Preserve room for Overview badges and the compact daily entry controls. */
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td:is([data-column="name"], [data-column="customer"]) { padding-bottom: 10px; vertical-align: top; }
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content > .opportunities-extension-cell-actions {
|
||||||
|
position: static;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-left: 5px; padding-right: 5px; }
|
||||||
|
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] { table-layout: fixed; }
|
||||||
|
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > th[scope="row"] { padding: 10px 12px; text-align: left; background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-border); }
|
||||||
|
/* Rate Card must fit its drawer; headings may wrap at spaces, never mid-word. */
|
||||||
|
/* Overview reserves nearly half its width for the two primary descriptions. */
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td)[data-column],
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td)[data-column] :is(a, span, button) {
|
||||||
|
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] { table-layout: fixed; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="name"] { width: 24%; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="customer"] { width: 24%; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="optyNumber"] { width: 7%; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="owner"] { width: 7%; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="revenue"] { width: 8%; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="closeDate"] { width: 7%; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="status"] { width: 5%; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="winProbability"] { width: 4%; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="stage"] { width: 6%; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="lastUpdateDate"] { width: 8%; }
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-inline: 6px; }
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button { min-width: 0; gap: 4px; }
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] .opportunities-extension-sort-label { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] {
|
||||||
|
min-width: 0;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) {
|
||||||
|
padding-inline: 6px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th,
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-ratecard-sort {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
word-break: normal;
|
||||||
|
hyphens: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-ratecard-sort {
|
||||||
|
padding: 4px 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal .opportunities-extension-ratecard-sort > span:first-child { min-width: 0; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-ratecard-sort-icon { flex: 0 0 12px; }
|
||||||
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 };
|
||||||
|
}
|
||||||
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 = 350;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
54
src/features/consumption/header-view.js
Normal file
54
src/features/consumption/header-view.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
function createConsumptionHeaderView({ createCloseIcon, formatOracleResponseDate, environment }) {
|
||||||
|
const { document } = environment;
|
||||||
|
function createHeader({ titleId, customerLabel, closeLabel, onClose, titleText = "Cloud Consumption" }) {
|
||||||
|
const header = document.createElement("header");
|
||||||
|
header.className = "opportunities-extension-account-plans-header";
|
||||||
|
const heading = document.createElement("div");
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.id = titleId;
|
||||||
|
title.textContent = titleText;
|
||||||
|
const subtitle = document.createElement("p");
|
||||||
|
subtitle.textContent = customerLabel;
|
||||||
|
heading.append(title, subtitle);
|
||||||
|
const closeButton = document.createElement("button");
|
||||||
|
closeButton.type = "button";
|
||||||
|
closeButton.className = "opportunities-extension-icon-button";
|
||||||
|
closeButton.setAttribute("aria-label", closeLabel);
|
||||||
|
closeButton.title = "Close";
|
||||||
|
closeButton.append(createCloseIcon());
|
||||||
|
closeButton.addEventListener("click", onClose);
|
||||||
|
header.append(heading, closeButton);
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
function createDetails(plan, { contractAction = null } = {}) {
|
||||||
|
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);
|
||||||
|
if (field.label === "Contract ID" && contractAction) {
|
||||||
|
group.className = "opportunities-extension-contract-row";
|
||||||
|
const value = document.createElement("span");
|
||||||
|
value.textContent = description.textContent;
|
||||||
|
description.textContent = "";
|
||||||
|
description.className = "opportunities-extension-contract-ratecard";
|
||||||
|
description.append(value, contractAction);
|
||||||
|
}
|
||||||
|
group.append(term, description);
|
||||||
|
details.append(group);
|
||||||
|
});
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
return { createHeader, createDetails };
|
||||||
|
}
|
||||||
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 };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user