Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1758a1f27e
|
|||
|
0d9e654b11
|
|||
|
0d0a7cfe14
|
|||
|
d689807a50
|
|||
|
2363e37aab
|
|||
|
9cdb46b6c2
|
|||
|
00bcfdc42e
|
|||
|
9b5e751179
|
|||
|
88de9b81b7
|
|||
|
3fa212e561
|
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).
|
||||
437
README.md
437
README.md
@@ -1,31 +1,95 @@
|
||||
# Opportunities Extension 1.0.7
|
||||
# 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 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. **Calendar** displays opportunities
|
||||
by Close Date (`EffectiveDate`) with month and week views only. Both views share
|
||||
Reference, search and all existing filters; switching views does not send requests.
|
||||
Events use date-only values without time-zone shifts, stage colors, and a read-only
|
||||
detail with an opportunity link and copy button. Invalid or missing dates appear
|
||||
in a separate list. Calendar navigation does not change the selected Reference.
|
||||
**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 and Manage Time.
|
||||
Preferences are restored when reopening each view and survive page reloads in the
|
||||
same browser profile. Opportunity product tables retain their original order.
|
||||
Each day shows a filtered opportunity count, at most two events and an overflow
|
||||
link to the remaining events. The grid fits the available height and reduces
|
||||
visible events to one or zero when needed to keep every week visible. Missing
|
||||
close dates are available in a collapsible list below the calendar.
|
||||
Fiscal labels use `FY27-Q2-W19`: June-May fiscal
|
||||
years, Sunday-Saturday weeks, and W1 for the week containing June 1. A quarter is
|
||||
assigned by the week's Sunday, except the first fiscal week which belongs to Q1.
|
||||
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
|
||||
@@ -53,9 +117,29 @@ 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`). Concurrent stale saves are rejected. Charts are local and do not
|
||||
sync between devices or write to Oracle services. They support up to 500 people
|
||||
and 60 reporting levels, with required name and optional 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
|
||||
@@ -74,7 +158,7 @@ check below.
|
||||
|
||||
- `src/content/`: page integration, modal shell, theme and content composition.
|
||||
- `src/background/`: synchronous message registration and background composition.
|
||||
- `src/features/`: opportunities, products, consumption, AI summaries, preferences and debug.
|
||||
- `src/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.
|
||||
@@ -85,52 +169,66 @@ check below.
|
||||
- `dist/chromium/`: output for Google Chrome and Microsoft Edge.
|
||||
- `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 (between theme and Preferences) invalidates
|
||||
persisted Products and activity caches and clears in-memory data views. It reloads
|
||||
the selected Reference, fetching fresh products and then the permitted SR/Actions
|
||||
queries. Customer details, rate cards, consumption charts and AI summaries are
|
||||
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.
|
||||
|
||||
On the first Opportunities modal opening in a page session, `requestCurrentUser`
|
||||
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
|
||||
Reference changes and modal reopenings reuse that result. Users with `SALES_REP`
|
||||
do not trigger CRM Service Request or Workload Actions queries or see their badges.
|
||||
Identity must contain a valid user (id, name, roles) and token before dependent
|
||||
requests start. Failed identity validation keeps those requests waiting and retries
|
||||
with backoff. Reload the page to refresh identity/roles.
|
||||
The identity token is retained in debug responses and hidden by default; the
|
||||
explicit sensitive-values toggle reveals the original value.
|
||||
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 an initial identity failure, recovery opens `https://spa.oracle.com/` in one
|
||||
inactive auxiliary tab. The top-frame-only `spa-page.js` content script inspects
|
||||
the page without sending HTTP requests. It rejects error pages, SAML/login forms,
|
||||
OAuth callbacks and incomplete documents; cookies, HTTP 200 and elapsed time alone
|
||||
do not establish readiness. After a stable loaded document is verified, identity
|
||||
is fetched in that tab's same-origin context, at most once per document. A failed
|
||||
read requires a fresh verified navigation before retrying. Rejected callbacks
|
||||
restart at the SPA home URL, never at the one-use callback URL. A two-minute
|
||||
readiness timeout ends the cycle without an additional API probe. The owned tab
|
||||
and observers are cleaned up on success or failure. Debug metadata records the
|
||||
page state, wait reason and `spa-tab` execution context; sensitive values remain
|
||||
hidden by default. Reload the extension after changes to the manifest or script.
|
||||
|
||||
Consumer token recovery also handles HTTP 401/403, in addition to HTTP 200 with
|
||||
invalid JSON. It opens the existing SPA session URL in an inactive temporary tab,
|
||||
preserves the originating window and Firefox cookie store, and retries within
|
||||
the same five-attempt limit. Debug entries retain the HTTP status and recovery
|
||||
reason. The auxiliary tab is closed after valid JSON or exhausted retries.
|
||||
|
||||
After a completed product-loading cycle with fresh API results, `resquestCrmServiceRequest` fetches all pages of
|
||||
`CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`,
|
||||
using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest`
|
||||
query endpoint, independently of the opportunity query version. Calls appear in
|
||||
the Service Requests debug category with masked credentials.
|
||||
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
|
||||
@@ -150,12 +248,12 @@ SR snapshots are stored separately by update timestamp so changing Reference doe
|
||||
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.
|
||||
Products loaded from cache restore matching activity entries without network calls.
|
||||
Missing, expired or mismatched entries are not treated as confirmed empty results;
|
||||
they wait for a real product refresh before a new activity query is allowed.
|
||||
Empty and entirely failed product cycles do not query activities. In mixed cycles,
|
||||
only fresh products permit activity API calls on cache misses; an already-loaded
|
||||
SR result is reused and associated with the new product update.
|
||||
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
|
||||
@@ -196,19 +294,33 @@ 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. Cached products still do not trigger network
|
||||
lookups. Each distinct `RevnId` uses `requestWlActions` with
|
||||
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. Credentials stay in memory only;
|
||||
the principal is used to scope persisted activity entries.
|
||||
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
|
||||
that load products from cache do not refresh activities.
|
||||
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
|
||||
|
||||
Use Node.js:
|
||||
@@ -227,14 +339,12 @@ after updating; clear historical extension errors to check for new failures.
|
||||
Do not edit files under `dist` directly. Both targets use Manifest V3, with a
|
||||
Chromium service worker and Firefox background scripts. The build composes classic
|
||||
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
|
||||
context. It serializes `src/content/styles/redwood.css` followed by the scoped
|
||||
`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` and `src/features/ratecard/styles.css` at
|
||||
build time. There is no
|
||||
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.
|
||||
|
||||
## Tests and Maintenance
|
||||
## Customer Insights
|
||||
|
||||
Installed Base and Service Requests include a search input above their tables.
|
||||
Search filters loaded records across every displayed column, including raw and
|
||||
@@ -256,8 +366,12 @@ It uses the existing OCICONS-TS `uiaasHeader.value` in `G2m-Authorization` and i
|
||||
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. Only the drawer
|
||||
table scrolls, horizontally when its available width is below 1100px.
|
||||
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
|
||||
@@ -296,7 +410,8 @@ 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. It
|
||||
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`
|
||||
@@ -306,26 +421,35 @@ 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 below Name and the customer
|
||||
consumption action below Customer, without a separate Actions column. Compact
|
||||
columns retain their values; Name and Customer share the remaining width while
|
||||
Owner is fixed at 132px. Both text cells reserve at least two text lines, and
|
||||
their controls stay anchored 10px above the row bottom, including while products
|
||||
load. Longer text can grow the row without overlapping controls. Horizontal
|
||||
scrolling is reserved for table containers below
|
||||
1120px. The scoped table stylesheet does not change product-table column widths
|
||||
or the existing light/dark colors.
|
||||
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.
|
||||
|
||||
Product HTTP requests appear in Debug Requests (Ctrl+D) as soon as they start.
|
||||
## 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+D, without
|
||||
opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+D
|
||||
returns to the underlying view. Header/category navigation remains above the
|
||||
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
|
||||
@@ -336,15 +460,20 @@ 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. Focus, expansion and timeline scroll position survive log updates.
|
||||
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, Products, AI, Consumption, Installed Base and Service Requests
|
||||
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
|
||||
@@ -354,112 +483,28 @@ Tests use built-in Node.js modules and synthetic credentials, without real Fusio
|
||||
requests. The characterization suite verifies manifests, source/output equality,
|
||||
fiscal dates, payloads, preferences, pagination, cache/progress and both browser API
|
||||
styles. Architecture tests enforce source inclusion, explicit factory dependencies,
|
||||
isolated state and unchanged CSS. These complement consumption, session recovery
|
||||
and AI suites. `check` also fails if generated packages are stale.
|
||||
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`. Development checks were run with Node 24.19.0.
|
||||
`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,
|
||||
AI text and consumption; unexpected requests fail closed. It does not authenticate
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.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.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.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.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.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.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.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.
|
||||
|
||||
## Loading in Development Mode
|
||||
|
||||
### Chrome or Microsoft Edge
|
||||
@@ -481,3 +526,25 @@ a focused chart fixture. Stop the server with Ctrl+C when finished.
|
||||
- `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.
|
||||
|
||||
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.
|
||||
47
dist/chromium/background.js
vendored
47
dist/chromium/background.js
vendored
@@ -292,6 +292,9 @@ function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
|
||||
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||
function createMessageTypes() {
|
||||
return Object.freeze({
|
||||
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
|
||||
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
|
||||
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
|
||||
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
|
||||
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
|
||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||
@@ -359,7 +362,7 @@ function createBackgroundContracts() {
|
||||
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/";
|
||||
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;
|
||||
@@ -3137,10 +3140,52 @@ function registerBackgroundMessages({
|
||||
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 }));
|
||||
|
||||
708
dist/chromium/content.js
vendored
708
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
14
dist/chromium/manifest.json
vendored
14
dist/chromium/manifest.json
vendored
@@ -2,8 +2,9 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Opportunities Extension",
|
||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
"version": "1.0.8",
|
||||
"version": "1.1.0",
|
||||
"permissions": [
|
||||
"bookmarks",
|
||||
"cookies",
|
||||
"storage",
|
||||
"webRequest",
|
||||
@@ -15,6 +16,17 @@
|
||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||
],
|
||||
"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": [
|
||||
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
||||
|
||||
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);
|
||||
})();
|
||||
47
dist/firefox/background.js
vendored
47
dist/firefox/background.js
vendored
@@ -292,6 +292,9 @@ function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
|
||||
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||
function createMessageTypes() {
|
||||
return Object.freeze({
|
||||
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
|
||||
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
|
||||
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
|
||||
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
|
||||
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
|
||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||
@@ -359,7 +362,7 @@ function createBackgroundContracts() {
|
||||
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/";
|
||||
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;
|
||||
@@ -3137,10 +3140,52 @@ function registerBackgroundMessages({
|
||||
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 }));
|
||||
|
||||
708
dist/firefox/content.js
vendored
708
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
14
dist/firefox/manifest.json
vendored
14
dist/firefox/manifest.json
vendored
@@ -2,8 +2,9 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Opportunities Extension",
|
||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
"version": "1.0.8",
|
||||
"version": "1.1.0",
|
||||
"permissions": [
|
||||
"bookmarks",
|
||||
"cookies",
|
||||
"storage",
|
||||
"webRequest",
|
||||
@@ -15,6 +16,17 @@
|
||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||
],
|
||||
"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": [
|
||||
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
||||
|
||||
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);
|
||||
})();
|
||||
@@ -1,7 +1,14 @@
|
||||
# Architecture and Compatibility Contracts
|
||||
|
||||
This document describes the integrated modular implementation. Verification and
|
||||
preexisting risks are tracked in [refactoring.md](refactoring.md).
|
||||
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
|
||||
|
||||
@@ -12,17 +19,24 @@ preexisting risks are tracked in [refactoring.md](refactoring.md).
|
||||
- `dist/chromium` serves Chrome and Edge; `dist/firefox` serves Firefox. Do not edit outputs.
|
||||
- Both manifests use V3. Chromium has a classic background service worker; Firefox
|
||||
uses `background.scripts`. Neither entry point is declared as a module.
|
||||
- Content scripts run at `document_idle`, in all matching frames on the two
|
||||
- `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` is a separate top-frame-only SPA content script, copied
|
||||
- `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.
|
||||
- Permissions remain `cookies` and `storage`, with host access limited to the
|
||||
configured Fusion host and `spa.oracle.com`.
|
||||
- `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
|
||||
@@ -40,41 +54,67 @@ different from extension storage and is not a safe place for credentials.
|
||||
| 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 | AI summary | Per-opportunity in-flight state and summary; fresh generation on expansion |
|
||||
| Content | Authentication/debug | General and scoped token promises, SPA authorizations, request logs, visibility flag |
|
||||
| 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 abort network traffic. No AbortController is currently used.
|
||||
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.
|
||||
Production tokens are held in memory, not deliberately persisted by the extension.
|
||||
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 both tokenrelays | Cookie recovery tab on missing/invalid response; existing retry rules |
|
||||
| Fusion general | GET `/fscmRestApi/tokenrelay` | Opportunity POST and product GET Bearer | Content promise/cache; no expiry-based refresh equivalent to Sales Common |
|
||||
| Sales Common | GET `/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F` | AI summary Bearer | In-memory `expires_in`, shared in-flight acquisition; at most one summary retry after 401/403 |
|
||||
| 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 |
|
||||
| --- | --- | --- |
|
||||
| requestOpportunities | POST `/crmRestApi/searchResources/11.13.18.05/custom-actions/queries` | RecordSet OR plus EffectiveDate AND, original fields, Preference transient, pages of 15 |
|
||||
| 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 |
|
||||
| requestGenerateAiSummary | POST `/api/sales-common/gen-ai/v1/summarizations/generate` | `{path,promptCode,object,puid}`; prompt `cx.sales.generate_opportunity_summary`; only summaryText displayed |
|
||||
| requestConsumerTokenService | GET `g2m-consumer-application/consumerTokenService` | No-store; parse JSON; HTML 200 recovery via inactive SPA UI tab; maximum five HTTP attempts |
|
||||
| requestAccountSummary | POST `provider-proxy/g2m-account/service/accountsummary` | ACCP-TS; `[{key:"regId",value:partyNumber}]` |
|
||||
| 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 |
|
||||
@@ -87,9 +127,23 @@ 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
|
||||
|
||||
All names below have the `opportunitiesExtension.` prefix. Unknown messages return
|
||||
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
|
||||
@@ -121,25 +175,94 @@ Do not expose a caller-provided arbitrary request URL through the background.
|
||||
| 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 in background. Storage
|
||||
failures are tolerated for the active view. Recreating background reloads stored
|
||||
products but does not restore in-flight jobs. Cache TTL does not physically delete
|
||||
expired entries. Account/container isolation limitations are tracked separately.
|
||||
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; RENEWALS currently
|
||||
has no date range and therefore does not load opportunities.
|
||||
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.
|
||||
AI text is inserted with textContent. Debug masking and DOM isolation have known
|
||||
limitations; see the separate security backlog rather than assuming full redaction.
|
||||
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
|
||||
|
||||
@@ -151,8 +274,9 @@ to isolated tests, not published on the page/global object in production.
|
||||
Factories do not import or instantiate peers. Features receive service/auth ports;
|
||||
services/auth receive transport/platform ports; pure models receive data. The
|
||||
composition roots supply callback notifications for UI updates and debug events,
|
||||
preserving existing interactions without module-import cycles. Shared messages
|
||||
are the only source included in both contexts, with distinct instances/state.
|
||||
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.
|
||||
|
||||
@@ -163,11 +287,12 @@ these objects are created per application, never stored in a global registry.
|
||||
| fiscal dates/query payload | `features/opportunities/fiscal-periods.js`, `services/fusion/opportunities.js` | Pure date rules vs HTTP operation |
|
||||
| table/search/filters/formatting/dashboard | `features/opportunities/` | Related presentation responsibilities, explicit state |
|
||||
| saved settings | `features/preferences/storage.js`, `view.js` | Serialization/fallbacks vs dialog |
|
||||
| token families | `auth/content-session.js`, `sales-common.js`, `token-relay.js`, `spa-credentials.js` | Different credentials and lifetime policies |
|
||||
| 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 |
|
||||
| AI summary | `features/ai-summary/content.js`, `state.js` | Per-opportunity lifecycle, safe text and auth port |
|
||||
| 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 |
|
||||
@@ -175,7 +300,7 @@ these objects are created per application, never stored in a global registry.
|
||||
|
||||
The background composition deliberately has no DOM dependency. Listener registration
|
||||
is synchronous, before any storage read or request. Worker restart reconstructs
|
||||
volatile state and lazily reads the existing product cache; tokens are not persisted.
|
||||
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
|
||||
@@ -183,7 +308,8 @@ source strings. Time-sensitive modules are tested with isolated clocks.
|
||||
|
||||
## Adding a Feature
|
||||
|
||||
1. Define its message/API/storage contract and synthetic expected behavior first.
|
||||
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
|
||||
@@ -206,8 +332,6 @@ belong in the consumption feature and SPA service, not the bootstrap.
|
||||
## Source vs Generated Code
|
||||
|
||||
Distribution `content.js` and `background.js` are generated bundles and may remain
|
||||
large. No copy/export/execute JavaScript generator feature was found in the source.
|
||||
AI summaries are text, not executable JavaScript. No empty generators subsystem
|
||||
was added. Build serialization uses JSON.stringify for CSS, retains quotes,
|
||||
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.
|
||||
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "opportunities-extension",
|
||||
"version": "1.0.8",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,14 +9,16 @@ const distDir = path.join(rootDir, "dist");
|
||||
const contentScript = composition.composeScript("content");
|
||||
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 baseManifest = {
|
||||
manifest_version: 3,
|
||||
name: "Opportunities Extension",
|
||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
version: "1.0.8",
|
||||
version: "1.1.0",
|
||||
permissions: [
|
||||
"bookmarks",
|
||||
"cookies",
|
||||
"storage",
|
||||
"webRequest",
|
||||
@@ -28,6 +30,12 @@ const baseManifest = {
|
||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||
],
|
||||
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: [
|
||||
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
||||
@@ -77,7 +85,7 @@ for (const target of targets) {
|
||||
JSON.parse(manifestJson);
|
||||
|
||||
if (checkOnly) {
|
||||
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript, "spa-page.js": spaPageScript };
|
||||
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.`);
|
||||
@@ -90,6 +98,7 @@ for (const target of targets) {
|
||||
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
|
||||
await output.writeOutput(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);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ module.exports = {
|
||||
"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",
|
||||
@@ -31,6 +32,7 @@ module.exports = {
|
||||
"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",
|
||||
|
||||
@@ -16,7 +16,7 @@ function createBackgroundContracts() {
|
||||
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/";
|
||||
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;
|
||||
|
||||
@@ -44,10 +44,52 @@ function registerBackgroundMessages({
|
||||
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 }));
|
||||
|
||||
@@ -79,6 +79,15 @@ function createContentApplication(overrides = {}) {
|
||||
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),
|
||||
@@ -160,9 +169,14 @@ function createContentApplication(overrides = {}) {
|
||||
environment
|
||||
});
|
||||
const svgPaths = createSvgPaths({ environment });
|
||||
const manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, tableSorting, searchableSelect, dates: createTimeDates(),
|
||||
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 || []);
|
||||
@@ -170,6 +184,7 @@ function createContentApplication(overrides = {}) {
|
||||
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() });
|
||||
@@ -178,6 +193,7 @@ function createContentApplication(overrides = {}) {
|
||||
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() });
|
||||
@@ -186,18 +202,21 @@ function createContentApplication(overrides = {}) {
|
||||
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() });
|
||||
@@ -206,13 +225,16 @@ function createContentApplication(overrides = {}) {
|
||||
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
|
||||
@@ -250,6 +272,7 @@ function createContentApplication(overrides = {}) {
|
||||
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(); },
|
||||
@@ -296,8 +319,8 @@ function createContentApplication(overrides = {}) {
|
||||
});
|
||||
const internalSrSession = createInternalSrSession({
|
||||
environment,
|
||||
query: async srId => {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
|
||||
query: async (srId, srNumber) => {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId, srNumber });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
return result;
|
||||
}
|
||||
@@ -305,7 +328,7 @@ function createContentApplication(overrides = {}) {
|
||||
const crmSrPopover = createCrmSrPopover({
|
||||
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
requestDetails: srId => internalSrSession.request(srId),
|
||||
requestDetails: (srId, srNumber) => internalSrSession.request(srId, srNumber),
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
||||
@@ -352,6 +375,9 @@ function createContentApplication(overrides = {}) {
|
||||
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); },
|
||||
@@ -364,7 +390,43 @@ function createContentApplication(overrides = {}) {
|
||||
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),
|
||||
@@ -467,6 +529,7 @@ function createContentApplication(overrides = {}) {
|
||||
});
|
||||
const opportunitiesLinks = createOpportunitiesLinks({
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
searchableSelect,
|
||||
environment
|
||||
});
|
||||
const preferencesStorage = createPreferencesStorage({
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -9,6 +9,20 @@ function createContentFusionPage({
|
||||
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";
|
||||
@@ -88,6 +102,9 @@ function createContentFusionPage({
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 });
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
function createContentModal({
|
||||
openBookmarkPicker,
|
||||
getShortcutBookmarkStatus,
|
||||
ACCOUNT_PLANS_MODAL_ID,
|
||||
AUTH_STATUS,
|
||||
CUSTOMER_FILTER_BUTTON_ID,
|
||||
@@ -72,6 +74,27 @@ function createContentModal({
|
||||
}) {
|
||||
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);
|
||||
@@ -89,6 +112,7 @@ function createContentModal({
|
||||
|
||||
if (existingModal) {
|
||||
existingModal.hidden = false;
|
||||
refreshBookmarkHint();
|
||||
prepareSrSession?.();
|
||||
existingModal.querySelector("select").focus();
|
||||
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||
@@ -148,7 +172,13 @@ function createContentModal({
|
||||
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
||||
productsCacheTimestamp.setAttribute("role", "status");
|
||||
productsCacheTimestamp.hidden = true;
|
||||
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp);
|
||||
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";
|
||||
@@ -280,6 +310,7 @@ function createContentModal({
|
||||
opportunitiesFilterState.hasSavedCustomerFilter = true;
|
||||
saveFilterPreferences();
|
||||
renderCustomerFilter();
|
||||
renderOwnerFilter();
|
||||
renderOpportunitiesTable();
|
||||
});
|
||||
|
||||
@@ -340,6 +371,7 @@ function createContentModal({
|
||||
opportunitiesFilterState.hasSavedOwnerFilter = true;
|
||||
saveFilterPreferences();
|
||||
renderOwnerFilter();
|
||||
renderCustomerFilter();
|
||||
renderOpportunitiesTable();
|
||||
});
|
||||
|
||||
@@ -420,6 +452,7 @@ function createContentModal({
|
||||
shell.append(header, body);
|
||||
overlay.append(shell);
|
||||
document.body.append(overlay);
|
||||
refreshBookmarkHint();
|
||||
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||
ensureEscapeKeyHandler();
|
||||
renderStageFilterButtons();
|
||||
@@ -472,6 +505,7 @@ function createContentModal({
|
||||
|
||||
function closeOpportunitiesModal() {
|
||||
if (closeOrganizationChart()) return;
|
||||
hideBookmarkHint();
|
||||
resetAuthCompletion();
|
||||
closeCrmSrPopover();
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
@@ -502,10 +536,14 @@ function createContentModal({
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.ctrlKey && event.key.toLowerCase() === "d") {
|
||||
const shortcutKey = event.key.toLowerCase();
|
||||
if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey &&
|
||||
(shortcutKey === "d" || shortcutKey === "f")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleDebugPanel();
|
||||
if (event.repeat) return;
|
||||
if (shortcutKey === "d") toggleDebugPanel();
|
||||
else openBookmarkPicker();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -580,5 +618,5 @@ function createContentModal({
|
||||
});
|
||||
}
|
||||
|
||||
return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler };
|
||||
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);
|
||||
})();
|
||||
@@ -1,10 +1,12 @@
|
||||
function createContentStyles({
|
||||
STYLE_ID,
|
||||
enableDialogDrag,
|
||||
redwoodStyles,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function ensureExtensionStyles() {
|
||||
enableDialogDrag?.();
|
||||
if (document.getElementById(STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
/* 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,
|
||||
|
||||
@@ -78,7 +78,18 @@
|
||||
.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: 38px; vertical-align: top; }
|
||||
.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); }
|
||||
|
||||
@@ -27,7 +27,7 @@ function createDebugContentView({
|
||||
if (entry.name === "requestOpptyProducts") return "products";
|
||||
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
|
||||
if (entry.name === "requestInstallBase") return "installBase";
|
||||
if (["requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
|
||||
if (["requestSRSearchResources", "requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
|
||||
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests";
|
||||
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
|
||||
return "other";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.oe-time-modal { grid-template-columns: minmax(0, 1fr); }
|
||||
.oe-time-dialog, .oe-time-body { min-width: 0; }
|
||||
.oe-time-dialog * { box-sizing: border-box; }
|
||||
.oe-time-dialog [hidden] { display: none !important; }
|
||||
.oe-time-dialog [hidden], .oe-time-launcher[hidden] { display: none !important; }
|
||||
.oe-time-header { flex-shrink: 0; }
|
||||
.oe-time-body { padding: 18px; overflow: auto; min-height: 0; }
|
||||
.oe-time-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function createManageTime({ MODAL_ID, environment, tableSorting, searchableSelect, dates, requestWeek, requestSave, requestDelete, notify = () => {}, requestEntries = async () => [], requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
|
||||
function createManageTime({ allowsManageTime = () => true, MODAL_ID, environment, tableSorting, searchableSelect, dates, requestWeek, requestSave, requestDelete, notify = () => {}, requestEntries = async () => [], requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
|
||||
const { document } = environment;
|
||||
let active = null;
|
||||
let ready = false;
|
||||
@@ -19,8 +19,8 @@ function createManageTime({ MODAL_ID, environment, tableSorting, searchableSelec
|
||||
catch { /* Keep the selection for this page even if persistence is blocked. */ }
|
||||
}
|
||||
function setReady(value) {
|
||||
ready = !!value;
|
||||
if (launcher) { launcher.disabled = !ready; launcher.title = ready ? "Manage Time" : "Waiting for SR session ready"; }
|
||||
ready = !!value && allowsManageTime();
|
||||
if (launcher) { launcher.hidden = !allowsManageTime(); launcher.disabled = !ready; launcher.title = ready ? "Manage Time" : "Waiting for SR session ready"; }
|
||||
}
|
||||
function node(tag, className = "", text) {
|
||||
const el = document.createElement(tag);
|
||||
@@ -63,7 +63,7 @@ function createManageTime({ MODAL_ID, environment, tableSorting, searchableSelec
|
||||
}
|
||||
function createButton() {
|
||||
const trigger = button("Manage Time", "clock", () => open(trigger));
|
||||
trigger.className = "opportunities-extension-icon-button";
|
||||
trigger.className = "opportunities-extension-icon-button oe-time-launcher";
|
||||
launcher = trigger; setReady(ready);
|
||||
return trigger;
|
||||
}
|
||||
@@ -77,7 +77,7 @@ function createManageTime({ MODAL_ID, environment, tableSorting, searchableSelec
|
||||
}
|
||||
function open(trigger) {
|
||||
if (active?.saving) return;
|
||||
if (!ready) return;
|
||||
if (!ready || !allowsManageTime()) return;
|
||||
const host = document.getElementById(MODAL_ID);
|
||||
if (!host || host.hidden) return;
|
||||
close(false);
|
||||
|
||||
@@ -64,7 +64,10 @@ function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSum
|
||||
function toggle(kind, value) {
|
||||
const selected = selections[kind];
|
||||
if (!selected || !allowsCrmActivities()) return;
|
||||
if (selected.has(value)) selected.delete(value);
|
||||
if (selected.has("matched") && selected.has("missing")) {
|
||||
selected.clear();
|
||||
selected.add(value);
|
||||
} else if (selected.has(value)) selected.delete(value);
|
||||
else {
|
||||
if (value === "under4") selected.clear();
|
||||
else selected.delete("under4");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
|
||||
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
|
||||
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
|
||||
productTypeSelection, onProductTypeChange }) {
|
||||
productTypeSelection, onProductTypeChange, createPrintButton }) {
|
||||
const { document } = environment;
|
||||
let elements = null;
|
||||
let calendar = null;
|
||||
@@ -210,6 +210,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
viewbar.append(tablist);
|
||||
if (productTypeSelection) {
|
||||
const filters = node("div", "opportunities-product-type-filters");
|
||||
const filterButtons = new Map();
|
||||
filters.setAttribute("role", "group"); filters.setAttribute("aria-label", "Opportunity type");
|
||||
for (const type of ["Booking", "Workload"]) {
|
||||
const button = node("button", "opportunities-product-type-filter", type);
|
||||
@@ -217,12 +218,19 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
button.setAttribute("data-type", type);
|
||||
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
|
||||
button.addEventListener("click", () => {
|
||||
if (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type);
|
||||
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
|
||||
if (["Booking", "Workload"].every(value => productTypeSelection.has(value))) {
|
||||
productTypeSelection.clear();
|
||||
productTypeSelection.add(type);
|
||||
} else if (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type);
|
||||
for (const [value, filterButton] of filterButtons) {
|
||||
filterButton.setAttribute("aria-pressed", String(productTypeSelection.has(value)));
|
||||
}
|
||||
onProductTypeChange();
|
||||
});
|
||||
filterButtons.set(type, button);
|
||||
filters.append(button);
|
||||
}
|
||||
if (createPrintButton) filters.append(createPrintButton());
|
||||
viewbar.append(filters);
|
||||
}
|
||||
workspace.append(viewbar, tableSurface, surface); body.append(workspace);
|
||||
|
||||
@@ -13,6 +13,7 @@ function createOpportunitiesController({
|
||||
productsContentState,
|
||||
renderProductsProgress,
|
||||
requestOpportunitiesPage,
|
||||
loadServiceRequests = async () => {},
|
||||
resetCrmServiceRequests,
|
||||
resetWorkloadActions,
|
||||
resetWorkloads,
|
||||
@@ -95,6 +96,9 @@ function createOpportunitiesController({
|
||||
status: "ready",
|
||||
message: ""
|
||||
});
|
||||
// Start SR only after all opportunity pages have been displayed.
|
||||
// Its incremental updates run independently of the opportunity request.
|
||||
try { Promise.resolve(loadServiceRequests(token, requestVersion)).catch(() => {}); } catch { /* Keep the displayed opportunities available. */ }
|
||||
requestOpptyProductsInBackground(allItems, token);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, formatDate, environment }) {
|
||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, formatDate, environment, onStateChange = () => {} }) {
|
||||
let status = "idle";
|
||||
let byOpportunity = new Map();
|
||||
let pending = null;
|
||||
@@ -20,6 +20,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
async function run(token, version, currentGeneration, cacheOptions) {
|
||||
const isActive = () => currentGeneration === generation;
|
||||
status = "loading";
|
||||
onStateChange();
|
||||
try {
|
||||
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
|
||||
if (!isActive()) return false;
|
||||
@@ -27,12 +28,28 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
let items = cached?.items;
|
||||
if (!items) {
|
||||
reportProgress("running", 0);
|
||||
items = await requestCrmServiceRequest(token, version, isActive, (count, total) => {
|
||||
if (isActive()) reportProgress("running", count, total);
|
||||
items = await requestCrmServiceRequest(token, version, isActive, (count, total, partialItems) => {
|
||||
if (!isActive()) return;
|
||||
reportProgress("running", count, total);
|
||||
if (Array.isArray(partialItems)) {
|
||||
updateItems(partialItems);
|
||||
onStateChange();
|
||||
}
|
||||
});
|
||||
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
|
||||
}
|
||||
if (!items || !isActive()) return false;
|
||||
updateItems(items);
|
||||
status = "ready";
|
||||
if (!cached) reportProgress("complete", items.length, items.length);
|
||||
onStateChange();
|
||||
return true;
|
||||
} catch {
|
||||
if (isActive()) { status = "error"; reportProgress("error"); onStateChange(); }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function updateItems(items) {
|
||||
const grouped = new Map();
|
||||
for (const item of items) {
|
||||
const number = item?.OpportunityName_Id_c?.OptyNumber;
|
||||
@@ -43,13 +60,6 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
}
|
||||
byOpportunity = grouped;
|
||||
loadedItems = items;
|
||||
status = "ready";
|
||||
if (!cached) reportProgress("complete", items.length, items.length);
|
||||
return true;
|
||||
} catch {
|
||||
if (isActive()) { status = "error"; reportProgress("error"); }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function getSummary(optyNumber) {
|
||||
if (!allowsCrmActivities() || !optyNumber) return null;
|
||||
@@ -58,22 +68,23 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
(typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value))
|
||||
).map(Number);
|
||||
return {
|
||||
state: status === "ready" ? items.length ? "matched" : "missing" : status,
|
||||
state: status === "loading" && items.length ? "matched" : status === "ready" ? items.length ? "matched" : "missing" : status,
|
||||
reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null,
|
||||
hasUnderFourHours: status === "ready" && hours.some(value => value < 4)
|
||||
hasUnderFourHours: (status === "ready" || status === "loading") && hours.some(value => value < 4)
|
||||
};
|
||||
}
|
||||
function createBadge(optyNumber) {
|
||||
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null;
|
||||
const badgeStatus = status === "loading" && byOpportunity.get(String(optyNumber))?.length ? "ready" : status;
|
||||
if (!allowsCrmActivities() || !optyNumber || badgeStatus === "idle" || badgeStatus === "loading") return null;
|
||||
const items = byOpportunity.get(String(optyNumber)) || [];
|
||||
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
|
||||
const badge = environment.document.createElement(items.length && badgeStatus === "ready" ? "button" : "span");
|
||||
badge.className = "opportunities-extension-crm-sr-badge";
|
||||
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
|
||||
badge.setAttribute("data-state", badgeStatus === "error" ? "error" : items.length ? "matched" : "missing");
|
||||
const summary = getSummary(optyNumber);
|
||||
const reportedHours = summary.reportedHours !== null
|
||||
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
|
||||
: "N/A";
|
||||
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/A" ? "" : "h"}` : "0h");
|
||||
setBadgeContent(badge, "sr", badgeStatus === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/A" ? "" : "h"}` : "0h");
|
||||
const display = value => value === null || value === undefined || value === "" ? "N/A" : String(value);
|
||||
const descriptions = items.map(item => [
|
||||
`Title: ${display(item.Title)}`,
|
||||
@@ -84,13 +95,13 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
|
||||
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
|
||||
].join("\n"));
|
||||
const description = status === "error" ? "Unable to check CRM service requests. See Debug Requests."
|
||||
const description = badgeStatus === "error" ? "Unable to check CRM service requests. See Debug Requests."
|
||||
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
|
||||
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
|
||||
badge.setAttribute("aria-label", description);
|
||||
badge.title = "Whether a Service Request has been created for the opportunities.";
|
||||
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
|
||||
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber)));
|
||||
if (items.length && badgeStatus === "ready") bindPopover(badge, items, descriptions);
|
||||
if (!items.length && badgeStatus === "ready") badge.append(createSrButton(String(optyNumber)));
|
||||
badge.tabIndex = 0;
|
||||
return badge;
|
||||
}
|
||||
|
||||
@@ -124,9 +124,13 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
|
||||
children.setAttribute("role", "status");
|
||||
children.textContent = "Loading SR details...";
|
||||
section.append(children);
|
||||
detailLoads.push(async () => {
|
||||
const loadDetails = async () => {
|
||||
if (active !== entry || entry.panel !== panel) return;
|
||||
children.textContent = "Loading SR details...";
|
||||
children.setAttribute("aria-busy", "true");
|
||||
children.setAttribute("role", "status");
|
||||
try {
|
||||
const records = await requestDetails(item.SrId);
|
||||
const records = await requestDetails(item.SrId, item.SrNumber);
|
||||
if (active !== entry || entry.panel !== panel) return;
|
||||
children.textContent = "";
|
||||
if (!records.length) children.textContent = "No further details.";
|
||||
@@ -157,13 +161,20 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
|
||||
if (active !== entry || entry.panel !== panel) return;
|
||||
children.setAttribute("role", "alert");
|
||||
children.textContent = error.message || "Unable to load SR details.";
|
||||
const retry = document.createElement("button");
|
||||
retry.type = "button";
|
||||
retry.textContent = "Retry";
|
||||
retry.setAttribute("aria-label", "Retry loading Internal Service Requests");
|
||||
retry.addEventListener("click", () => { retry.disabled = true; loadDetails(); });
|
||||
children.append(retry);
|
||||
} finally {
|
||||
if (active === entry && entry.panel === panel) {
|
||||
children.setAttribute("aria-busy", "false");
|
||||
entry.position();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
detailLoads.push(loadDetails);
|
||||
panel.append(section);
|
||||
});
|
||||
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
|
||||
|
||||
@@ -64,7 +64,10 @@ function createOpportunitiesFilters({
|
||||
}
|
||||
|
||||
function toggleStageFilter(stage) {
|
||||
if (opportunitiesFilterState.selectedStages.has(stage)) {
|
||||
if (STAGE_OPTIONS.every(value => opportunitiesFilterState.selectedStages.has(value))) {
|
||||
opportunitiesFilterState.selectedStages.clear();
|
||||
opportunitiesFilterState.selectedStages.add(stage);
|
||||
} else if (opportunitiesFilterState.selectedStages.has(stage)) {
|
||||
opportunitiesFilterState.selectedStages.delete(stage);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedStages.add(stage);
|
||||
@@ -99,7 +102,10 @@ function createOpportunitiesFilters({
|
||||
}
|
||||
|
||||
function toggleStatusFilter(status) {
|
||||
if (opportunitiesFilterState.selectedStatuses.has(status)) {
|
||||
if (STATUS_OPTIONS.every(value => opportunitiesFilterState.selectedStatuses.has(value))) {
|
||||
opportunitiesFilterState.selectedStatuses.clear();
|
||||
opportunitiesFilterState.selectedStatuses.add(status);
|
||||
} else if (opportunitiesFilterState.selectedStatuses.has(status)) {
|
||||
opportunitiesFilterState.selectedStatuses.delete(status);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedStatuses.add(status);
|
||||
@@ -196,21 +202,35 @@ function createOpportunitiesFilters({
|
||||
}
|
||||
}
|
||||
|
||||
// Keep stored selections intact: narrowing a menu must not turn an implicit
|
||||
// "all" into a second filter that prevents expanding the original selection.
|
||||
function getAvailableCustomerOptions() {
|
||||
const items = opportunitiesTableState.opportunitiesTableState.items;
|
||||
const allOwners = getOwnerOptions().every(option => opportunitiesFilterState.selectedOwnerKeys.has(option.key));
|
||||
return getCustomerOptions(allOwners ? items : items.filter(item => opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))));
|
||||
}
|
||||
|
||||
function getAvailableOwnerOptions() {
|
||||
const items = opportunitiesTableState.opportunitiesTableState.items;
|
||||
const allCustomers = getCustomerOptions().every(option => opportunitiesFilterState.selectedCustomerKeys.has(option.key));
|
||||
return getOwnerOptions(allCustomers ? items : items.filter(item => opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))));
|
||||
}
|
||||
|
||||
function renderCustomerFilter() {
|
||||
const trigger = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
|
||||
const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID);
|
||||
const selectAllCheckbox = document.getElementById(CUSTOMER_FILTER_SELECT_ALL_ID);
|
||||
const customerOptions = getCustomerOptions();
|
||||
const customerOptions = getAvailableCustomerOptions();
|
||||
|
||||
if (!trigger || !searchInput || !selectAllCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCount = customerOptions.filter((customer) => opportunitiesFilterState.selectedCustomerKeys.has(customer.key)).length;
|
||||
trigger.disabled = customerOptions.length === 0;
|
||||
trigger.disabled = getCustomerOptions().length === 0;
|
||||
trigger.textContent = getCustomerFilterSummary(customerOptions.length, selectedCount);
|
||||
searchInput.value = opportunitiesFilterState.customerSearch;
|
||||
selectAllCheckbox.disabled = customerOptions.length === 0;
|
||||
selectAllCheckbox.disabled = getCustomerOptions().length === 0;
|
||||
selectAllCheckbox.checked = customerOptions.length > 0 && selectedCount === customerOptions.length;
|
||||
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < customerOptions.length;
|
||||
renderCustomerFilterList();
|
||||
@@ -224,7 +244,7 @@ function createOpportunitiesFilters({
|
||||
}
|
||||
|
||||
const normalizedSearch = opportunitiesFilterState.customerSearch.trim().toLocaleLowerCase();
|
||||
const customerOptions = getCustomerOptions().filter((customer) => {
|
||||
const customerOptions = getAvailableCustomerOptions().filter((customer) => {
|
||||
return customer.label.toLocaleLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
|
||||
@@ -246,7 +266,10 @@ function createOpportunitiesFilters({
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key);
|
||||
checkbox.addEventListener("change", () => {
|
||||
if (checkbox.checked) {
|
||||
if (getAvailableCustomerOptions().every(option => opportunitiesFilterState.selectedCustomerKeys.has(option.key))) {
|
||||
opportunitiesFilterState.selectedCustomerKeys.clear();
|
||||
opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
|
||||
} else if (checkbox.checked) {
|
||||
opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedCustomerKeys.delete(customer.key);
|
||||
@@ -255,6 +278,7 @@ function createOpportunitiesFilters({
|
||||
opportunitiesFilterState.hasSavedCustomerFilter = true;
|
||||
saveFilterPreferences();
|
||||
renderCustomerFilter();
|
||||
renderOwnerFilter();
|
||||
renderOpportunitiesTable();
|
||||
});
|
||||
|
||||
@@ -269,18 +293,18 @@ function createOpportunitiesFilters({
|
||||
const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID);
|
||||
const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID);
|
||||
const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID);
|
||||
const ownerOptions = getOwnerOptions();
|
||||
const ownerOptions = getAvailableOwnerOptions();
|
||||
|
||||
if (!trigger || !searchInput || !selectAllCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCount = ownerOptions.filter((owner) => opportunitiesFilterState.selectedOwnerKeys.has(owner.key)).length;
|
||||
trigger.disabled = ownerOptions.length === 0;
|
||||
trigger.disabled = getOwnerOptions().length === 0;
|
||||
trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount)
|
||||
.replace("customer", "owner");
|
||||
searchInput.value = opportunitiesFilterState.ownerSearch;
|
||||
selectAllCheckbox.disabled = ownerOptions.length === 0;
|
||||
selectAllCheckbox.disabled = getOwnerOptions().length === 0;
|
||||
selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length;
|
||||
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length;
|
||||
renderOwnerFilterList();
|
||||
@@ -294,7 +318,7 @@ function createOpportunitiesFilters({
|
||||
}
|
||||
|
||||
const normalizedSearch = opportunitiesFilterState.ownerSearch.trim().toLocaleLowerCase();
|
||||
const ownerOptions = getOwnerOptions().filter((owner) => {
|
||||
const ownerOptions = getAvailableOwnerOptions().filter((owner) => {
|
||||
return owner.label.toLocaleLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
|
||||
@@ -316,7 +340,10 @@ function createOpportunitiesFilters({
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key);
|
||||
checkbox.addEventListener("change", () => {
|
||||
if (checkbox.checked) {
|
||||
if (getAvailableOwnerOptions().every(option => opportunitiesFilterState.selectedOwnerKeys.has(option.key))) {
|
||||
opportunitiesFilterState.selectedOwnerKeys.clear();
|
||||
opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
|
||||
} else if (checkbox.checked) {
|
||||
opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedOwnerKeys.delete(owner.key);
|
||||
@@ -325,6 +352,7 @@ function createOpportunitiesFilters({
|
||||
opportunitiesFilterState.hasSavedOwnerFilter = true;
|
||||
saveFilterPreferences();
|
||||
renderOwnerFilter();
|
||||
renderCustomerFilter();
|
||||
renderOpportunitiesTable();
|
||||
});
|
||||
|
||||
@@ -406,5 +434,5 @@ function createOpportunitiesFilters({
|
||||
return opportunitiesFilterState.selectedStatuses.has(statusCode);
|
||||
}
|
||||
|
||||
return { resetCustomerAndOwnerFiltersForPeriod, renderStageFilterButtons, toggleStageFilter, renderStatusFilterButtons, toggleStatusFilter, toggleCustomerFilterPanel, closeCustomerFilterPanel, toggleOwnerFilterPanel, closeOwnerFilterPanel, resetCustomerFilter, resetOwnerFilter, renderCustomerFilter, renderCustomerFilterList, renderOwnerFilter, renderOwnerFilterList, getSortedOpportunities, matchesTableSearch, matchesSelectedStatus };
|
||||
return { getAvailableCustomerOptions, getAvailableOwnerOptions, resetCustomerAndOwnerFiltersForPeriod, renderStageFilterButtons, toggleStageFilter, renderStatusFilterButtons, toggleStatusFilter, toggleCustomerFilterPanel, closeCustomerFilterPanel, toggleOwnerFilterPanel, closeOwnerFilterPanel, resetCustomerFilter, resetOwnerFilter, renderCustomerFilter, renderCustomerFilterList, renderOwnerFilter, renderOwnerFilterList, getSortedOpportunities, matchesTableSearch, matchesSelectedStatus };
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
|
||||
const { document, setTimeout } = environment;
|
||||
const pending = new Map();
|
||||
let recovery = null;
|
||||
const wait = duration => new Promise(resolve => setTimeout(resolve, duration));
|
||||
const now = () => environment.performance?.now() ?? Date.now();
|
||||
function isUnauthorized(result) {
|
||||
const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus;
|
||||
if (status != null) return Number(status) === 401;
|
||||
// Older background workers may return only the request error text.
|
||||
return /failed with status 401\.$/.test(result?.error || "");
|
||||
return status != null ? Number(status) === 401 : /failed with status 401\.$/.test(result?.error || "");
|
||||
}
|
||||
async function warmSession(id) {
|
||||
async function warmSession(id, srNumber) {
|
||||
const document = environment?.document;
|
||||
if (!document?.body) throw new Error(`${sessionLabel} session cannot be initialized on this page.`);
|
||||
const frame = document.createElement("iframe");
|
||||
// Restore the original session initialization route without opening a tab.
|
||||
frame.src = sessionUrl || "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr";
|
||||
frame.title = `${sessionLabel} session initialization`;
|
||||
frame.tabIndex = -1;
|
||||
@@ -18,43 +17,46 @@ function createInternalSrSession({ query, environment, sessionUrl, sessionLabel
|
||||
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
||||
frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;";
|
||||
let blocked = false;
|
||||
let loaded = false;
|
||||
frame.addEventListener("error", () => { blocked = true; });
|
||||
frame.addEventListener("load", () => { loaded = true; });
|
||||
const failed = () => { blocked = true; };
|
||||
frame.addEventListener("error", failed);
|
||||
try {
|
||||
if (!document.body) throw new Error(`The current page cannot initialize the ${sessionLabel} session.`);
|
||||
document.body.append(frame);
|
||||
const deadline = now() + 60000;
|
||||
for (let attempt = 0; now() < deadline; attempt += 1) {
|
||||
// Check immediately, then briefly poll faster before backing off.
|
||||
if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now()));
|
||||
if (now() >= deadline) break;
|
||||
if (blocked) throw new Error(`The ${sessionLabel} session iframe could not be loaded. Sign in manually and try again.`);
|
||||
const result = await query(id);
|
||||
if (result?.ok) return { id, result };
|
||||
// Keep the iframe alive while its application completes authentication.
|
||||
// A load event is not proof of a ready session. Limit and space API checks.
|
||||
for (const delay of [2000, 4000, 8000, 12000, 16000]) {
|
||||
await new Promise(resolve => environment.setTimeout(resolve, delay));
|
||||
if (blocked) throw new Error(`${sessionLabel} session iframe could not be loaded. Retry after signing in.`);
|
||||
const result = await query(id, srNumber);
|
||||
if (result?.ok) return { id: String(id), result };
|
||||
if (!isUnauthorized(result)) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
||||
}
|
||||
throw new Error(`${sessionLabel} session timed out after 60 seconds: the API still returned HTTP 401. ${loaded ? "The iframe emitted a load event, but authentication was not confirmed." : "The iframe did not emit a load event."} Sign in manually and try again.`);
|
||||
throw new Error(`${sessionLabel} session is still unavailable. Retry loading the details after signing in.`);
|
||||
} finally {
|
||||
// Stop remaining page resources without stopping the caller's page.
|
||||
try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ }
|
||||
frame.src = "about:blank";
|
||||
frame.removeEventListener("error", failed);
|
||||
try { frame.contentWindow?.stop(); } catch { /* SSO may redirect cross-origin. */ }
|
||||
frame.remove();
|
||||
}
|
||||
}
|
||||
async function request(id) {
|
||||
let result = await query(id);
|
||||
for (let attempt = 0; !result?.ok && isUnauthorized(result) && attempt < 2; attempt += 1) {
|
||||
function request(id, srNumber) {
|
||||
const key = String(id);
|
||||
if (!pending.has(key)) {
|
||||
const operation = (async () => {
|
||||
let result = await query(id, srNumber);
|
||||
if (!result?.ok && isUnauthorized(result)) {
|
||||
if (!recovery) {
|
||||
recovery = warmSession(id);
|
||||
const pending = recovery;
|
||||
pending.finally(() => { if (recovery === pending) recovery = null; }).catch(() => {});
|
||||
const current = warmSession(id, srNumber);
|
||||
recovery = current;
|
||||
current.finally(() => { if (recovery === current) recovery = null; }).catch(() => {});
|
||||
}
|
||||
const recovered = await recovery;
|
||||
result = recovered.id === id ? recovered.result : await query(id);
|
||||
result = recovered.id === key ? recovered.result : await query(id, srNumber);
|
||||
}
|
||||
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
||||
return result.items;
|
||||
})().finally(() => pending.delete(key));
|
||||
pending.set(key, operation);
|
||||
}
|
||||
return pending.get(key);
|
||||
}
|
||||
return { request };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,140 @@
|
||||
function createOpportunitiesLinks({
|
||||
MODAL_ID,
|
||||
searchableSelect,
|
||||
environment
|
||||
}) {
|
||||
const { document, navigator, window } = environment;
|
||||
function chooseBookmarkFolder(loadFolders, saveBookmark) {
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
if (!modal || modal.querySelector(".oe-bookmark-picker")) return;
|
||||
const previous = document.activeElement;
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "opportunities-extension-action-form-modal oe-bookmark-picker";
|
||||
const panel = document.createElement("section");
|
||||
panel.className = "opportunities-extension-action-form-dialog";
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute("aria-modal", "true");
|
||||
panel.setAttribute("aria-labelledby", "oe-bookmark-title");
|
||||
const header = document.createElement("header");
|
||||
header.className = "opportunities-extension-account-plans-header";
|
||||
const title = document.createElement("h2");
|
||||
title.id = "oe-bookmark-title";
|
||||
title.textContent = "Add to favorites";
|
||||
const dismiss = document.createElement("button");
|
||||
dismiss.type = "button";
|
||||
dismiss.className = "opportunities-extension-icon-button";
|
||||
dismiss.setAttribute("aria-label", "Close favorites form");
|
||||
const closeIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
closeIcon.setAttribute("viewBox", "0 0 24 24");
|
||||
closeIcon.setAttribute("aria-hidden", "true");
|
||||
const closePath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
closePath.setAttribute("d", "M6 6l12 12M18 6 6 18");
|
||||
closePath.setAttribute("stroke", "currentColor");
|
||||
closePath.setAttribute("stroke-width", "1.75");
|
||||
closeIcon.append(closePath);
|
||||
dismiss.append(closeIcon);
|
||||
header.append(title, dismiss);
|
||||
const body = document.createElement("div");
|
||||
body.className = "opportunities-extension-action-form";
|
||||
const label = document.createElement("label");
|
||||
label.className = "opportunities-extension-action-form-field opportunities-extension-action-form-wide";
|
||||
label.textContent = "Folder (required)";
|
||||
const select = document.createElement("select");
|
||||
select.id = "oe-bookmark-folder";
|
||||
select.required = true;
|
||||
select.setAttribute("aria-describedby", "oe-bookmark-status");
|
||||
select.disabled = true;
|
||||
label.append(select);
|
||||
const status = document.createElement("p");
|
||||
status.id = "oe-bookmark-status";
|
||||
status.className = "opportunities-extension-action-form-status opportunities-extension-action-form-wide";
|
||||
status.setAttribute("role", "status");
|
||||
status.textContent = "Loading bookmarks folders…";
|
||||
const actions = document.createElement("footer");
|
||||
actions.className = "opportunities-extension-action-form-footer";
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.textContent = "Cancel";
|
||||
const save = document.createElement("button");
|
||||
save.type = "button";
|
||||
save.className = "opportunities-extension-action-form-save";
|
||||
save.textContent = "Save";
|
||||
save.disabled = true;
|
||||
const autocomplete = searchableSelect(select, label, () => { save.disabled = !select.value; }, { floating: true });
|
||||
autocomplete.input.setAttribute("aria-describedby", "oe-bookmark-status");
|
||||
let saving = false;
|
||||
const siblings = Array.from(modal.children).map(node => [node, node.inert]);
|
||||
siblings.forEach(([node]) => { node.inert = true; });
|
||||
function close() {
|
||||
if (saving) return;
|
||||
autocomplete.destroy();
|
||||
overlay.remove();
|
||||
siblings.forEach(([node, inert]) => { node.inert = inert; });
|
||||
previous?.focus();
|
||||
}
|
||||
cancel.addEventListener("click", close);
|
||||
dismiss.addEventListener("click", close);
|
||||
save.addEventListener("click", async () => {
|
||||
if (!select.value || saving) return;
|
||||
saving = true;
|
||||
save.disabled = cancel.disabled = select.disabled = dismiss.disabled = true;
|
||||
autocomplete.sync();
|
||||
status.setAttribute("role", "status");
|
||||
status.textContent = "Saving favorite…";
|
||||
try {
|
||||
const result = await saveBookmark(select.value);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to add favorite.");
|
||||
saving = false;
|
||||
close();
|
||||
showCopyConfirmation(result.existing ? "This shortcut is already in the selected folder." : "Opportunities Extension added to favorites.");
|
||||
} catch (error) {
|
||||
status.setAttribute("role", "alert");
|
||||
status.textContent = error.message;
|
||||
saving = false;
|
||||
save.disabled = cancel.disabled = select.disabled = dismiss.disabled = false;
|
||||
autocomplete.sync();
|
||||
}
|
||||
});
|
||||
overlay.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
||||
if (event.key === "Tab") {
|
||||
const controls = [dismiss, autocomplete.input, cancel, save].filter(control => !control.disabled);
|
||||
event.preventDefault();
|
||||
const index = controls.indexOf(document.activeElement);
|
||||
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length]?.focus();
|
||||
}
|
||||
});
|
||||
actions.append(cancel, save);
|
||||
body.append(label, status, actions);
|
||||
panel.append(header, body);
|
||||
overlay.append(panel);
|
||||
modal.append(overlay);
|
||||
cancel.focus();
|
||||
Promise.resolve().then(loadFolders).then(result => {
|
||||
if (!overlay.isConnected) return;
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load bookmarks folders.");
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = "Select a folder";
|
||||
select.append(placeholder);
|
||||
for (const folder of result.folders) {
|
||||
const option = document.createElement("option");
|
||||
option.value = folder.id;
|
||||
option.textContent = folder.label;
|
||||
select.append(option);
|
||||
}
|
||||
select.disabled = !result.folders.length;
|
||||
status.textContent = result.folders.length ? "Choose where to save Opportunities Extension." : "No writable bookmarks folders available.";
|
||||
select.addEventListener("change", () => { save.disabled = !select.value; });
|
||||
autocomplete.sync();
|
||||
if (!select.disabled) autocomplete.input.focus();
|
||||
}).catch(error => {
|
||||
if (overlay.isConnected) {
|
||||
status.setAttribute("role", "alert");
|
||||
status.textContent = error.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
function createDetailLink(label, href) {
|
||||
if (label === null || label === undefined || label === "") {
|
||||
const placeholder = document.createElement("span");
|
||||
@@ -114,5 +246,5 @@ function createOpportunitiesLinks({
|
||||
return badge;
|
||||
}
|
||||
|
||||
return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
|
||||
return { chooseBookmarkFolder, createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
|
||||
}
|
||||
|
||||
100
src/features/opportunities/print.js
Normal file
100
src/features/opportunities/print.js
Normal file
@@ -0,0 +1,100 @@
|
||||
function createOpportunitiesPrint({ environment, getSnapshot }) {
|
||||
function createButton() {
|
||||
const doc = environment.document;
|
||||
const button = doc.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-icon-button";
|
||||
button.title = "Print opportunities";
|
||||
button.setAttribute("aria-label", "Print opportunities");
|
||||
const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("stroke-width", "1.6");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
const path = doc.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", "M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6zM17 11h1");
|
||||
svg.append(path); button.append(svg);
|
||||
button.addEventListener("click", print);
|
||||
return button;
|
||||
}
|
||||
function print() {
|
||||
const snapshot = getSnapshot();
|
||||
if (!["ready", "empty"].includes(snapshot.status)) {
|
||||
environment.window.alert("Wait for opportunities to finish loading before printing.");
|
||||
return;
|
||||
}
|
||||
const popup = environment.window.open("", "_blank", "width=1200,height=850");
|
||||
if (!popup) { environment.window.alert("Allow pop-ups to print opportunities."); return; }
|
||||
popup.opener = null;
|
||||
const doc = popup.document;
|
||||
doc.title = "Opportunities Extension — Report";
|
||||
doc.documentElement.lang = "en";
|
||||
const node = (tag, text, parent) => {
|
||||
const el = doc.createElement(tag);
|
||||
if (text != null) el.textContent = String(text);
|
||||
if (parent) parent.append(el);
|
||||
return el;
|
||||
};
|
||||
node("style", `
|
||||
@page { size: A4 landscape; margin: 15mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 32px; font: 11px/1.4 Arial, sans-serif; color: #222; background: white; }
|
||||
h1 { margin: 0 0 4px; font-size: 21px; } h2 { font-size: 13px; margin: 16px 0 6px; }
|
||||
.meta { color: #555; margin: 0 0 12px; }
|
||||
.filters { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px 16px; border: 1px solid #ccc; padding: 10px; }
|
||||
.filters div { overflow-wrap: anywhere; } .filters strong { margin-right: 5px; }
|
||||
.totals { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 12px 0; }
|
||||
.totals section { border: 1px solid #bbb; border-top: 3px solid #426a75; padding: 8px; break-inside: avoid; }
|
||||
.totals strong { display: block; font-size: 16px; } .totals small { display: block; }
|
||||
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 9px; }
|
||||
th, td { padding: 6px 4px; border-bottom: 1px solid #ccc; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
|
||||
th { background: #eee; font-weight: bold; } tbody tr:nth-child(even) { background: #f7f7f7; }
|
||||
thead { display: table-header-group; } tr { break-inside: avoid; }
|
||||
th:nth-child(1), th:nth-child(3) { width: 22%; }
|
||||
th:nth-child(2) { width: 7%; } th:nth-child(4) { width: 8%; }
|
||||
th:nth-child(5) { width: 9%; } th:nth-child(6) { width: 7%; }
|
||||
th:nth-child(7), th:nth-child(8) { width: 5%; } th:nth-child(9) { width: 7%; } th:nth-child(10) { width: 8%; }
|
||||
.money { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
button { padding: 8px 14px; cursor: pointer; }
|
||||
@media print { body { margin: 0; } .toolbar { display: none; } th { print-color-adjust: exact; } }
|
||||
`, doc.head);
|
||||
const toolbar = node("div", null, doc.body); toolbar.className = "toolbar";
|
||||
node("button", "Print / Save PDF", toolbar).addEventListener("click", () => popup.print());
|
||||
node("button", "Close", toolbar).addEventListener("click", () => popup.close());
|
||||
node("h1", "Opportunities Extension", doc.body);
|
||||
node("p", `Generated ${new Date().toLocaleString("en-US")} • ${snapshot.rows.length} opportunities`, doc.body).className = "meta";
|
||||
const filters = node("div", null, doc.body); filters.className = "filters";
|
||||
for (const [label, value] of snapshot.filters) {
|
||||
const field = node("div", null, filters);
|
||||
node("strong", `${label}:`, field); node("span", value || "None", field);
|
||||
}
|
||||
const totals = node("div", null, doc.body); totals.className = "totals";
|
||||
for (const card of snapshot.totals) {
|
||||
const section = node("section", null, totals);
|
||||
node("span", card.label, section); node("strong", card.value, section);
|
||||
if (card.detail) node("small", card.detail, section);
|
||||
}
|
||||
node("h2", "Opportunities", doc.body);
|
||||
const table = node("table", null, doc.body);
|
||||
const header = node("tr", null, node("thead", null, table));
|
||||
for (const column of snapshot.columns) {
|
||||
const th = node("th", column.label, header); th.scope = "col";
|
||||
if (column.key === "revenue") th.className = "money";
|
||||
}
|
||||
const body = node("tbody", null, table);
|
||||
for (const values of snapshot.rows) {
|
||||
const row = node("tr", null, body);
|
||||
values.forEach((value, index) => {
|
||||
const td = node("td", value, row);
|
||||
if (snapshot.columns[index].key === "revenue") td.className = "money";
|
||||
});
|
||||
}
|
||||
if (!snapshot.rows.length) node("p", "No opportunities match the selected filters.", doc.body);
|
||||
popup.focus();
|
||||
// Print a frozen snapshot, without changing the extension's scroll or selected view.
|
||||
environment.setTimeout(() => { if (!popup.closed) popup.print(); }, 150);
|
||||
}
|
||||
return { createButton, print };
|
||||
}
|
||||
@@ -111,10 +111,14 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = selectedOrders.has(value);
|
||||
checkbox.addEventListener("change", () => {
|
||||
if (checkbox.checked) selectedOrders.add(value); else selectedOrders.delete(value);
|
||||
if (orders.every(order => selectedOrders.has(order))) {
|
||||
selectedOrders.clear();
|
||||
selectedOrders.add(value);
|
||||
} else if (checkbox.checked) selectedOrders.add(value); else selectedOrders.delete(value);
|
||||
allOrders.checked = selectedOrders.size === orders.length;
|
||||
allOrders.indeterminate = selectedOrders.size > 0 && !allOrders.checked;
|
||||
orderButton.textContent = allOrders.checked ? "All order numbers" : `${selectedOrders.size} order numbers selected`;
|
||||
updateOrders();
|
||||
renderRows();
|
||||
});
|
||||
label.append(checkbox, element("span", null, value || "Not provided"));
|
||||
|
||||
@@ -2,10 +2,27 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
||||
logRequestStart, logRequestSuccess, logRequestFailure, readResponseBodyForDebug, maskToken, environment }) {
|
||||
const { fetch } = environment;
|
||||
const url = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries";
|
||||
function createPayload(offset) {
|
||||
async function requestSRSearchResources(token) {
|
||||
const searchUrl = `${url}?applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=CRMServiceRequest&language=en&limit=-1&onlyData=true`;
|
||||
const headers = { Accept: "application/json", Authorization: `Bearer ${token}` };
|
||||
const entry = logRequestStart({ name: "requestSRSearchResources", method: "GET", url: searchUrl, headers });
|
||||
try {
|
||||
const response = await fetch(searchUrl, { method: "GET", credentials: "include", headers });
|
||||
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
||||
if (response.status !== 200) throw new Error(`requestSRSearchResources failed with status ${response.status}.`);
|
||||
const data = await response.json();
|
||||
const number = data?.items?.[0]?.savedSearchNumber;
|
||||
if (!Array.isArray(data?.items) || typeof number !== "string" || !number.trim()) {
|
||||
throw new Error("requestSRSearchResources did not return savedSearchNumber in its first item.");
|
||||
}
|
||||
return number.trim();
|
||||
} catch (error) { logRequestFailure(entry, error.message); throw error; }
|
||||
}
|
||||
function createPayload(offset, savedSearchNumber) {
|
||||
if (typeof savedSearchNumber !== "string" || !savedSearchNumber.trim()) throw new Error("Missing SR savedSearchNumber.");
|
||||
return {
|
||||
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true,
|
||||
entity: "CRMServiceRequest", copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205",
|
||||
entity: "CRMServiceRequest", copiedFrom: `queries/${savedSearchNumber.trim()}`,
|
||||
q: { op: "$and", criteria: [
|
||||
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
|
||||
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
|
||||
@@ -19,11 +36,13 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
||||
};
|
||||
}
|
||||
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) {
|
||||
if (!isActive()) return null;
|
||||
const savedSearchNumber = await requestSRSearchResources(token);
|
||||
const items = [];
|
||||
let offset = 0;
|
||||
let attempt = 1;
|
||||
while (isActive()) {
|
||||
const payload = createPayload(offset);
|
||||
const payload = createPayload(offset, savedSearchNumber);
|
||||
const headers = {
|
||||
Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`,
|
||||
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
|
||||
@@ -48,7 +67,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
||||
throw new Error("CRM service request pagination did not advance.");
|
||||
}
|
||||
items.push(...data.items);
|
||||
onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null);
|
||||
onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null, items.slice());
|
||||
if (!data.hasMore) return items;
|
||||
offset = current + received;
|
||||
attempt = 1;
|
||||
@@ -60,5 +79,5 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return { createPayload, requestCrmServiceRequest };
|
||||
return { createPayload, requestCrmServiceRequest, requestSRSearchResources };
|
||||
}
|
||||
|
||||
@@ -37,7 +37,10 @@ function createFusionOpportunities({
|
||||
const payload = createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber);
|
||||
const requestBody = JSON.stringify(payload);
|
||||
// One history per query, preserving all pages of the same result set.
|
||||
if (page === 1) resetDebugRequests();
|
||||
if (page === 1) {
|
||||
resetDebugRequests();
|
||||
if (!isCurrentPeriodRequest(requestVersion)) return null;
|
||||
}
|
||||
const requestEntry = logRequestStart({
|
||||
name: "requestOpportunities",
|
||||
method: "POST",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||
function createMessageTypes() {
|
||||
return Object.freeze({
|
||||
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
|
||||
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
|
||||
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
|
||||
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
|
||||
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
|
||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||
|
||||
@@ -61,11 +61,13 @@ test("activity badge filters precede Status, are accessible and combine without
|
||||
h.sr.set("B", { state: "missing" });
|
||||
h.actions.set("B", { state: "matched" });
|
||||
assert.equal(h.matches("unknown"), true);
|
||||
h.click("sr", "missing");
|
||||
h.click("sr", "matched");
|
||||
assert.equal(h.button("sr", "matched").attributes["aria-pressed"], "true");
|
||||
assert.equal(h.button("sr", "missing").attributes["aria-pressed"], "false");
|
||||
assert.equal(h.matches("A"), true);
|
||||
assert.equal(h.matches("B"), false);
|
||||
assert.equal(h.matches("unknown"), false);
|
||||
h.click("actions", "missing");
|
||||
h.click("actions", "matched");
|
||||
assert.equal(h.matches("A"), false, "SR and Action use AND");
|
||||
h.actions.set("A", { state: "matched" });
|
||||
assert.equal(h.matches("A"), true);
|
||||
@@ -119,8 +121,8 @@ test("filter vectors retain their fixed slot without duplication when selection
|
||||
|
||||
test("missing filters exclude unknown, loading and error states; SALES_REP bypasses and hides them", () => {
|
||||
const h = setup();
|
||||
h.click("sr", "matched");
|
||||
h.click("actions", "matched");
|
||||
h.click("sr", "missing");
|
||||
h.click("actions", "missing");
|
||||
for (const state of ["idle", "loading", "error", "matched"]) {
|
||||
h.sr.set("A", { state });
|
||||
h.actions.set("A", { state: "missing" });
|
||||
@@ -215,7 +217,7 @@ test("activity filters compose with existing search, status, owner and customer
|
||||
h.app.opportunitiesFilters.resetOwnerFilter(items);
|
||||
h.sr.set("A", { state: "matched" });
|
||||
h.sr.set("B", { state: "missing" });
|
||||
h.click("sr", "missing");
|
||||
h.click("sr", "matched");
|
||||
const results = () => Array.from(h.app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber);
|
||||
assert.deepEqual(results(), ["A"]);
|
||||
h.app.opportunitiesFilterState.tableSearch = "Test B";
|
||||
|
||||
@@ -4,7 +4,8 @@ const { loadApplication } = require("./support/application.cjs");
|
||||
const { response } = require("./support/characterization.cjs");
|
||||
|
||||
function setup(fetch) {
|
||||
const app = loadApplication("content", { fetch });
|
||||
const app = loadApplication("content", { fetch: (url, options) => options.method === "GET"
|
||||
? response({ items: [{ savedSearchNumber: "dynamic-sr-search" }] }) : fetch(url, options) });
|
||||
app.currentUser.allowsCrmActivities = () => true;
|
||||
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||
app.debugContentView.renderDebugPanel = () => {};
|
||||
|
||||
@@ -14,7 +14,7 @@ const json = value => JSON.parse(JSON.stringify(value));
|
||||
test("every source belongs to an explicit runtime composition, with no duplicate factories", () => {
|
||||
const actual = fs.readdirSync(path.join(root, "src"), { recursive: true })
|
||||
.filter(file => file.endsWith(".js")).map(file => `src/${file.replaceAll(path.sep, "/")}`).sort();
|
||||
assert.deepEqual([...new Set([...Object.values(sources).flat(), ...Object.values(vendorSources).flat()])].sort(), actual);
|
||||
assert.deepEqual([...new Set(["src/content/shortcut-intent.js", ...Object.values(sources).flat(), ...Object.values(vendorSources).flat()])].sort(), actual);
|
||||
for (const files of Object.values(vendorSources)) {
|
||||
for (const file of files) {
|
||||
assert.doesNotThrow(() => new vm.Script(read(file)));
|
||||
|
||||
47
tests/bookmarkHint.test.cjs
Normal file
47
tests/bookmarkHint.test.cjs
Normal file
@@ -0,0 +1,47 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const factory = vm.runInNewContext(fs.readFileSync('src/content/modal.js', 'utf8') + '\ncreateContentModal');
|
||||
function setup(check) {
|
||||
const hint = { hidden: true }, modal = { hidden: false }, timers = new Map();
|
||||
let next = 0;
|
||||
const app = factory({ MODAL_ID: 'modal', getShortcutBookmarkStatus: check,
|
||||
environment: { window: {}, document: { getElementById: id => id === 'modal' ? modal : hint },
|
||||
setTimeout(fn, delay) { const id = ++next; timers.set(id, { fn, delay }); return id; },
|
||||
clearTimeout(id) { timers.delete(id); }
|
||||
}
|
||||
});
|
||||
return { app, hint, modal, timers };
|
||||
}
|
||||
test('unbookmarked shortcut shows for 20 seconds and can show on reopening', async () => {
|
||||
const h = setup(async () => ({ ok: true, bookmarked: false }));
|
||||
await h.app.refreshBookmarkHint();
|
||||
assert.equal(h.hint.hidden, false);
|
||||
const timer = [...h.timers.values()][0];
|
||||
assert.equal(timer.delay, 20000);
|
||||
timer.fn();
|
||||
assert.equal(h.hint.hidden, true);
|
||||
await h.app.refreshBookmarkHint();
|
||||
assert.equal(h.hint.hidden, false);
|
||||
h.app.hideBookmarkHint();
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
test('existing bookmark and failed checks never display the hint', async () => {
|
||||
for (const check of [async () => ({ ok: true, bookmarked: true }), async () => ({ ok: false }), async () => { throw Error('unavailable'); }]) {
|
||||
const h = setup(check);
|
||||
await h.app.refreshBookmarkHint();
|
||||
assert.equal(h.hint.hidden, true);
|
||||
assert.equal(h.timers.size, 0);
|
||||
}
|
||||
});
|
||||
test('closing or saving while the check is pending prevents late hints', async () => {
|
||||
let resolve;
|
||||
const h = setup(() => new Promise(done => { resolve = done; }));
|
||||
const pending = h.app.refreshBookmarkHint();
|
||||
h.app.hideBookmarkHint();
|
||||
resolve({ ok: true, bookmarked: false });
|
||||
await pending;
|
||||
assert.equal(h.hint.hidden, true);
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
64
tests/bookmarkShortcut.test.cjs
Normal file
64
tests/bookmarkShortcut.test.cjs
Normal file
@@ -0,0 +1,64 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function harness(bookmarks) {
|
||||
let listener;
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(fs.readFileSync('src/background/messages.js', 'utf8'), context);
|
||||
context.registerBackgroundMessages({
|
||||
messageTypes: { addShortcutBookmark: 'bookmark', getBookmarkFolders: 'folders' },
|
||||
runtimeApi: { bookmarks: { getTree: async () => [{ id: "0", children: [{ id: "1", parentId: "0", title: "Favorites" }] }], ...bookmarks }, runtime: { onMessage: { addListener(fn) { listener = fn; } } } }
|
||||
});
|
||||
return (message = { type: "bookmark", parentId: "1" }) => new Promise(resolve => {
|
||||
assert.equal(listener(message, {}, resolve), true);
|
||||
});
|
||||
}
|
||||
|
||||
test('creates only the fixed extension shortcut, deduplicating concurrent requests', async () => {
|
||||
const created = [];
|
||||
const request = harness({ search: async () => [], create: async value => { created.push(value); } });
|
||||
const results = await Promise.all([request(), request()]);
|
||||
assert.equal(created.length, 1);
|
||||
assert.equal(created[0].parentId, '1');
|
||||
assert.equal(created[0].title, 'Opportunities Extension');
|
||||
assert.equal(created[0].url, 'https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension');
|
||||
assert.ok(results.every(result => result.ok));
|
||||
});
|
||||
|
||||
test('does not duplicate an existing bookmark', async () => {
|
||||
const request = harness({ search: async () => [{ id: '2', parentId: '1' }], create: async () => assert.fail('duplicate') });
|
||||
assert.equal((await request()).existing, true);
|
||||
});
|
||||
|
||||
test('reports API errors and allows retry', async () => {
|
||||
let calls = 0;
|
||||
const request = harness({ search: async () => {
|
||||
if (++calls === 1) throw new Error('Permission denied');
|
||||
return [{ id: '2', parentId: '1' }];
|
||||
} });
|
||||
assert.equal((await request()).error, 'Permission denied');
|
||||
assert.equal((await request()).ok, true);
|
||||
});
|
||||
|
||||
|
||||
test('lists nested writable folders without bookmarks or managed folders', async () => {
|
||||
const request = harness({ getTree: async () => [{ id: '0', children: [
|
||||
{ id: '1', parentId: '0', title: 'Favorites', children: [
|
||||
{ id: '2', parentId: '1', title: 'Work', children: [] },
|
||||
{ id: '3', parentId: '1', title: 'Link', url: 'https://example.com' }
|
||||
] },
|
||||
{ id: '4', parentId: '0', title: 'Managed', unmodifiable: 'managed' }
|
||||
] }] });
|
||||
const result = await request({ type: 'folders' });
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(result.folders)), [
|
||||
{ id: '1', label: 'Favorites' }, { id: '2', label: 'Favorites / Work' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects missing or unavailable destinations without creating a bookmark', async () => {
|
||||
const request = harness({ create: async () => assert.fail('invalid destination') });
|
||||
assert.equal((await request({ type: 'bookmark' })).ok, false);
|
||||
assert.equal((await request({ type: 'bookmark', parentId: 'unknown' })).ok, false);
|
||||
});
|
||||
@@ -40,9 +40,12 @@ test("distribution contracts: manifests, execution boundaries and deterministic
|
||||
const base = {
|
||||
manifest_version: 3, name: "Opportunities Extension",
|
||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||
version: "1.0.8", permissions: ["cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"],
|
||||
version: "1.1.0", permissions: ["bookmarks", "cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"],
|
||||
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*", "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"],
|
||||
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: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
|
||||
js: ["content.js"], all_frames: true, run_at: "document_idle"
|
||||
}, {
|
||||
|
||||
@@ -26,7 +26,7 @@ for (const family of ["chrome", "firefox"]) {
|
||||
assert.equal(h.tabs[0].active, false);
|
||||
assert.equal(h.tabs[0].windowId, 3);
|
||||
assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
|
||||
assert.equal(h.tabs[0].url, "https://spa.oracle.com/");
|
||||
assert.equal(h.tabs[0].url, "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace");
|
||||
assert.deepEqual(h.removedTabs, [91]);
|
||||
assert.equal(result.debugRequests[0].httpStatus, status);
|
||||
assert.match(result.debugRequests[0].metadata.retryReason, /SPA session recovery/);
|
||||
|
||||
@@ -109,7 +109,7 @@ async function run() {
|
||||
assert.equal(createdTabs[0].active, false);
|
||||
assert.equal(
|
||||
createdTabs[0].url,
|
||||
"https://spa.oracle.com/"
|
||||
"https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace"
|
||||
);
|
||||
assert.deepEqual(removedTabs, [27]);
|
||||
assert.equal(response.debugRequests.length, 2);
|
||||
|
||||
@@ -2,10 +2,10 @@ const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
const response = data => new Response(JSON.stringify(data), { status: 200 });
|
||||
function setup(fetch) {
|
||||
function setup(fetch, searchFetch = async () => response({ items: [{ savedSearchNumber: "dynamic-sr-search" }] })) {
|
||||
const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
|
||||
document.createElementNS = (_, tag) => document.createElement(tag);
|
||||
const app = loadApplication("content", { fetch, document, Intl, Date });
|
||||
const app = loadApplication("content", { fetch: (url, options) => options.method === "GET" ? searchFetch(url, options) : fetch(url, options), document, Intl, Date });
|
||||
app.currentUser.allowsCrmActivities = () => true;
|
||||
app.debugContentView.renderDebugPanel = () => {};
|
||||
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||
@@ -15,6 +15,42 @@ const sr = (number, id = "SR-1") => ({ SrNumber: id, Title: "<script>synthetic</
|
||||
OpportunityName_Id_c: { OptyNumber: number }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner",
|
||||
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
|
||||
|
||||
test("SR saved search runs once before pages and uses the first item, not the default", async () => {
|
||||
const calls = [];
|
||||
const app = setup(async (_url, options) => {
|
||||
const payload = JSON.parse(options.body); calls.push(payload);
|
||||
assert.equal(payload.copiedFrom, "queries/first-search");
|
||||
return response({ items: [sr("A")], count: 1, offset: payload.offset, hasMore: payload.offset === 0 });
|
||||
}, async (url, options) => {
|
||||
assert.equal(options.method, "GET");
|
||||
assert.equal(options.headers.Authorization, "Bearer synthetic");
|
||||
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries?applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=CRMServiceRequest&language=en&limit=-1&onlyData=true");
|
||||
calls.push("lookup");
|
||||
return response({ items: [{ savedSearchNumber: "first-search" }, { savedSearchNumber: "other", isDefault: true }] });
|
||||
});
|
||||
await app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1);
|
||||
assert.equal(calls.length, 3); assert.equal(calls[0], "lookup");
|
||||
assert.equal(app.debugContentView.getRequestCategory(app.debugState.requestLog[0]), "serviceRequests");
|
||||
});
|
||||
|
||||
test("failed or empty SR search blocks POST; superseded search never starts pagination", async () => {
|
||||
for (const lookup of [
|
||||
async () => new Response("Denied", { status: 401 }),
|
||||
async () => response({ items: [] }),
|
||||
async () => response({ items: [{ savedSearchNumber: " " }] }),
|
||||
async () => new Response("<html>Login</html>")
|
||||
]) {
|
||||
const app = setup(() => assert.fail("No POST without a saved search"), lookup);
|
||||
await assert.rejects(app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1));
|
||||
assert.equal(app.debugState.requestLog[0].status, "failed");
|
||||
}
|
||||
const app = setup(() => assert.fail("Stale POST"), async () => {
|
||||
app.opportunitiesQueryState.periodRequestVersion = 2;
|
||||
return response({ items: [{ savedSearchNumber: "first" }] });
|
||||
});
|
||||
assert.equal(await app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1), null);
|
||||
});
|
||||
|
||||
test("SR badge shows reported hours, sums linked SRs and preserves missing values", async () => {
|
||||
for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/A"], [[4, null], "N/A"], [[""], "N/A"], [["invalid"], "N/A"]]) {
|
||||
const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false }));
|
||||
@@ -79,7 +115,7 @@ test("CRM query preserves payload and pagination after opportunities, uses token
|
||||
assert.equal(options.headers.Preference, "transient");
|
||||
assert.deepEqual(payload, {
|
||||
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "CRMServiceRequest",
|
||||
copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205",
|
||||
copiedFrom: "queries/dynamic-sr-search",
|
||||
q: { op: "$and", criteria: [
|
||||
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
|
||||
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
|
||||
@@ -90,8 +126,9 @@ test("CRM query preserves payload and pagination after opportunities, uses token
|
||||
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c", "OpportunityName_Id_c.OptyNumber", "Owner"],
|
||||
skipInValidFields: true, limit: 15, offset: 0
|
||||
});
|
||||
assert.equal(app.debugState.requestLog.length, 2);
|
||||
for (const log of app.debugState.requestLog) {
|
||||
assert.equal(app.debugState.requestLog.length, 3);
|
||||
assert.equal(app.debugState.requestLog[0].name, "requestSRSearchResources");
|
||||
for (const log of app.debugState.requestLog.slice(1)) {
|
||||
assert.equal(log.name, "resquestCrmServiceRequest");
|
||||
assert.equal(log.requestHeaders.Origin, "https://eeho.fa.us2.oraclecloud.com");
|
||||
assert.equal(log.requestHeaders.Preference, "transient");
|
||||
@@ -144,6 +181,7 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
|
||||
let resolveOld;
|
||||
const app = setup(() => new Promise(resolve => { resolveOld = resolve; }));
|
||||
const old = app.opportunityCrmServiceRequests.load("token", 1);
|
||||
while (!resolveOld) await new Promise(resolve => setImmediate(resolve));
|
||||
app.opportunitiesQueryState.periodRequestVersion = 2;
|
||||
app.opportunityCrmServiceRequests.reset();
|
||||
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [sr("NEW")];
|
||||
@@ -152,7 +190,7 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
|
||||
await old;
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "0h");
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "0h");
|
||||
assert.equal(app.debugState.requestLog.length, 1);
|
||||
assert.equal(app.debugState.requestLog.length, 2);
|
||||
});
|
||||
|
||||
test("Reference changes share pending pagination and reuse completed CRM results", async () => {
|
||||
@@ -167,6 +205,7 @@ test("Reference changes share pending pagination and reuse completed CRM results
|
||||
app.opportunitiesQueryState.periodRequestVersion = 2;
|
||||
const second = app.opportunityCrmServiceRequests.load("token", 2);
|
||||
assert.equal(first, second);
|
||||
while (!finish) await new Promise(resolve => setImmediate(resolve));
|
||||
finish(response({ items: [sr("A")], count: 1, offset: 0, hasMore: true }));
|
||||
await second;
|
||||
app.opportunitiesQueryState.periodRequestVersion = 3;
|
||||
|
||||
@@ -250,44 +250,6 @@ test("Primary pillar lookup uses Session, orders enabled name/code options and r
|
||||
assert.equal((await loadBackground({ fetch, messageTimeout: 10000 }).dispatch({ type: "opportunitiesExtension.requestSrLookupPrimaryPillar" })).ok, false);
|
||||
}
|
||||
});
|
||||
for (const family of ["chrome", "firefox"]) {
|
||||
test(`${family}: 401 is returned to the caller without opening tabs`, async () => {
|
||||
let count = 0;
|
||||
const h = loadBackground({ family, fetch: async () => ++count < 3 ? response({}, 401) : response({ items: [] }) });
|
||||
const result = await h.dispatch({ type, srId: "123" });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.httpStatus, 401);
|
||||
assert.equal(count, 1);
|
||||
assert.equal(h.tabs.length, 0);
|
||||
assert.deepEqual(h.removedTabs, []);
|
||||
assert.deepEqual(result.debugRequests.map(entry => entry.httpStatus), [401]);
|
||||
});
|
||||
}
|
||||
test("concurrent 401 responses do not cause background tab recovery", async () => {
|
||||
let calls = 0;
|
||||
const h = loadBackground({ fetch: async url => {
|
||||
if (++calls <= 2) return response({}, 401);
|
||||
const id = new URL(url).searchParams.get("q");
|
||||
return response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: [{ Title: id }] }] });
|
||||
} });
|
||||
const results = await Promise.all([h.dispatch({ type, srId: "1" }), h.dispatch({ type, srId: "2" })]);
|
||||
assert.equal(h.tabs.length, 0);
|
||||
assert.deepEqual(h.removedTabs, []);
|
||||
assert.ok(results.every(result => result.httpStatus === 401));
|
||||
});
|
||||
test("background never creates recovery tabs after an error", async () => {
|
||||
for (const status of [401, 500]) {
|
||||
let count = 0;
|
||||
const h = loadBackground({ fetch: async () => response({}, ++count === 1 ? 401 : status) });
|
||||
const result = await h.dispatch({ type, srId: "1" });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(count, 1);
|
||||
assert.deepEqual(h.removedTabs, []);
|
||||
}
|
||||
const h = loadBackground({ fetch: async () => response({}, 403) });
|
||||
assert.equal((await h.dispatch({ type, srId: "1" })).ok, false);
|
||||
assert.equal(h.tabs.length, 0);
|
||||
});
|
||||
for (const family of ["chrome", "firefox"]) {
|
||||
test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => {
|
||||
const calls = [];
|
||||
|
||||
@@ -84,7 +84,7 @@ test("internal SR form validates Save, maps parent data, prevents duplicate post
|
||||
assert.ok(!h.walk(h.modal).includes(form));
|
||||
});
|
||||
|
||||
test("internal SR uses Session without TokenRelay and reports unavailable iframe host", async () => {
|
||||
test("internal SR uses Session and offers manual retry after 401 without automatic probes", async () => {
|
||||
const h = setup();
|
||||
let renewals = 0;
|
||||
const sent = [];
|
||||
@@ -106,6 +106,11 @@ test("internal SR uses Session without TokenRelay and reports unavailable iframe
|
||||
assert.equal(sent[0].token, undefined);
|
||||
assert.ok(sent.every(item => item.type === "opportunitiesExtension.requestInternalServiceRequest"));
|
||||
assert.ok(h.walk(h.panel()).some(node => node.attributes.role === "alert"));
|
||||
const retry = h.walk(h.panel()).find(node => node.attributes["aria-label"] === "Retry loading Internal Service Requests");
|
||||
assert.ok(retry);
|
||||
retry.listeners.click();
|
||||
await flush();
|
||||
assert.equal(sent.length, 2);
|
||||
});
|
||||
|
||||
function setup() {
|
||||
|
||||
@@ -86,7 +86,7 @@ for (const family of ["chrome", "firefox"]) {
|
||||
assert.deepEqual(first, second);
|
||||
assert.equal(attempts, 3);
|
||||
assert.deepEqual(app.tabs, [{
|
||||
url: "https://spa.oracle.com/",
|
||||
url: "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=whitespace",
|
||||
active: false, windowId: 3, ...(family === "firefox" ? { cookieStoreId: "test-store" } : {})
|
||||
}]);
|
||||
assert.deepEqual(app.removedTabs, [91]);
|
||||
@@ -194,7 +194,7 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL
|
||||
assert.equal(same, app.currentUser.ensure());
|
||||
finish({ ok: true, user: { ...validIdentity.user, roles } });
|
||||
await first;
|
||||
assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]);
|
||||
assert.deepEqual(calls, [type, "tokenrelay", "opportunities", ...(allowed ? ["crm"] : [])]);
|
||||
app.productsContentState.currentProductsRequestId = "products-cycle";
|
||||
app.productActivities.begin("products-cycle", "synthetic");
|
||||
const products = { items: [{ RevnId: "123" }] };
|
||||
|
||||
43
tests/dependentFilters.test.cjs
Normal file
43
tests/dependentFilters.test.cjs
Normal file
@@ -0,0 +1,43 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadApplication } = require('./support/application.cjs');
|
||||
function setup() {
|
||||
const app = loadApplication('content');
|
||||
const items = [['A', 'X'], ['A', 'Y'], ['B', 'Y'], ['C', 'Z'], ['A', 'X']].map(([customer, owner]) => ({
|
||||
CustomerAccount: { PartyId: customer, PartyUniqueName: customer }, Owner: owner
|
||||
}));
|
||||
app.opportunitiesTableState.opportunitiesTableState.items = items;
|
||||
app.opportunitiesFilters.resetCustomerFilter(items);
|
||||
app.opportunitiesFilters.resetOwnerFilter(items);
|
||||
return { filters: app.opportunitiesFilters, state: app.opportunitiesFilterState };
|
||||
}
|
||||
const labels = options => Array.from(options, option => option.label);
|
||||
test('customers narrow owners without losing all-owner selection; widening restores options', () => {
|
||||
const { filters, state } = setup();
|
||||
state.selectedCustomerKeys = new Set(['party:A']);
|
||||
assert.deepEqual(labels(filters.getAvailableOwnerOptions()), ['X', 'Y']);
|
||||
assert.deepEqual(labels(filters.getAvailableCustomerOptions()), ['A', 'B', 'C']);
|
||||
state.selectedCustomerKeys.add('party:C');
|
||||
assert.deepEqual(labels(filters.getAvailableOwnerOptions()), ['X', 'Y', 'Z']);
|
||||
assert.equal(state.selectedOwnerKeys.size, 3);
|
||||
});
|
||||
test('owners narrow customers and multi-selection combines their customers', () => {
|
||||
const { filters, state } = setup();
|
||||
state.selectedOwnerKeys = new Set(['name:Y']);
|
||||
assert.deepEqual(labels(filters.getAvailableCustomerOptions()), ['A', 'B']);
|
||||
assert.deepEqual(labels(filters.getAvailableOwnerOptions()), ['X', 'Y', 'Z']);
|
||||
state.selectedOwnerKeys.add('name:Z');
|
||||
assert.deepEqual(labels(filters.getAvailableCustomerOptions()), ['A', 'B', 'C']);
|
||||
});
|
||||
test('both filters use the opposite selection, independent of search and stage', () => {
|
||||
const { filters, state } = setup();
|
||||
state.selectedOwnerKeys = new Set(['name:Y']);
|
||||
state.selectedCustomerKeys = new Set(['party:A']);
|
||||
state.tableSearch = 'no match';
|
||||
state.selectedStages.clear();
|
||||
assert.deepEqual(labels(filters.getAvailableCustomerOptions()), ['A', 'B']);
|
||||
assert.deepEqual(labels(filters.getAvailableOwnerOptions()), ['X', 'Y']);
|
||||
state.selectedCustomerKeys.clear();
|
||||
assert.deepEqual(labels(filters.getAvailableOwnerOptions()), []);
|
||||
assert.deepEqual(labels(filters.getAvailableCustomerOptions()), ['A', 'B']);
|
||||
});
|
||||
76
tests/dialogDrag.test.cjs
Normal file
76
tests/dialogDrag.test.cjs
Normal file
@@ -0,0 +1,76 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const factory = vm.runInNewContext(fs.readFileSync('src/content/dialog-drag.js', 'utf8') + '\ncreateDialogDrag');
|
||||
function setup({ header = false, interactive = false, unrelated = false } = {}) {
|
||||
const events = {}, winEvents = {}, attrs = {}, style = {};
|
||||
const panel = { style, isConnected: true, full: false,
|
||||
closest: selector => selector.includes('modal') ? {} : null,
|
||||
querySelector: () => title,
|
||||
setAttribute: (key, value) => attrs[key] = value,
|
||||
removeAttribute: key => delete attrs[key],
|
||||
getClientRects: () => [1],
|
||||
getBoundingClientRect() {
|
||||
const left = 200 + (parseFloat(style.left) || 0), top = 150 + (parseFloat(style.top) || 0);
|
||||
return { left, top, width: this.full ? 1000 : 400, height: this.full ? 800 : 300 };
|
||||
}
|
||||
};
|
||||
const title = { closest: () => panel, setPointerCapture() {}, hasPointerCapture: () => true, releasePointerCapture() {} };
|
||||
const bar = { ...title, contains: node => !unrelated && node === title };
|
||||
const target = { closest: selector => selector === 'header, h2, h3' ? (header ? bar : title) : interactive ? {} : null };
|
||||
const document = { documentElement: { clientWidth: 1000, clientHeight: 800 },
|
||||
addEventListener: (name, fn) => events[name] = fn, querySelectorAll: () => [panel] };
|
||||
const window = { addEventListener: (name, fn) => winEvents[name] = fn };
|
||||
factory({ environment: { document, window } }).install();
|
||||
const event = (x, y) => ({ target, button: 0, pointerId: 1, clientX: x, clientY: y, preventDefault() {} });
|
||||
return { panel, events, winEvents, attrs, style, document, event };
|
||||
}
|
||||
test('dragging permits 70 percent overflow in every direction and stops on release', () => {
|
||||
const h = setup();
|
||||
h.events.pointerdown(h.event(220, 160));
|
||||
h.events.pointermove(h.event(2000, 2000));
|
||||
assert.equal(h.panel.getBoundingClientRect().left, 880);
|
||||
assert.equal(h.panel.getBoundingClientRect().top, 710);
|
||||
h.events.pointermove(h.event(-2000, -2000));
|
||||
assert.equal(h.panel.getBoundingClientRect().left, -280);
|
||||
assert.equal(h.panel.getBoundingClientRect().top, -210);
|
||||
h.events.pointerup(h.event(0, 0));
|
||||
assert.equal(h.attrs['data-dialog-dragging'], undefined);
|
||||
h.events.pointermove(h.event(400, 400));
|
||||
assert.equal(h.panel.getBoundingClientRect().left, -280);
|
||||
});
|
||||
test('full-page panels do not drag', () => {
|
||||
const h = setup(); h.panel.full = true;
|
||||
h.events.pointerdown(h.event(220, 160));
|
||||
h.events.pointermove(h.event(300, 200));
|
||||
assert.equal(h.style.left, undefined);
|
||||
});
|
||||
test('resize keeps at least 30 percent of a moved panel inside each viewport axis', () => {
|
||||
const h = setup();
|
||||
h.events.pointerdown(h.event(220, 160));
|
||||
h.events.pointermove(h.event(500, 400));
|
||||
h.document.documentElement.clientWidth = 600;
|
||||
h.document.documentElement.clientHeight = 500;
|
||||
h.winEvents.resize();
|
||||
assert.equal(h.panel.getBoundingClientRect().left, 480);
|
||||
assert.equal(h.panel.getBoundingClientRect().top, 390);
|
||||
assert.equal(h.attrs['data-dialog-dragging'], undefined);
|
||||
});
|
||||
|
||||
test('empty title bar space starts dragging', () => {
|
||||
const h = setup({ header: true });
|
||||
h.events.pointerdown(h.event(220, 160));
|
||||
h.events.pointermove(h.event(300, 200));
|
||||
assert.equal(h.panel.getBoundingClientRect().left, 280);
|
||||
assert.equal(h.panel.getBoundingClientRect().top, 190);
|
||||
});
|
||||
|
||||
test('title bar controls and content headers do not start dragging', () => {
|
||||
for (const options of [{ header: true, interactive: true }, { header: true, unrelated: true }]) {
|
||||
const h = setup(options);
|
||||
h.events.pointerdown(h.event(220, 160));
|
||||
h.events.pointermove(h.event(300, 200));
|
||||
assert.equal(h.style.left, undefined);
|
||||
}
|
||||
});
|
||||
14
tests/internalSrRecovery.test.cjs
Normal file
14
tests/internalSrRecovery.test.cjs
Normal file
@@ -0,0 +1,14 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const factory = vm.runInNewContext(fs.readFileSync('src/services/fusion/sr-details.js', 'utf8') + '\ncreateFusionSrDetails');
|
||||
test('Internal SR background returns 401 without opening any tab or retrying', async () => {
|
||||
let calls = 0;
|
||||
const api = factory({
|
||||
fetchJson: async () => { calls++; throw Object.assign(new Error('Unauthorized'), {httpStatus:401}); },
|
||||
tabsCreate: () => assert.fail('Internal SR must not open a tab')
|
||||
});
|
||||
await assert.rejects(api.requestInternalServiceRequest({srId:'123'}), /Unauthorized/);
|
||||
assert.equal(calls,1);
|
||||
});
|
||||
@@ -1,145 +1,69 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const fs = require("node:fs");
|
||||
const vm = require("node:vm");
|
||||
const path = require("node:path");
|
||||
const factory = vm.runInNewContext(`${fs.readFileSync(path.join(__dirname, "../src/features/opportunities/internal-sr-session.js"), "utf8")}\ncreateInternalSrSession`);
|
||||
function setup(query, options = {}) {
|
||||
let elapsed = 0;
|
||||
const frames = [], removed = [], delays = [], stopped = [];
|
||||
const body = { append(frame) { frame.initialSrc = frame.src; frames.push(frame); } };
|
||||
const document = { body, createElement(tag) {
|
||||
assert.equal(tag, "iframe");
|
||||
return { contentWindow: { stop() { stopped.push(true); } }, style: {}, attributes: {}, listeners: {}, setAttribute(k, v) { this.attributes[k] = v; },
|
||||
addEventListener(k, fn) { this.listeners[k] = fn; }, remove() { removed.push(this); } };
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const factory = vm.runInNewContext(fs.readFileSync('src/features/opportunities/internal-sr-session.js', 'utf8') + '\ncreateInternalSrSession');
|
||||
test('without a document, session recovery stops without polling', async () => {
|
||||
for (const result of [{ok:false,httpStatus:401}, {ok:false,debugRequests:[{httpStatus:401}]}, {ok:false,error:'Request failed with status 401.'}]) {
|
||||
let calls=0;
|
||||
const service=factory({query:async()=>{calls++; return result;},environment:{document:{createElement(){assert.fail('No iframe');}},setTimeout(){assert.fail('No polling');}}});
|
||||
await assert.rejects(service.request('123'), /Internal SR|status 401/);
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(calls,1);
|
||||
}
|
||||
});
|
||||
|
||||
function harness() {
|
||||
const timers = [], listeners = new Map();
|
||||
let appended = 0, removed = 0;
|
||||
const frame = {style:{},setAttribute(){},addEventListener(k,v){listeners.set(k,v);},removeEventListener(k){listeners.delete(k);},remove(){removed++;}};
|
||||
return { timers, frame, listeners, counts:()=>({appended,removed}), environment:{
|
||||
document:{createElement:()=>frame,body:{append(){appended++;}}},
|
||||
setTimeout(fn,delay){timers.push({fn,delay});}
|
||||
}};
|
||||
const api = factory({ ...options, query, environment: { document, performance: { now: () => elapsed }, setTimeout: (fn, ms) => { delays.push(ms); elapsed += ms; return setImmediate(fn); } } });
|
||||
return { api, frames, removed, delays, stopped };
|
||||
}
|
||||
|
||||
test("lookup requests recover together using the configured page and preserve each result", async () => {
|
||||
let authenticated = false;
|
||||
let calls = 0;
|
||||
const h = setup(async message => {
|
||||
if (++calls <= 4) return { ok: false, httpStatus: 401 };
|
||||
authenticated = true;
|
||||
return { ok: true, items: [message.type] };
|
||||
}, { sessionUrl: "https://eeho.fa.us2.oraclecloud.com/?view=Show%20Service%20Request", sessionLabel: "Service Request" });
|
||||
const types = ["pillar", "language", "platform", "services"];
|
||||
const results = await Promise.all(types.map(type => h.api.request({ type })));
|
||||
assert.deepEqual(results, types.map(type => [type]));
|
||||
assert.equal(authenticated, true);
|
||||
assert.equal(h.frames.length, 1);
|
||||
assert.match(h.frames[0].initialSrc, /Show%20Service%20Request$/);
|
||||
assert.equal(h.frames[0].title, "Service Request session initialization");
|
||||
assert.equal(h.removed.length, 1);
|
||||
assert.equal(h.stopped.length, 1);
|
||||
const flush = () => new Promise(resolve=>setImmediate(resolve));
|
||||
test('hidden iframe remains alive after load and closes only after successful session query',async()=>{
|
||||
const h=harness(); let calls=0;
|
||||
const api=factory({environment:h.environment,query:async()=>++calls<3?{ok:false,httpStatus:401}:{ok:true,items:[{SrId:1}]}});
|
||||
const first=api.request('123'); assert.equal(first,api.request(123));
|
||||
await flush();
|
||||
assert.equal(h.counts().appended,1);
|
||||
h.listeners.get('load')?.();
|
||||
assert.equal(h.counts().removed,0);
|
||||
assert.equal(calls,1);
|
||||
assert.equal(h.timers[0].delay,2000);
|
||||
h.timers.shift().fn(); await flush();
|
||||
assert.equal(h.counts().removed,0);
|
||||
assert.equal(h.timers[0].delay,4000);
|
||||
h.timers.shift().fn();
|
||||
assert.equal((await first)[0].SrId,1);
|
||||
assert.equal(h.counts().removed,1);
|
||||
});
|
||||
test("401 initializes a hidden iframe in the calling document and removes it on success", async () => {
|
||||
let calls = 0;
|
||||
const h = setup(async () => ++calls < 3 ? { ok: false, httpStatus: 401 } : { ok: true, items: [{ SrId: 1 }] });
|
||||
const items = await h.api.request("1");
|
||||
assert.equal(items[0].SrId, 1);
|
||||
assert.equal(h.frames.length, 1);
|
||||
const frame = h.frames[0];
|
||||
assert.equal(frame.tabIndex, -1);
|
||||
assert.equal(frame.attributes["aria-hidden"], "true");
|
||||
assert.match(frame.style.cssText, /pointer-events:none/);
|
||||
assert.ok(!frame.attributes.sandbox.includes("allow-top-navigation"));
|
||||
assert.ok(frame.initialSrc.endsWith("&view=showisr"));
|
||||
assert.equal(frame.src, "about:blank");
|
||||
assert.equal(h.stopped.length, 1);
|
||||
assert.deepEqual(h.delays, [250]);
|
||||
assert.equal(h.removed[0], frame);
|
||||
});
|
||||
|
||||
test("an immediately available session stops the iframe without waiting for load or a timer", async () => {
|
||||
let calls = 0;
|
||||
const h = setup(async () => ++calls === 1 ? { ok: false, httpStatus: 401 } : { ok: true, items: [] });
|
||||
await h.api.request("1");
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(h.delays.length, 0);
|
||||
assert.equal(h.stopped.length, 1);
|
||||
assert.equal(h.removed.length, 1);
|
||||
});
|
||||
|
||||
test("lookup 401 errors from older background responses still initialize the session", async () => {
|
||||
for (const failure of [
|
||||
{ ok: false, error: "requestSrLookupLanguage failed with status 401." },
|
||||
{ ok: false, debugRequests: [{ httpStatus: 401 }] },
|
||||
{ ok: false, httpStatus: "401" }
|
||||
]) {
|
||||
let calls = 0;
|
||||
const h = setup(async () => ++calls <= 2 ? failure : { ok: true, items: ["ready"] });
|
||||
assert.deepEqual(await h.api.request("language"), ["ready"]);
|
||||
assert.equal(h.frames.length, 1);
|
||||
assert.equal(h.removed.length, 1);
|
||||
test('persistent 401 is bounded to five recovery checks and removes iframe',async()=>{
|
||||
const h=harness(); let calls=0;
|
||||
const api=factory({environment:h.environment,query:async()=>{calls++;return {ok:false,httpStatus:401};}});
|
||||
const failed=assert.rejects(api.request('123'),/still unavailable/);
|
||||
for(const delay of [2000,4000,8000,12000,16000]) {
|
||||
await flush(); assert.equal(h.timers[0].delay,delay); h.timers.shift().fn();
|
||||
}
|
||||
await failed;
|
||||
assert.equal(calls,6);
|
||||
assert.equal(h.counts().removed,1);
|
||||
assert.equal(h.timers.length,0);
|
||||
});
|
||||
|
||||
test("a lookup that still returns 401 after shared recovery initializes its own session", async () => {
|
||||
const calls = new Map();
|
||||
const h = setup(async id => {
|
||||
const count = (calls.get(id) || 0) + 1;
|
||||
calls.set(id, count);
|
||||
return count <= (id === "language" ? 1 : 2)
|
||||
? { ok: false, httpStatus: 401 } : { ok: true, items: [id] };
|
||||
test('concurrent requests share work and explicit retry can recover', async () => {
|
||||
let calls=0, complete;
|
||||
const service=factory({query:()=>{calls++; return new Promise(resolve=>{complete=resolve;});}});
|
||||
const first=service.request('123'); const second=service.request(123);
|
||||
assert.equal(first,second); await Promise.resolve();
|
||||
complete({ok:false,httpStatus:401}); await assert.rejects(first);
|
||||
const retry=service.request('123'); await Promise.resolve(); complete({ok:true,items:[{SrId:'456'}]});
|
||||
assert.equal((await retry)[0].SrId,'456'); assert.equal(calls,2);
|
||||
});
|
||||
assert.deepEqual(await Promise.all([h.api.request("language"), h.api.request("platform")]), [["language"], ["platform"]]);
|
||||
assert.equal(h.frames.length, 2);
|
||||
assert.equal(h.removed.length, 2);
|
||||
});
|
||||
|
||||
test("cross-origin redirects still allow iframe removal when stop is blocked", async () => {
|
||||
let calls = 0;
|
||||
const h = setup(async () => {
|
||||
if (++calls === 1) return { ok: false, httpStatus: 401 };
|
||||
h.frames[0].contentWindow.stop = () => { throw new Error("Cross-origin"); };
|
||||
return { ok: true, items: [] };
|
||||
});
|
||||
await h.api.request("1");
|
||||
assert.equal(h.frames[0].src, "about:blank");
|
||||
assert.equal(h.removed.length, 1);
|
||||
});
|
||||
test("concurrent queries share one iframe without mixing SR results", async () => {
|
||||
let calls = 0;
|
||||
const h = setup(async id => ++calls <= 2 ? { ok: false, httpStatus: 401 } : { ok: true, items: [id] });
|
||||
const results = await Promise.all([h.api.request("1"), h.api.request("2")]);
|
||||
assert.deepEqual(results, [["1"], ["2"]]);
|
||||
assert.equal(h.frames.length, 1);
|
||||
assert.equal(h.removed.length, 1);
|
||||
});
|
||||
test("normal success does not mount an iframe; errors and timeout always clean it up", async () => {
|
||||
const success = setup(async () => ({ ok: true, items: [] }));
|
||||
await success.api.request("1");
|
||||
assert.equal(success.frames.length, 0);
|
||||
for (const status of [401, 500]) {
|
||||
let calls = 0;
|
||||
const h = setup(async () => ({ ok: false, httpStatus: ++calls === 1 ? 401 : status }));
|
||||
await assert.rejects(h.api.request("1"));
|
||||
if (status === 401) {
|
||||
assert.ok(calls > 31);
|
||||
assert.equal(h.delays.reduce((sum, ms) => sum + ms, 0), 60000);
|
||||
} else assert.equal(calls, 2);
|
||||
assert.equal(h.removed.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("slow session initialization can succeed after the old thirty-attempt limit", async () => {
|
||||
let calls = 0;
|
||||
const h = setup(async () => ++calls < 45 ? { ok: false, httpStatus: 401 } : { ok: true, items: ["ready"] });
|
||||
assert.deepEqual(await h.api.request("1"), ["ready"]);
|
||||
assert.equal(calls, 45);
|
||||
assert.equal(h.removed.length, 1);
|
||||
assert.ok(h.delays.reduce((sum, ms) => sum + ms, 0) < 60000);
|
||||
});
|
||||
|
||||
test("iframe load alone never confirms authentication", async () => {
|
||||
let calls = 0;
|
||||
const h = setup(async () => {
|
||||
if (++calls > 1) h.frames[0].listeners.load();
|
||||
return { ok: false, httpStatus: 401 };
|
||||
});
|
||||
await assert.rejects(h.api.request("1"), /HTTP 401.*load event, but authentication was not confirmed/);
|
||||
assert.equal(h.removed.length, 1);
|
||||
test('other errors do not retry and successful empty results are preserved', async()=>{
|
||||
const failed=factory({query:async()=>({ok:false,error:'Forbidden',httpStatus:403})});
|
||||
await assert.rejects(failed.request('123'),/Forbidden/);
|
||||
const ok=factory({query:async()=>({ok:true,items:[]})}); assert.equal((await ok.request('123')).length,0);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user