mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-15 16:00:41 +01:00
Compare commits
16 Commits
quick-filt
...
feat/sqlco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b19b89b004 | ||
|
|
9fb05731a7 | ||
|
|
861e5f75cf | ||
|
|
755cca13cf | ||
|
|
e0c0ac4ea6 | ||
|
|
c8e9e362f7 | ||
|
|
4175f84815 | ||
|
|
fd032291f9 | ||
|
|
851abd2c93 | ||
|
|
c41899f2eb | ||
|
|
24b6debd78 | ||
|
|
2134adf735 | ||
|
|
8ee9f97f28 | ||
|
|
878e938b65 | ||
|
|
206aad1acd | ||
|
|
f6cd4d31b4 |
294
.claude/skills/storybook-visual-diff/SKILL.md
Normal file
294
.claude/skills/storybook-visual-diff/SKILL.md
Normal file
@@ -0,0 +1,294 @@
|
||||
---
|
||||
name: storybook-visual-diff
|
||||
description: Screenshot a set of SigNoz Storybook stories, then pixel-diff two runs to see what a CSS or component change did, with the changes tinted over the new shot. Use when asked to take story screenshots, capture a visual baseline, compare before/after of a style change, or find which pages a change affects.
|
||||
---
|
||||
|
||||
# Storybook visual diff
|
||||
|
||||
Two scripts under `frontend/scripts`:
|
||||
|
||||
- `story-shots.mjs` — screenshots stories off a running Storybook dev server.
|
||||
- `story-shots-diff.mjs` — pixel-diffs two runs and paints what moved.
|
||||
|
||||
Output goes to `frontend/.story-shots/` (gitignored), one directory per run.
|
||||
|
||||
## 0. Settle what is being compared, first
|
||||
|
||||
A diff is only worth taking when the two runs straddle something. Run twice over
|
||||
the same tree and the answer is zero, or the noise floor: true, and useless.
|
||||
So before starting a server, pin down four things. Whatever the prompt already
|
||||
says, take it and do not ask again; ask only for what is genuinely missing, in
|
||||
**one** `AskUserQuestion` call.
|
||||
|
||||
| To settle | Ask | Options |
|
||||
| --- | --- | --- |
|
||||
| Job | "What should this run produce?" | shoot only · baseline for a change you are about to make · compare against a change already in the working tree · compare this branch against another (`main` by default, or one the user names) · compare two configurations of the same story (`--args`, clock, width) · noise floor (same tree twice) |
|
||||
| Scope | "Which stories?" | offer 2-3 concrete selections read off `index.json` (a page, a `--title` prefix, everything), never open-ended |
|
||||
| Themes | "Which themes?" | dark · dark + light |
|
||||
| Read-out | "How should the diff read?" | `green` (changed pixels over the after shot) · `green-parallel` (before \| after \| diff, side by side) · `red` · `red-parallel` · `none` (keep both runs, do not diff) |
|
||||
|
||||
Skip a row when the prompt answers it, and skip the whole call when the prompt
|
||||
answers all of it ("shoot the pods tooltips in both themes" needs no question).
|
||||
Skip Read-out too whenever the job is *shoot only*, and take `none` for what it
|
||||
says: shoot both sides, report both paths, run no comparison. When the prompt
|
||||
says nothing at all, ask; a silent guess here burns ~6 min per sweep on the
|
||||
wrong stories.
|
||||
|
||||
The job decides which loop below to run:
|
||||
|
||||
| Job | Loop |
|
||||
| --- | --- |
|
||||
| **shoot only** | §1, §2, stop. Report the paths. No diff, no second run. |
|
||||
| **baseline first** | the full loop, stopping after step 2 to hand the change back. The user makes it, then continue at step 4. |
|
||||
| **change already in the tree** | the tree *is* the after state. `git stash` (or check out the base commit) to shoot the before, restore, shoot the after. Confirm the working tree is clean enough to stash before touching it, and restore it even if a capture fails. |
|
||||
| **branch vs branch** | shoot the current branch, then `git switch <base>` in place (stash first if the tree is dirty), restart the dev server, shoot again, switch back and unstash. Restart matters: HMR does not survive a whole-branch swap cleanly. Get the tree back to where it started even if a capture fails. |
|
||||
| **noise floor** | two runs, same tree, diff. The number is the harness's floor, not a finding. |
|
||||
| **config vs config** | same tree, two runs that differ only in flags: `--args`, `--clock`, `--width`, `--theme`, `--motion`. Filenames stay identical, so the pairs line up and the caption names what changed. |
|
||||
|
||||
## The loop
|
||||
|
||||
1. Capture the baseline **before touching anything**.
|
||||
2. Capture it a second time and diff the two. That is the noise floor: anything
|
||||
it reports is what the harness cannot hold still, and no conclusion about the
|
||||
change may rest on those stories. Cheap on a handful of stories, ~6 min per
|
||||
32, so on a wide sweep run it over the two or three stories the change is
|
||||
aimed at instead of all of them.
|
||||
3. Make the change.
|
||||
4. Capture again into a third directory.
|
||||
5. Diff, then read the tinted shot of the largest movers to judge the change.
|
||||
|
||||
## 1. One dev server, on a free port
|
||||
|
||||
`storybook dev` keys its Vite dep cache off the config dir, so two servers on the
|
||||
same `-c` serve mismatched prebundles and every story dies with `Invalid hook
|
||||
call`. Check what is already up first — port 6006 is often another repo's
|
||||
Storybook, and its `index.json` then indexes the wrong stories:
|
||||
|
||||
```bash
|
||||
for port in 6006 6007; do
|
||||
curl -s -m 2 "http://localhost:$port/index.json" | head -c 60 && echo " <- $port"
|
||||
done
|
||||
```
|
||||
|
||||
Start the SigNoz one on a free port, from the repo's own binary so no package
|
||||
manager shim is in the way:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
nohup ./node_modules/.bin/storybook dev -p 6007 --no-open --quiet \
|
||||
> "${TMPDIR:-/tmp}/signoz-storybook.log" 2>&1 &
|
||||
```
|
||||
|
||||
It is ready when `curl -s localhost:6007/index.json` returns JSON whose
|
||||
`entries` hold SigNoz story ids.
|
||||
|
||||
## 2. Capture
|
||||
|
||||
Playwright is not a frontend dependency. The script finds it in `tests/e2e`
|
||||
(`pnpm -C tests/e2e install`, `@playwright/test` is enough) or in a global
|
||||
install, and launches Playwright's own chromium, falling back to an installed
|
||||
Chrome. Two escape hatches when that is not what a machine has:
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_MODULE=/path/to/playwright # a different install
|
||||
export CHROME_PATH=/path/to/chrome # a specific browser binary
|
||||
```
|
||||
|
||||
Then pick the stories. `--list` prints the selection without shooting anything:
|
||||
|
||||
```bash
|
||||
# every tooltip story of every page
|
||||
node scripts/story-shots.mjs .story-shots/baseline \
|
||||
--port 6007 --title Pages/ --name tooltip --theme dark
|
||||
|
||||
# a handful of stories by id or by title/name substring, both themes
|
||||
node scripts/story-shots.mjs .story-shots/baseline \
|
||||
--port 6007 --stories pages-noz,dashboards/detail --theme dark,light
|
||||
```
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--stories <match>` | id or `Title/Name` substring, repeatable or comma-separated. Omit for every story. |
|
||||
| `--title <prefix>` | only titles starting with the prefix (`Pages/`, `Components/`) |
|
||||
| `--name <match>` | only story names containing the match |
|
||||
| `--theme dark,light` | one pass per theme; omit for the story's own default (dark) |
|
||||
| `--args <k:v;k2:v2>` | arg overrides, Storybook's own `?args=` syntax, repeatable. A dotted value is dropped by Storybook itself, so map it to a slug inside the story's mocks |
|
||||
| `--port` | dev server port, or `$SB_PORT` |
|
||||
| `--width <px>` | the only fixed dimension, default 1680 |
|
||||
| `--height <px>` | shortest the viewport may be, default 1200 |
|
||||
| `--max-height <px>` | tallest it may grow to, default 8000 |
|
||||
| `--grow <what>` | `scrollers` (default) grows the viewport until the page's own scrollers fit, `document` only follows the document height, `none` keeps `--height` |
|
||||
| `--settle <ms>` | wait after the page goes quiet, default 1500 |
|
||||
| `--clock <iso\|live>` | wall clock the page reads, passed to the preview as `?storyClock`; `live` unfreezes it |
|
||||
| `--motion` | keep animations and transitions running (sets the `motion` global to `live`) |
|
||||
| `--ignore <selector>` | hide matching elements, on top of `[data-shot-ignore]` and `[data-chromatic="ignore"]` |
|
||||
| `--flat` | write `<out>/<id>.png`, no theme directory |
|
||||
| `--no-caption` | leave the caption band off the shots |
|
||||
| `--list` | print the matched stories and exit |
|
||||
|
||||
Files land at `<out>/<theme>/<story-id>.png`, next to a `shots.json` recording
|
||||
what each shot is (id, title, name, theme, `ok`/`busy`, the caption's height in
|
||||
rows) and how the run was configured (args, clock, width, height, grow, motion,
|
||||
settle, ignore). Keep the flags identical between the two runs or the diff pairs
|
||||
nothing.
|
||||
|
||||
Every shot carries the caption band described below, so a single screenshot says
|
||||
what it is on its own. `--no-caption` leaves it off, and so does a machine
|
||||
without ImageMagick (with a warning). The band never changes the shot's width
|
||||
(long text wraps rather than widening the canvas) and its height is recorded, so
|
||||
the diff crops it back off and never reports one caption against another. Two
|
||||
runs whose captions are different heights still diff to zero. A story that never held still for two
|
||||
identical frames is logged `busy` instead of `ok` — treat its diff as suspect.
|
||||
|
||||
Dark alone is enough while iterating on the harness; add `light` for the run you
|
||||
report.
|
||||
|
||||
## 3. Diff
|
||||
|
||||
```bash
|
||||
node scripts/story-shots-diff.mjs .story-shots/baseline .story-shots/capped .story-shots/diff
|
||||
```
|
||||
|
||||
Prints `<changed pixels> <theme>/<story>.png`, largest first, and writes one
|
||||
image per pair. Needs ImageMagick for PNG encode/decode (7's `magick`, or 6's
|
||||
`convert`/`identify`/`montage`); the comparison itself is in the script.
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--mode green` | default. The after shot with the changed pixels painted over it, exactly the pixels that changed. What Chromatic shows. |
|
||||
| `--mode green-parallel` | `previous \| current \| diff` in one image, each tile labelled above it, on a gutter inverted from the theme. The diff tile is the `green` one, so the after shot stays readable underneath. |
|
||||
| `--mode red` | the after shot faded to 10%, changed pixels in red. A pixelmatch-style diff, easiest to read when the change is a thin edge. |
|
||||
| `--mode red-parallel` | the same three tiles, with the `red` diff. Best when the change is a thin edge that the unfaded shot would swallow. |
|
||||
| `--threshold <0..1>` | how far a pixel must move to count. Default 0.063, Chromatic's `diffThreshold`. |
|
||||
| `--include-aa` | count antialiasing changes too. Off by default, as in Chromatic. |
|
||||
| `--tint <#rrggbb>` | override the mode's colour. |
|
||||
| `--no-caption` | drop the caption band. |
|
||||
|
||||
### The caption
|
||||
|
||||
Both scripts stamp a band on top of what they write: `story-shots.mjs` on each
|
||||
shot, from the story and the run's own settings; `story-shots-diff.mjs` on each
|
||||
diff, read out of the two runs' `shots.json`. It carries the story's
|
||||
`Title/Name`, then its id, theme and `busy` flag, then the settings both runs
|
||||
shared, each reading `key:value`. Whatever the two runs did **differently** goes
|
||||
on the side it belongs to: under `previous` and `current` on the parallel tiles,
|
||||
on two lines of the band otherwise. So a pair that differs only in `--args` says
|
||||
so on its face, which is what makes several shots of one story tellable apart.
|
||||
|
||||
The shots' own bands are cropped off before comparing and before going into the
|
||||
tiles, so nothing in the output is a diff of a caption. Type size follows the
|
||||
image width, so it stays readable with the whole image viewed at fit-to-width;
|
||||
the heading is set in an installed sans and the detail lines in a mono, falling
|
||||
back to ImageMagick's default when neither is on the machine. Without a manifest
|
||||
the band falls back to the file path, and a directory of captioned shots whose
|
||||
`shots.json` is missing has nothing to crop by, so its captions do land in the
|
||||
diff. Keep `shots.json` next to the shots.
|
||||
|
||||
### How the comparison works
|
||||
|
||||
Chromatic's own capture and diff run server-side — `chromatic-cli` uploads a
|
||||
built Storybook and contains no capture or comparison code at all. What is public
|
||||
is the parameter contract, and the numbers in it say what the comparison is:
|
||||
`diffThreshold` defaults to `0.063` on a 0-1 scale, which is pixelmatch's
|
||||
`threshold`, and `diffIncludeAntiAliasing` defaults to false, which is
|
||||
pixelmatch's `includeAA: false`. So the script implements that comparison:
|
||||
|
||||
1. Both PNGs are read as raw RGBA through `magick … RGBA:-`.
|
||||
2. Per pixel, the squared YIQ distance between the two colours (weights
|
||||
`0.5053 / 0.299 / 0.1957`), compared against `35215 * threshold²` — 35215 is
|
||||
the largest distance two 8-bit colours can have. Chroma is included, so a
|
||||
colour swap at equal brightness still counts.
|
||||
3. A pixel over the threshold is dropped when it is only antialiasing: it is the
|
||||
darkest or lightest of its eight neighbours, and the other image has a pixel
|
||||
around there doing the same job. This is what keeps a subpixel glyph edge from
|
||||
reading as a change.
|
||||
4. What survives is painted at full opacity, one output pixel per changed input
|
||||
pixel. No dilation, no blobs — a one-pixel shift shows as a one-pixel line.
|
||||
|
||||
A pair whose shots are different sizes is compared over the overlap, and every
|
||||
row and column that exists in only one of them counts as changed.
|
||||
|
||||
Pairing is by `<theme>/<story-id>.png`, so a story that exists on only one side
|
||||
(new on the feature branch, renamed, retitled) has nothing to pair with and is
|
||||
skipped silently. On a branch-vs-branch run, compare the two runs' file lists
|
||||
before reading the numbers.
|
||||
|
||||
## What makes a shot reproducible
|
||||
|
||||
Most of it is in the preview, not in the script, so a Chromatic build in the
|
||||
cloud shoots the same page: `.storybook/preview-head.html` freezes the clock,
|
||||
and `settleForCapture` (the preview's `afterEach`, which runs after `play`)
|
||||
parks the animations and snaps the bottom-pinned lists. The script drives the
|
||||
rest:
|
||||
|
||||
- **Storybook's own render phase is the readiness signal.** It waits for
|
||||
`window.__STORYBOOK_PREVIEW__.storyRenders[].phase === 'finished'`, which is
|
||||
reached only after the loaders, the decorators and the story's `play` are done.
|
||||
A DOM check cannot see a `play` still running. (Storybook 10 spells the final
|
||||
phase `finished`, not `completed`.)
|
||||
- **Network quiescence, not `networkidle`.** react-query retries and msw keep
|
||||
requests going after load, and a few stories hang a request by design, so the
|
||||
wait is "no request for 600ms", capped at 15s.
|
||||
- **The clock is frozen** (`2026-06-15T12:00:00Z`), by the preview itself. Chart windows, `4 mins ago`
|
||||
labels and trial countdowns all derive from `now`; a live clock alone moved
|
||||
8000 pixels on the dashboards list and redrew every chart axis.
|
||||
- **Animations are parked on their last frame** by `html.sb-still`, a
|
||||
zero-length single iteration with `forwards` fill, plus `prefers-reduced-
|
||||
motion`. The Motion toolbar item (`still` by default) turns it off. An infinite
|
||||
spinner is otherwise caught at a random angle.
|
||||
- **`document.fonts.ready`**, because text reflows when a face lands late.
|
||||
- **Lists pinned to their bottom are snapped onto it**, once by the preview and
|
||||
again by the script after the page goes quiet. A virtuoso list settles a
|
||||
few pixels short of the end depending on the order its items were measured in.
|
||||
- **Two identical frames in a row**, because what a page is still waiting on is
|
||||
often not observable from outside it.
|
||||
- **`[data-shot-ignore]`, `[data-chromatic="ignore"]` and `--ignore <selector>`**
|
||||
hide a region that cannot be held still; Chromatic excludes the same attribute
|
||||
from its comparison.
|
||||
- **The width is the only fixed dimension.** Chromatic's `viewports` are widths;
|
||||
the height follows the page. `src/styles.scss` pins `html, body, #root` to
|
||||
`height: 100%; overflow: hidden`, so the document never outgrows the viewport
|
||||
and its height says nothing: what overflows are the shell's inner scrollers.
|
||||
`--grow scrollers`, the default, grows the viewport until the tallest in-flow
|
||||
scroller fits, so nothing is cut off and no scrollbar is left in the shot (the
|
||||
dashboards list goes to 2226px in one round). Popups are skipped — they are out
|
||||
of the flow, and a tall dropdown would otherwise drag the shot to a height
|
||||
nothing on the page needs. A page that sizes a panel in `vh` grows its own
|
||||
content as the viewport grows, so no height ever fits it and the rounds only
|
||||
chase — `.alert-chart-container` is `57vh`, which puts Create Alert's fixed
|
||||
point at 4344px with an empty band on top. Those pages are shot at `--height`
|
||||
with their own scrollbar, which is what they look like in a browser, and the
|
||||
log says `(viewport-sized content, stopped chasing Npx)`.
|
||||
|
||||
With all of that, 29 of the 32 page tooltip stories are byte-identical across
|
||||
runs. The three that are not, and why:
|
||||
|
||||
| Story | Residual | Cause |
|
||||
| --- | --- | --- |
|
||||
| `kubernetes-pods--tooltips-in-options-panel` | ~13k px | 24 tooltips held open in an overlapping cluster; they portal to `body` in mount order, and the drawer's own tooltips mount before or after the list's depending on when their data lands, so overlapping tooltips stack differently. Panel geometry itself is stable. |
|
||||
| `settings-role-editor--tooltips-in-json-editor` | ~2.5k px | monaco re-measures and lands one pixel off. |
|
||||
| `traces-trace-details--tooltips` | ~800 px | same class, one row of the waterfall. |
|
||||
|
||||
Each is bimodal — two stable arrangements — so the same number reappears run
|
||||
after run. Diff a story against itself before believing its number, and reach
|
||||
for `--ignore` when a region cannot be settled.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Zero pixels is a real answer.** A story whose tooltips are all short is
|
||||
unaffected by a tooltip rule; it is not a broken capture.
|
||||
- **The selector matters more than the rule.** A global rule on
|
||||
`[data-slot='…']` only reaches design-system components. antd's own tooltips
|
||||
(`.ant-tooltip-inner`, e.g. the Create Alert help popups) are untouched, which
|
||||
is why some stories show no diff at all.
|
||||
- **Global style overrides need `!important`.** `src/styles.scss` loads before
|
||||
the design system injects its CSS-module styles at runtime, so a plain rule on
|
||||
a `[data-slot='…']` element loses. A component-level `!important` of the same
|
||||
specificity still wins over it — `PanelStatusPopover.module.scss` keeps its own
|
||||
`max-width: 520px !important`.
|
||||
- **A fresh context per story** is why a full sweep takes ~6 min for 32 stories.
|
||||
Reusing one page loses the msw service worker re-registration race and stories
|
||||
start failing after a few navigations.
|
||||
- **Stories behind a hover, drawer or modal** only render what their `play`
|
||||
reaches. If a state is missing from the shot, the story needs the `play`, not
|
||||
the script.
|
||||
30
.github/workflows/jsci.yaml
vendored
30
.github/workflows/jsci.yaml
vendored
@@ -79,6 +79,36 @@ jobs:
|
||||
run: |
|
||||
cd frontend && pnpm generate:api
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run pnpm generate:api in frontend/ locally and commit."; exit 1)
|
||||
storybook:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-noble
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
- name: node-install
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: pnpm
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
- name: install-frontend
|
||||
run: cd frontend && pnpm install
|
||||
- name: test-storybook
|
||||
run: cd frontend && pnpm test:storybook --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
web-settings:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
|
||||
@@ -96,6 +96,7 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceDashboard).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
|
||||
|
||||
@@ -296,6 +296,17 @@ components:
|
||||
- jsmops
|
||||
- incidentio
|
||||
type: string
|
||||
AlertmanagertypesChannelListOrder:
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
AlertmanagertypesChannelListSort:
|
||||
enum:
|
||||
- updated_at
|
||||
- created_at
|
||||
- name
|
||||
type: string
|
||||
AlertmanagertypesChannelMSTeamsConfig:
|
||||
properties:
|
||||
sendResolved:
|
||||
@@ -576,6 +587,43 @@ components:
|
||||
wont_fix_resolution:
|
||||
type: string
|
||||
type: object
|
||||
AlertmanagertypesListableNotificationChannel:
|
||||
properties:
|
||||
channels:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesListedNotificationChannel'
|
||||
type: array
|
||||
total:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- channels
|
||||
- total
|
||||
type: object
|
||||
AlertmanagertypesListedNotificationChannel:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
kind:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelKind'
|
||||
name:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- displayName
|
||||
- kind
|
||||
- createdAt
|
||||
- updatedAt
|
||||
type: object
|
||||
AlertmanagertypesMaintenanceKind:
|
||||
enum:
|
||||
- fixed
|
||||
@@ -942,6 +990,20 @@ components:
|
||||
- timezone
|
||||
- startTime
|
||||
type: object
|
||||
AlertmanagertypesTestableNotificationChannel:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
AlertmanagertypesUpdatableNotificationChannel:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
AuthtypesAttributeMapping:
|
||||
properties:
|
||||
email:
|
||||
@@ -3474,6 +3536,11 @@ components:
|
||||
- tags
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesHeaderOptions:
|
||||
properties:
|
||||
hide:
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -3831,6 +3898,7 @@ components:
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
@@ -3841,6 +3909,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -3851,6 +3920,7 @@ components:
|
||||
- signoz/TablePanel
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
@@ -3924,6 +3994,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesTextPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4215,6 +4297,37 @@ components:
|
||||
- color
|
||||
- columnName
|
||||
type: object
|
||||
DashboardtypesTextAlign:
|
||||
enum:
|
||||
- left
|
||||
- center
|
||||
- right
|
||||
type: string
|
||||
DashboardtypesTextMode:
|
||||
enum:
|
||||
- markdown
|
||||
type: string
|
||||
DashboardtypesTextPanelSpec:
|
||||
properties:
|
||||
headerOptions:
|
||||
$ref: '#/components/schemas/DashboardtypesHeaderOptions'
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesTextMode'
|
||||
presentation:
|
||||
$ref: '#/components/schemas/DashboardtypesTextPresentation'
|
||||
text:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesTextPresentation:
|
||||
properties:
|
||||
background:
|
||||
nullable: true
|
||||
type: string
|
||||
textAlign:
|
||||
$ref: '#/components/schemas/DashboardtypesTextAlign'
|
||||
verticalAlign:
|
||||
$ref: '#/components/schemas/DashboardtypesVerticalAlign'
|
||||
type: object
|
||||
DashboardtypesTextVariableSpec:
|
||||
properties:
|
||||
constant:
|
||||
@@ -4424,6 +4537,12 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVerticalAlign:
|
||||
enum:
|
||||
- top
|
||||
- center
|
||||
- bottom
|
||||
type: string
|
||||
ErrorsJSON:
|
||||
properties:
|
||||
code:
|
||||
@@ -7395,10 +7514,7 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
meta:
|
||||
properties:
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
|
||||
predictedSeries:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
@@ -7413,12 +7529,51 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5Bucket:
|
||||
Querybuildertypesv5AggregationMeta:
|
||||
properties:
|
||||
step:
|
||||
format: double
|
||||
type: number
|
||||
buckets:
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
type: array
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptions:
|
||||
discriminator:
|
||||
mapping:
|
||||
linear: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
|
||||
log: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptionsLinear:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LinearBucketsSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptionsLog:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LogBucketsSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
Querybuildertypesv5BucketsKind:
|
||||
enum:
|
||||
- linear
|
||||
- log
|
||||
type: string
|
||||
Querybuildertypesv5BuilderQuerySpec:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -7599,6 +7754,16 @@ components:
|
||||
value:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LinearBucketsSpec:
|
||||
properties:
|
||||
maxValue:
|
||||
format: double
|
||||
type: number
|
||||
numBuckets:
|
||||
type: integer
|
||||
required:
|
||||
- maxValue
|
||||
type: object
|
||||
Querybuildertypesv5LogAggregation:
|
||||
properties:
|
||||
alias:
|
||||
@@ -7606,6 +7771,12 @@ components:
|
||||
expression:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LogBucketsSpec:
|
||||
properties:
|
||||
scale:
|
||||
nullable: true
|
||||
type: integer
|
||||
type: object
|
||||
Querybuildertypesv5MetricAggregation:
|
||||
properties:
|
||||
comparisonSpaceAggregationParam:
|
||||
@@ -7686,6 +7857,8 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5QueryBuilderFormula:
|
||||
properties:
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
disabled:
|
||||
type: boolean
|
||||
expression:
|
||||
@@ -7716,6 +7889,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LogAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -7777,6 +7952,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5MetricAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -7838,6 +8015,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -8163,6 +8342,7 @@ components:
|
||||
- raw
|
||||
- raw_stream
|
||||
- trace
|
||||
- heatmap
|
||||
type: string
|
||||
Querybuildertypesv5ScalarData:
|
||||
properties:
|
||||
@@ -8237,8 +8417,6 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5TimeSeriesValue:
|
||||
properties:
|
||||
bucket:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Bucket'
|
||||
partial:
|
||||
type: boolean
|
||||
timestamp:
|
||||
@@ -8349,6 +8527,8 @@ components:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
overallStateChanged:
|
||||
type: boolean
|
||||
relatedAITracesLink:
|
||||
type: string
|
||||
relatedLogsLink:
|
||||
type: string
|
||||
relatedTracesLink:
|
||||
@@ -8392,6 +8572,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Label'
|
||||
nullable: true
|
||||
type: array
|
||||
relatedAITracesLink:
|
||||
type: string
|
||||
relatedLogsLink:
|
||||
type: string
|
||||
relatedTracesLink:
|
||||
@@ -8497,6 +8679,7 @@ components:
|
||||
- TRACES_BASED_ALERT
|
||||
- LOGS_BASED_ALERT
|
||||
- EXCEPTIONS_BASED_ALERT
|
||||
- AI_TRACES_BASED_ALERT
|
||||
type: string
|
||||
RuletypesBasicRuleThreshold:
|
||||
properties:
|
||||
@@ -19720,6 +19903,86 @@ paths:
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/notification_channels:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a page of notification channels for the org. Each entry
|
||||
carries the channel's identity and kind but not its configuration; fetch a
|
||||
channel by ID for that. Supports a case-insensitive display name search (`query`),
|
||||
a kind filter (`kind`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`),
|
||||
and offset-based pagination (`limit`/`offset`).
|
||||
operationId: ListNotificationChannels
|
||||
parameters:
|
||||
- in: query
|
||||
name: query
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: kind
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelKind'
|
||||
- in: query
|
||||
name: sort
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelListSort'
|
||||
- in: query
|
||||
name: order
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelListOrder'
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: offset
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AlertmanagertypesListableNotificationChannel'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- notification-channel:list
|
||||
- tokenizer:
|
||||
- notification-channel:list
|
||||
summary: List notification channels
|
||||
tags:
|
||||
- channels
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a notification channel
|
||||
@@ -19782,6 +20045,239 @@ paths:
|
||||
summary: Create notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/notification_channels/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint deletes a notification channel by ID
|
||||
operationId: DeleteNotificationChannel
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- notification-channel:delete
|
||||
- tokenizer:
|
||||
- notification-channel:delete
|
||||
summary: Delete notification channel
|
||||
tags:
|
||||
- channels
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns a notification channel by ID. A channel written
|
||||
by the v1 API can carry a configuration this API does not model.
|
||||
operationId: GetNotificationChannel
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- notification-channel:read
|
||||
- tokenizer:
|
||||
- notification-channel:read
|
||||
summary: Get notification channel by ID
|
||||
tags:
|
||||
- channels
|
||||
put:
|
||||
deprecated: false
|
||||
description: 'This endpoint replaces a notification channel''s configuration
|
||||
in full. Neither name is part of the request body: both are immutable. The
|
||||
kind may change, which replaces the channel''s notifier configuration.'
|
||||
operationId: UpdateNotificationChannel
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesUpdatableNotificationChannel'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- notification-channel:update
|
||||
- tokenizer:
|
||||
- notification-channel:update
|
||||
summary: Update notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/notification_channels/test:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint sends a test notification for the configuration in
|
||||
the request body. The channel need not exist and nothing is persisted, so
|
||||
the body carries a configuration only.
|
||||
operationId: TestNotificationChannel
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesTestableNotificationChannel'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- notification-channel:create
|
||||
- tokenizer:
|
||||
- notification-channel:create
|
||||
summary: Test notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/orgs/me:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -21,4 +21,5 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
|
||||
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
|
||||
- [Service](service.md) - Managed service lifecycle with `factory.Service`
|
||||
- [SQL](sql.md) - Database and SQL patterns
|
||||
- [SQL Compiler](sqlcompiler.md) - Compiling the list filter DSL to relational-store WHERE clauses
|
||||
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`
|
||||
|
||||
84
docs/contributing/go/sqlcompiler.md
Normal file
84
docs/contributing/go/sqlcompiler.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# SQL Compiler
|
||||
|
||||
List pages (dashboards, alert rules) share one filter DSL in their search bars. [pkg/parser/filterquery/sqlcompiler](/pkg/parser/filterquery/sqlcompiler/compiler.go) compiles a DSL string into a WHERE clause for the relational store: `?`-placeholder SQL plus bind arguments, ready for bun on both SQLite and Postgres. Telemetry filters are a different pipeline. They stay on querybuilder's ClickHouse visitor.
|
||||
|
||||
The package owns everything generic about the language. A module adopting it writes exactly one thing: a `FieldResolver` that says which keys exist and what each maps to.
|
||||
|
||||
## What is the DSL?
|
||||
|
||||
The grammar lives at [grammar/FilterQuery.g4](/grammar/FilterQuery.g4), with the ANTLR-generated parser in [pkg/parser/filterquery/grammar](/pkg/parser/filterquery/grammar). It is the same grammar the telemetry search bars use, so the query language feels identical everywhere. The shapes that matter:
|
||||
|
||||
- Boolean structure: parentheses > `NOT` > `AND` > `OR`; adjacent terms with no connective are an implicit `AND`.
|
||||
- Comparisons: `key OP value`, e.g. `name CONTAINS cpu`, `created_at > '2025-01-01T00:00:00Z'`, `labels.team IN ('infra', 'platform')`, `labels.env EXISTS`. See the `comparison` rule in the grammar for the full operator list.
|
||||
- Free text: a bare or quoted token with no key. Quoting is the escape hatch for a phrase that looks like DSL.
|
||||
- Values: bare tokens or quoted strings; `IN` accepts `in(...)` and `[...]` forms.
|
||||
|
||||
## What does the framework already cover?
|
||||
|
||||
```go
|
||||
compiled, errs := sqlcompiler.Compile(query, formatter, resolver)
|
||||
```
|
||||
|
||||
`Compile` returns either a non-nil `*Compiled` or a list of human-readable errors. An empty query compiles to an empty `Compiled`; callers gate on `IsEmpty()`, not nil. On top of parsing, the package handles:
|
||||
|
||||
- Syntax errors, collected with line/column positions instead of failing on the first one.
|
||||
- The boolean tree: `AND`/`OR`/`NOT`, parentheses, implicit `AND`, and pruning of empty conditions.
|
||||
- Operator extraction, including inversion of `NOT LIKE`, `NOT IN`, `NOT EXISTS` and friends.
|
||||
- Predicate builders the resolver calls back into:
|
||||
|
||||
| Builder | Handles |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `BuildStringOperation` | `=`, `!=`, `LIKE`/`ILIKE`, `CONTAINS`, `IN` on a string column; escapes `%`/`_` for `CONTAINS`, rejects patterns ending in a dangling backslash, lowers both sides for `ILIKE` so SQLite and Postgres agree |
|
||||
| `BuildTimestampComparison` | equality, ranges and `BETWEEN` on RFC3339 timestamps |
|
||||
| `BuildBoolComparison` | `= true/false` |
|
||||
| `BuildFreeTextContains` | case-insensitive substring match, `COALESCE`d so `NOT (...)` does not drop rows where the column is NULL |
|
||||
|
||||
- Typed value extraction (`ExtractSingleStringValue`, `ExtractStringValueList`, ...) with accumulated errors: the user sees every problem in the query at once.
|
||||
- Argument binding through go-sqlbuilder; no value is ever interpolated into the SQL text.
|
||||
|
||||
## When do I write a FieldResolver?
|
||||
|
||||
Whenever a module adopts the DSL for its list page. The resolver is the per-module policy and the only code you write:
|
||||
|
||||
```go
|
||||
type FieldResolver interface {
|
||||
ResolveComparison(v *Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string
|
||||
ResolveFreeText(v *Visitor, value string) string
|
||||
}
|
||||
```
|
||||
|
||||
Rules for implementing one:
|
||||
|
||||
- Declare the key namespace in `pkg/types/<domain>`: `DSLKey` constants plus a `ReservedOps` map of key to allowed operators. The list API advertises these as `reservedKeywords`, so the frontend suggestions never go stale.
|
||||
- Reject a disallowed operator with `v.AddError(...)` and return `""`. Never panic, never fail fast; the compile fails at the end with all errors.
|
||||
- Map a key to a column expression through `v.Formatter` (`JSONExtractString`, `LowerExpression`), never by hand, so the expression is valid on both SQLite and Postgres.
|
||||
- Delegate the predicate to the `Build*` helpers above; do not hand-build SQL or manage arguments yourself.
|
||||
- For a key that lives in a relation table (dashboard tags, rule labels), build an `EXISTS` subquery on a fresh `sqlbuilder.SelectBuilder` and pass that builder into `BuildStringOperation`, so its arguments thread through the compile. For a negative operator, build the positive predicate and toggle `NotExists` on the outer builder.
|
||||
|
||||
The reference implementation is the dashboards resolver, [pkg/modules/dashboard/impldashboard/listfilter_resolver.go](/pkg/modules/dashboard/impldashboard/listfilter_resolver.go): reserved keys backed by columns and JSON paths, tag keys via `EXISTS` subqueries, free text across name, description and tags.
|
||||
|
||||
## How to wire it in?
|
||||
|
||||
Give the module a thin `Compile` wrapper that maps the error list onto the module's error code, as in [pkg/modules/dashboard/impldashboard/listfilter.go](/pkg/modules/dashboard/impldashboard/listfilter.go):
|
||||
|
||||
```go
|
||||
func Compile(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
|
||||
compiled, errs := sqlcompiler.Compile(query, formatter, dashboardFieldResolver{})
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
|
||||
"invalid filter query: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return compiled, nil
|
||||
}
|
||||
```
|
||||
|
||||
The store then appends `compiled.SQL` with `compiled.Args` to its list query when `!compiled.IsEmpty()`.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- One DSL, one compiler; a new list page adds a `FieldResolver`, not a new parser or SQL layer.
|
||||
- Keys and allowed operators live in `pkg/types/<domain>` and are advertised as `reservedKeywords`.
|
||||
- Column expressions go through `v.Formatter`; predicates go through the `Build*` helpers.
|
||||
- Report problems with `v.AddError` and return `""`; errors accumulate.
|
||||
- Relation-table keys use `EXISTS` subqueries on their own builder; negation toggles `NotExists`.
|
||||
- This package is for the relational store only; telemetry filtering stays in querybuilder.
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -33,3 +33,6 @@ e2e/test-plan/user-preferences/
|
||||
# Storybook
|
||||
/storybook-static/
|
||||
debug-storybook.log
|
||||
|
||||
# Storybook screenshot sweeps (scripts/story-shots.mjs)
|
||||
/.story-shots/
|
||||
|
||||
@@ -576,6 +576,17 @@
|
||||
"rules": {
|
||||
"signoz/no-dashboard-fetch-outside-root": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Dev-tooling CLIs: stdout is their output, and they carry ported pixel/heap
|
||||
// algorithms that read worse when split up.
|
||||
"files": [
|
||||
"scripts/**"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"sonarjs/cognitive-complexity": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
10
frontend/.storybook/modes.ts
Normal file
10
frontend/.storybook/modes.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Chromatic modes: one snapshot per entry, per story. The globals in a mode are
|
||||
* Storybook's own, so `theme` is the toolbar's theme and the story renders the
|
||||
* way it does locally. The width matches `scripts/story-shots.mjs` (`--width`),
|
||||
* so a cloud snapshot and a local shot frame the same page.
|
||||
*/
|
||||
export const allModes = {
|
||||
dark: { theme: 'dark', viewport: { width: 1680, height: 1200 } },
|
||||
light: { theme: 'light', viewport: { width: 1680, height: 1200 } },
|
||||
} as const;
|
||||
@@ -1,9 +1,11 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!--
|
||||
`index.html` links Inter from Google Fonts here. The link is parsed before the
|
||||
msw worker has started, so that one request escapes the iframe while every
|
||||
other font request the app makes is answered with an empty stylesheet; the
|
||||
local faces stand in for both.
|
||||
-->
|
||||
<link rel="stylesheet" href="storybook-fonts.css" />
|
||||
|
||||
<link rel="stylesheet" href="css/uPlot.min.css" />
|
||||
|
||||
<script>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Preview } from '@storybook/react-vite';
|
||||
import type { SetupWorker } from 'msw';
|
||||
import { setupWorker } from 'msw';
|
||||
|
||||
import { settleForCapture } from '../src/storybook/visual/settleForCapture';
|
||||
import { withProviders } from '../src/storybook/decorators/withProviders';
|
||||
import { globalMocks } from '../src/storybook/globals';
|
||||
import { resetStoryHistory } from '../src/storybook/navigation/containment';
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
resolveStory,
|
||||
type StoryRuntimeContext,
|
||||
} from '../src/storybook/runtime/resolveStory';
|
||||
import { allModes } from './modes';
|
||||
|
||||
import '../src/ReactI18';
|
||||
|
||||
@@ -65,6 +67,12 @@ const preview: Preview = {
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
controls: { expanded: true },
|
||||
// One cloud snapshot per theme, for every story. A mode carries Storybook
|
||||
// globals, so `theme` here is the same toolbar global the app reads out of
|
||||
// localStorage. Widths are Chromatic's only real dimension, as they are
|
||||
// locally: the app shell sizes itself to the viewport, so the height is the
|
||||
// one it is given.
|
||||
chromatic: { modes: allModes },
|
||||
},
|
||||
globalTypes: {
|
||||
theme: {
|
||||
@@ -79,8 +87,21 @@ const preview: Preview = {
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
motion: {
|
||||
description:
|
||||
'Park every animation on its last frame once the story has settled. Still is what both capture stacks shoot; Live is for watching a transition.',
|
||||
toolbar: {
|
||||
title: 'Motion',
|
||||
icon: 'play',
|
||||
items: [
|
||||
{ value: 'still', title: 'Still' },
|
||||
{ value: 'live', title: 'Live' },
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: { theme: 'dark' },
|
||||
initialGlobals: { theme: 'dark', motion: 'still' },
|
||||
// Controls every story carries: permissions, banners, and whether the page's
|
||||
// own endpoints answer, hang or fail.
|
||||
args: globalMocks.args,
|
||||
@@ -105,6 +126,8 @@ const preview: Preview = {
|
||||
clearBlockedNavigations();
|
||||
resetStoryHistory();
|
||||
},
|
||||
// After `play`, which is the moment both capture stacks shoot at.
|
||||
afterEach: settleForCapture,
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
||||
50
frontend/.storybook/public/storybook-fonts.css
Normal file
50
frontend/.storybook/public/storybook-fonts.css
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* The five families the app pulls from Google Fonts, served from the files in
|
||||
* `public/fonts` instead. `msw/appShellHandlers.ts` answers the CDN with an
|
||||
* empty stylesheet so no request leaves the iframe, which without this left
|
||||
* every story on a fallback for the four families `src/styles.scss` imports at
|
||||
* runtime, and on whatever the Inter link in `index.html` happened to fetch
|
||||
* before the worker had started.
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
/* The comma is part of the filename and the dev server serves it raw, so it
|
||||
stays literal inside the quoted url rather than percent-encoded. */
|
||||
src: url('fonts/Inter-VariableFont_opsz,wght.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Work Sans';
|
||||
src: url('fonts/WorkSans-VariableFont_wght.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Space Mono';
|
||||
src: url('fonts/SpaceMono-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Fira Code';
|
||||
src: url('fonts/FiraCode-VariableFont_wght.ttf') format('truetype');
|
||||
font-weight: 300 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('fonts/GeistMonoVF.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
85
frontend/.storybook/test-runner.ts
Normal file
85
frontend/.storybook/test-runner.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { getStoryContext } from '@storybook/test-runner';
|
||||
import type { TestRunnerConfig } from '@storybook/test-runner';
|
||||
import type { Page } from 'playwright';
|
||||
|
||||
const IGNORED_MESSAGES = [
|
||||
// `preview-head.html` swaps a local stylesheet in for Google Fonts, but the
|
||||
// browser still warns on the real cross-origin one it briefly requests
|
||||
// before msw starts (no CORS headers), regardless of story content.
|
||||
/Can't access cssRules/,
|
||||
// Pre-existing dev-server noise, unrelated to any story.
|
||||
/Couldn't load preload assets/,
|
||||
// Fires because a Jest-driven browser sets a global testing flag React
|
||||
// checks for; unrelated to anything a story does.
|
||||
/current testing environment is not configured to support act/,
|
||||
// React and antd route dev-only warnings (missing keys, DOM nesting, API
|
||||
// deprecations) through `console.error` under this prefix; app-wide and
|
||||
// tracked separately from story regressions.
|
||||
/^Warning: /,
|
||||
// msw's own warning when its response listener count grows across many
|
||||
// story visits in one browser session; not a story defect.
|
||||
/MaxListenersExceededWarning/,
|
||||
// `preview-head.html`'s CSP intentionally blocks third-party iframes
|
||||
// (YouTube embeds, the docs pane) so they hit the real network instead of
|
||||
// an unanswered msw request; the block is the point, not a bug.
|
||||
/violates the following Content Security Policy directive/,
|
||||
];
|
||||
|
||||
const messagesByPage = new WeakMap<Page, string[]>();
|
||||
|
||||
/**
|
||||
* Only `console.error` fails a story. `console.warn` is dev-time advice from
|
||||
* app code (e.g. `aggregateData is null`) and from the runner itself; an
|
||||
* unmocked `/api/` call is a `console.error` in `src/storybook/msw/handlers.ts`.
|
||||
*/
|
||||
const config: TestRunnerConfig = {
|
||||
// msw logs every mocked request at `log`; keep it out of the failure dump
|
||||
// unless the job is re-run with debug logging (GitHub sets RUNNER_DEBUG=1).
|
||||
logLevel: process.env.RUNNER_DEBUG === '1' ? 'info' : 'warn',
|
||||
async preVisit(page): Promise<void> {
|
||||
const existing = messagesByPage.get(page);
|
||||
if (existing) {
|
||||
existing.length = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const messages: string[] = [];
|
||||
messagesByPage.set(page, messages);
|
||||
page.on('console', (message) => {
|
||||
if (
|
||||
message.type() === 'error' &&
|
||||
!IGNORED_MESSAGES.some((pattern) => pattern.test(message.text()))
|
||||
) {
|
||||
messages.push(`[error] ${message.text()}`);
|
||||
}
|
||||
});
|
||||
// The console message alone ("Failed to load resource") doesn't name the
|
||||
// URL; pairing it with the response is what makes a missing mock
|
||||
// actionable instead of just a status code.
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
messages.push(`[response] ${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
async postVisit(page, context): Promise<void> {
|
||||
const messages = messagesByPage.get(page) ?? [];
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A story that deliberately mocks a failure response (e.g. a 500 to test
|
||||
// an error state) logs the error it's testing for; opt it out per-story
|
||||
// with `parameters: { allowConsoleErrors: true }`.
|
||||
const storyContext = await getStoryContext(page, context);
|
||||
if (storyContext.parameters?.allowConsoleErrors) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Story "${context.name}" logged console error/warning:\n${messages.join('\n')}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -34,6 +34,9 @@ These hold for every page. The per-pattern sections below only add to them.
|
||||
4. **Never gate per row.** If a user can `list`, render every row. Check `read` only when the row is opened (drawer or
|
||||
detail route).
|
||||
5. **Gate the narrowest thing that works**, a button over a section, a section over a page.
|
||||
- **Gate a page on `read` alone.** It is the only verb the page's own request needs. `update` and `delete` gate
|
||||
individual controls, so waiting for them holds up the whole page for nothing — pass them as `preloadChecks` and
|
||||
they resolve in the same request, leaving the controls to read from cache.
|
||||
6. **A resource may be gated while a sub-resource is not.** A user without `read` on Service Accounts can still hold
|
||||
`create` on API Keys, so blocking the outer container would hide work they are allowed to do.
|
||||
7. **Verbs not covered here** (`attach`, `detach`, `assignee`) behave like `delete`: gate the control that triggers
|
||||
@@ -45,11 +48,20 @@ These hold for every page. The per-pattern sections below only add to them.
|
||||
|
||||

|
||||
|
||||
Without `list`, but with any of `read` / `create` / `update`, only the table is blocked:
|
||||
Without `list`, but with any of `read` / `create` / `update`, the table and
|
||||
everything that only feeds it are blocked:
|
||||
|
||||
- Title, description, search filters and action buttons stay visible.
|
||||
- Filters and any control that drives the table are non-interactive.
|
||||
- The create button stays enabled if the user holds `create`.
|
||||
- Title, description, search, filters and action buttons stay visible. Nothing is
|
||||
hidden for lack of permission.
|
||||
- Disable what only shapes the blocked request — the search box, the filter chips,
|
||||
a Clear button — and give it the same denial. It edits a query that has nowhere
|
||||
to run, so leaving it live invites the user to compose a filter and watch
|
||||
nothing happen.
|
||||
- Gate a region as one section when several of its controls are dead. A saved
|
||||
views rail is a block with a single callout, not a column of identical
|
||||
tooltips; a row of filters is one tooltip zone, not one per control.
|
||||
- The create button stays enabled if the user holds `create`. It is independent
|
||||
of `list`.
|
||||
|
||||
### Edit page
|
||||
|
||||
@@ -96,6 +108,11 @@ Blocking is always a visible denial, never a silent removal. Use the components
|
||||
[`lib/authz/components`](../src/lib/authz/components/README.md) rather than hand-rolling a check, they carry the
|
||||
denial message and the loading state.
|
||||
|
||||
**Denial copy comes from the components.** Never write a custom message for a permission check. `disabledTooltip` is
|
||||
for a block that is *not* a permission — a lock, an immutable resource, a mount that is deliberately read-only. It
|
||||
takes precedence over the checks, which are then skipped, so set it only when that block is the real obstacle: a
|
||||
missing permission must still surface its own wording.
|
||||
|
||||
| Scope | Component | Denied state |
|
||||
| --------------- | ----------------------------------------- | ------------------------------------------- |
|
||||
| Button | `AuthZButton` | Disabled + tooltip |
|
||||
|
||||
@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
|
||||
transformIgnorePatterns: [
|
||||
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
|
||||
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
|
||||
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev": "vite",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build -o storybook-static",
|
||||
"test:storybook": "bash scripts/test-storybook.sh",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prettify": "oxfmt",
|
||||
@@ -162,6 +163,7 @@
|
||||
"@jest/types": "30.2.0",
|
||||
"@storybook/addon-a11y": "10.5.9",
|
||||
"@storybook/react-vite": "10.5.9",
|
||||
"@storybook/test-runner": "0.24.5",
|
||||
"@testing-library/dom": "8.20.0",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "13.4.0",
|
||||
@@ -233,7 +235,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@babel/core@<=7.29.0": ">=7.29.6 <8",
|
||||
"@istanbuljs/load-nyc-config>js-yaml": ">=4.2.0 <5",
|
||||
"@istanbuljs/load-nyc-config>js-yaml": ">=4.3.1 <5",
|
||||
"cookie@<0.7.0": ">=0.7.1 <1",
|
||||
"dompurify@<=3.4.10": ">=3.4.11 <4",
|
||||
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1 <0.29.0",
|
||||
@@ -242,6 +244,14 @@
|
||||
"prismjs@<1.30.0": ">=1.30.0 <2",
|
||||
"react-router@>=6.7.0 <6.30.4": ">=6.30.4 <7",
|
||||
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2",
|
||||
"brace-expansion@<1.1.18": ">=1.1.18 <2",
|
||||
"brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4 <3",
|
||||
"brace-expansion@>=5.0.0 <5.0.9": ">=5.0.9 <6",
|
||||
"fast-uri@<3.1.5": ">=3.1.5 <4",
|
||||
"immutable@<5.1.8": ">=5.1.8 <6",
|
||||
"js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5",
|
||||
"less@<4.5.0": ">=4.5.0 <5",
|
||||
"nanoid@<3.3.18": ">=3.3.18 <4"
|
||||
}
|
||||
}
|
||||
|
||||
1076
frontend/pnpm-lock.yaml
generated
1076
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -25,11 +25,13 @@
|
||||
"dashboard_has_been_updated": "Dashboard has been updated",
|
||||
"do_you_want_to_refresh_the_dashboard": "Do you want to refresh the dashboard?",
|
||||
"locked_dashboard_delete_tooltip_admin_author": "Dashboard is locked. Please unlock the dashboard to enable delete.",
|
||||
"locked_dashboard_delete_tooltip_editor": "Dashboard is locked. Please contact admin to delete the dashboard.",
|
||||
"delete_dashboard_success": "{{name}} dashboard deleted successfully",
|
||||
"dashboard_unsave_changes": "There are unsaved changes in the Query builder, please stage and run the query or the changes will be lost. Press OK to discard.",
|
||||
"dashboard_save_changes": "Your graph built with {{queryTag}} query will be saved. Press OK to confirm.",
|
||||
"your_graph_build_with": "Your graph built with",
|
||||
"dashboard_ok_confirm": "query will be saved. Press OK to confirm.",
|
||||
"variable_name_already_exists": "Variable \"{{name}}\" already exists"
|
||||
"variable_name_already_exists": "Variable \"{{name}}\" already exists",
|
||||
"dashboard_locked": "This dashboard is locked",
|
||||
"dashboard_read_only_here": "This dashboard is read-only here",
|
||||
"lock_integration_dashboard": "An integration dashboard cannot be locked or unlocked"
|
||||
}
|
||||
|
||||
167
frontend/scripts/story-shots-caption.mjs
Normal file
167
frontend/scripts/story-shots-caption.mjs
Normal file
@@ -0,0 +1,167 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* The caption band both story-shots.mjs and story-shots-diff.mjs stamp on their
|
||||
* output, and the ImageMagick plumbing under it. A shot records the band's
|
||||
* height in `shots.json` so the diff can crop it back off before comparing:
|
||||
* otherwise two runs whose captions differ would report the caption as a change.
|
||||
*/
|
||||
export const CONFIG_KEYS = [
|
||||
'args',
|
||||
'clock',
|
||||
'width',
|
||||
'height',
|
||||
'grow',
|
||||
'motion',
|
||||
'settle',
|
||||
'ignore',
|
||||
];
|
||||
|
||||
let tools;
|
||||
|
||||
const detect = () =>
|
||||
(tools ??= {
|
||||
seven: spawnSync('magick', ['-version']).status === 0,
|
||||
six: spawnSync('convert', ['-version']).status === 0,
|
||||
});
|
||||
|
||||
export const hasMagick = () => {
|
||||
const { seven, six } = detect();
|
||||
return seven || six;
|
||||
};
|
||||
|
||||
export const requireMagick = () => {
|
||||
if (hasMagick()) {
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
'ImageMagick not found. Install it (brew install imagemagick, apt install imagemagick).',
|
||||
);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
export const magick = (args, input) => {
|
||||
// ImageMagick 6 has no `magick`: its tools are separate binaries.
|
||||
const [command, ...rest] = detect().seven
|
||||
? ['magick', ...args]
|
||||
: ['identify', 'montage'].includes(args[0])
|
||||
? args
|
||||
: ['convert', ...args];
|
||||
const result = spawnSync(command, rest, {
|
||||
input,
|
||||
maxBuffer: 1024 * 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${rest.join(' ')}: ${result.stderr}`);
|
||||
}
|
||||
return result.stdout;
|
||||
};
|
||||
|
||||
/**
|
||||
* ImageMagick's built-in default is a serif that reads as a book, not as a
|
||||
* screenshot label, so the band asks for what is installed: a sans for the
|
||||
* heading, a mono for the lines that carry ids, args and numbers. An
|
||||
* unrecognised name is fatal to `convert`, hence the check against the list it
|
||||
* reports; a machine with none of them keeps the default.
|
||||
*/
|
||||
const FONTS = {
|
||||
heading: [
|
||||
'Helvetica-Bold',
|
||||
'DejaVu-Sans-Bold',
|
||||
'Liberation-Sans-Bold',
|
||||
'Arial-Bold',
|
||||
'Noto-Sans-Bold',
|
||||
'DejaVu-Sans',
|
||||
'Liberation-Sans',
|
||||
],
|
||||
body: [
|
||||
'Menlo',
|
||||
'DejaVu-Sans-Mono',
|
||||
'Liberation-Mono',
|
||||
'JetBrainsMono-NF-Regular',
|
||||
'Courier',
|
||||
],
|
||||
};
|
||||
|
||||
let installed;
|
||||
|
||||
const fontArgs = (role) => {
|
||||
installed ??= new Set(
|
||||
[
|
||||
...magick(['-list', 'font'])
|
||||
.toString()
|
||||
.matchAll(/^\s*Font:\s*(\S+)/gm),
|
||||
].map(([, name]) => name),
|
||||
);
|
||||
const font = FONTS[role].find((name) => installed.has(name));
|
||||
return font ? ['-font', font] : [];
|
||||
};
|
||||
|
||||
/** Readable at fit-to-width, whatever the image is. */
|
||||
export const pointsize = (width) =>
|
||||
Math.min(Math.max(Math.round(width / 45), 24), 140);
|
||||
|
||||
// `label:` expands ImageMagick's own escapes and reads a file when the text
|
||||
// starts with @, so story names and arg values go through neither.
|
||||
export const bodyFont = () => fontArgs('body');
|
||||
|
||||
export const literal = (text) => text.replaceAll('%', '%%').replace(/^@/, ' @');
|
||||
|
||||
/** The gutter is the opposite of the theme, so the band keeps an edge. */
|
||||
export const palette = (theme) =>
|
||||
theme === 'light'
|
||||
? { background: '#101014', foreground: '#f4f4f5' }
|
||||
: { background: '#f4f4f5', foreground: '#101014' };
|
||||
|
||||
export const settingsLine = (config, keys = CONFIG_KEYS) =>
|
||||
keys
|
||||
.filter((key) => config?.[key])
|
||||
.map((key) => `${key}:${config[key]}`)
|
||||
.join(' ');
|
||||
|
||||
const heightOf = (file) => Number(magick(['identify', '-format', '%h', file]));
|
||||
|
||||
/**
|
||||
* Writes `from` to `to` with `lines` above it, and returns how many rows that
|
||||
* added — which is what a reader has to crop off to get the original back, so
|
||||
* the band must never change the width. Each line is a `caption:` at the
|
||||
* image's own width, wrapping instead of widening the canvas: a run whose
|
||||
* caption is longer must still produce a shot the next run's shot pairs with.
|
||||
* Type size follows the width, since a three-tile montage of 1680px shots is
|
||||
* over 5000px wide and is read at fit-to-width.
|
||||
*/
|
||||
export const stamp = ({ lines, from, to, theme }) => {
|
||||
const { background, foreground } = palette(theme);
|
||||
const width = Number(magick(['identify', '-format', '%w', from]));
|
||||
const heading = pointsize(width);
|
||||
const before = heightOf(from);
|
||||
const spacer = [
|
||||
'-size',
|
||||
`${width}x${Math.round(heading * 0.4)}`,
|
||||
`xc:${background}`,
|
||||
];
|
||||
|
||||
magick([
|
||||
'-background',
|
||||
background,
|
||||
'-fill',
|
||||
foreground,
|
||||
'-gravity',
|
||||
'center',
|
||||
...spacer,
|
||||
...lines.flatMap((line, index) => [
|
||||
...fontArgs(index ? 'body' : 'heading'),
|
||||
'-size',
|
||||
`${width}x`,
|
||||
'-pointsize',
|
||||
String(index ? Math.round(heading * 0.62) : heading),
|
||||
`caption:${literal(line)}`,
|
||||
]),
|
||||
...spacer,
|
||||
from,
|
||||
'-append',
|
||||
to,
|
||||
]);
|
||||
|
||||
return heightOf(to) - before;
|
||||
};
|
||||
460
frontend/scripts/story-shots-diff.mjs
Normal file
460
frontend/scripts/story-shots-diff.mjs
Normal file
@@ -0,0 +1,460 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { parseArgs } from 'node:util';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import {
|
||||
bodyFont,
|
||||
CONFIG_KEYS,
|
||||
literal,
|
||||
magick,
|
||||
palette,
|
||||
pointsize,
|
||||
requireMagick,
|
||||
settingsLine,
|
||||
stamp,
|
||||
} from './story-shots-caption.mjs';
|
||||
|
||||
/**
|
||||
* Pairs the PNGs of two story-shots.mjs runs by relative path and reports what
|
||||
* moved, per pair, largest first.
|
||||
*
|
||||
* The comparison is Chromatic's: a pixel counts as changed when its YIQ
|
||||
* distance from the baseline pixel is over `threshold` of the largest distance
|
||||
* two colours can have, and pixels that are only antialiasing around an
|
||||
* otherwise identical edge do not count. `threshold` is their `diffThreshold`
|
||||
* and its default is theirs too.
|
||||
*/
|
||||
const MAX_YIQ_DELTA = 35_215;
|
||||
|
||||
const { values: opts, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
mode: { type: 'string', default: 'green' },
|
||||
threshold: { type: 'string', default: '0.063' },
|
||||
'include-aa': { type: 'boolean', default: false },
|
||||
tint: { type: 'string', default: '' },
|
||||
'no-caption': { type: 'boolean', default: false },
|
||||
help: { type: 'boolean', short: 'h', default: false },
|
||||
},
|
||||
});
|
||||
|
||||
const [baseDir, afterDir, outArg] = positionals;
|
||||
const MODES = new Set(['green', 'green-parallel', 'red', 'red-parallel']);
|
||||
|
||||
if (opts.help || !baseDir || !afterDir || !MODES.has(opts.mode)) {
|
||||
console.log(`usage: node scripts/story-shots-diff.mjs <baseline-dir> <after-dir> [diff-dir]
|
||||
|
||||
--mode green the after shot, changed pixels painted over it (default)
|
||||
--mode green-parallel previous | current | green diff, side by side and labelled
|
||||
--mode red the after shot faded out, changed pixels painted red
|
||||
--mode red-parallel previous | current | red diff, side by side and labelled
|
||||
--threshold <0..1> YIQ distance a pixel must move to count (default 0.063)
|
||||
--include-aa count antialiasing changes too (default: ignore them)
|
||||
--tint <#rrggbb> override the mode's highlight colour
|
||||
--no-caption do not stamp the story and the run settings on top
|
||||
|
||||
Prints "<changed pixels> <relative path>", largest first. Needs ImageMagick.`);
|
||||
process.exit(opts.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const outDir = outArg ?? path.join(path.dirname(baseDir), 'diff');
|
||||
const threshold = Number(opts.threshold);
|
||||
const maxDelta = MAX_YIQ_DELTA * threshold * threshold;
|
||||
const highlight = hexToRgb(
|
||||
opts.tint || (opts.mode.startsWith('green') ? '#00e05a' : '#ff003a'),
|
||||
);
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const value = Number.parseInt(hex.replace('#', ''), 16);
|
||||
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
||||
}
|
||||
|
||||
requireMagick();
|
||||
|
||||
/**
|
||||
* `top` rows are dropped: story-shots.mjs stamps a caption on its shots and
|
||||
* records how tall it is, and a caption is not part of what the two runs are
|
||||
* being compared on.
|
||||
*/
|
||||
const readRgba = (file, top = 0) => {
|
||||
const [width, height] = magick(['identify', '-format', '%w %h', file])
|
||||
.toString()
|
||||
.split(' ')
|
||||
.map(Number);
|
||||
const data = magick([file, '-depth', '8', 'RGBA:-']);
|
||||
return top > 0 && top < height
|
||||
? { width, height: height - top, data: data.subarray(top * width * 4) }
|
||||
: { width, height, data };
|
||||
};
|
||||
|
||||
const writeRgba = ({ width, height, data }, file) =>
|
||||
writeFile(
|
||||
file,
|
||||
magick(
|
||||
['-depth', '8', '-size', `${width}x${height}`, 'RGBA:-', 'png:-'],
|
||||
data,
|
||||
),
|
||||
);
|
||||
|
||||
/* The pixelmatch colour maths, which is what Chromatic's threshold is scaled to. */
|
||||
const y = (r, g, b) => r * 0.29889531 + g * 0.58662247 + b * 0.11448223;
|
||||
const i = (r, g, b) => r * 0.59597799 - g * 0.2741761 - b * 0.32180189;
|
||||
const q = (r, g, b) => r * 0.21147017 - g * 0.52261711 + b * 0.31114694;
|
||||
|
||||
/** Squared YIQ distance, signed by which pixel is brighter. */
|
||||
const colorDelta = (a, b, posA, posB, yOnly = false) => {
|
||||
let r1 = a[posA];
|
||||
let g1 = a[posA + 1];
|
||||
let b1 = a[posA + 2];
|
||||
const a1 = a[posA + 3];
|
||||
let r2 = b[posB];
|
||||
let g2 = b[posB + 1];
|
||||
let b2 = b[posB + 2];
|
||||
const a2 = b[posB + 3];
|
||||
|
||||
if (a1 === a2 && r1 === r2 && g1 === g2 && b1 === b2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Anything translucent is composited over the same mid grey in both images,
|
||||
// so a difference in alpha alone still registers.
|
||||
if (a1 < 255) {
|
||||
const alpha = a1 / 255;
|
||||
r1 = r1 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
g1 = g1 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
b1 = b1 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
}
|
||||
if (a2 < 255) {
|
||||
const alpha = a2 / 255;
|
||||
r2 = r2 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
g2 = g2 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
b2 = b2 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
}
|
||||
|
||||
const deltaY = y(r1, g1, b1) - y(r2, g2, b2);
|
||||
if (yOnly) {
|
||||
return deltaY;
|
||||
}
|
||||
|
||||
const deltaI = i(r1, g1, b1) - i(r2, g2, b2);
|
||||
const deltaQ = q(r1, g1, b1) - q(r2, g2, b2);
|
||||
return (
|
||||
0.5053 * deltaY * deltaY + 0.299 * deltaI * deltaI + 0.1957 * deltaQ * deltaQ
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* True when the pixel sits on an edge that is drawn one subpixel over rather
|
||||
* than moved: it is the darkest or lightest of its neighbours in one image, and
|
||||
* the other image has a pixel around there doing the same job.
|
||||
*/
|
||||
const antialiased = (a, x1, y1, width, height, b) => {
|
||||
const x0 = Math.max(x1 - 1, 0);
|
||||
const y0 = Math.max(y1 - 1, 0);
|
||||
const x2 = Math.min(x1 + 1, width - 1);
|
||||
const y2 = Math.min(y1 + 1, height - 1);
|
||||
const pos = (y1 * width + x1) * 4;
|
||||
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
|
||||
let min = 0;
|
||||
let max = 0;
|
||||
let minX = 0;
|
||||
let minY = 0;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
|
||||
for (let x = x0; x <= x2; x += 1) {
|
||||
for (let yy = y0; yy <= y2; yy += 1) {
|
||||
if (x === x1 && yy === y1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = colorDelta(a, a, pos, (yy * width + x) * 4, true);
|
||||
if (delta === 0) {
|
||||
zeroes += 1;
|
||||
if (zeroes > 2) {
|
||||
return false;
|
||||
}
|
||||
} else if (delta < min) {
|
||||
min = delta;
|
||||
minX = x;
|
||||
minY = yy;
|
||||
} else if (delta > max) {
|
||||
max = delta;
|
||||
maxX = x;
|
||||
maxY = yy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (min === 0 || max === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(hasManySiblings(a, minX, minY, width, height) &&
|
||||
hasManySiblings(b, minX, minY, width, height)) ||
|
||||
(hasManySiblings(a, maxX, maxY, width, height) &&
|
||||
hasManySiblings(b, maxX, maxY, width, height))
|
||||
);
|
||||
};
|
||||
|
||||
/** Whether the pixel has at least three identical neighbours. */
|
||||
const hasManySiblings = (img, x1, y1, width, height) => {
|
||||
const x0 = Math.max(x1 - 1, 0);
|
||||
const y0 = Math.max(y1 - 1, 0);
|
||||
const x2 = Math.min(x1 + 1, width - 1);
|
||||
const y2 = Math.min(y1 + 1, height - 1);
|
||||
const pos = (y1 * width + x1) * 4;
|
||||
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
|
||||
|
||||
for (let x = x0; x <= x2; x += 1) {
|
||||
for (let yy = y0; yy <= y2; yy += 1) {
|
||||
if (x === x1 && yy === y1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const other = (yy * width + x) * 4;
|
||||
if (
|
||||
img[pos] === img[other] &&
|
||||
img[pos + 1] === img[other + 1] &&
|
||||
img[pos + 2] === img[other + 2] &&
|
||||
img[pos + 3] === img[other + 3]
|
||||
) {
|
||||
zeroes += 1;
|
||||
if (zeroes > 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* The changed pixels of the pair, painted over the after shot. The `red` modes
|
||||
* fade the shot out first, the way a pixelmatch diff reads; the `green` ones
|
||||
* leave it alone, the way Chromatic's does.
|
||||
*/
|
||||
const diffPair = (base, after, mode) => {
|
||||
const width = Math.min(base.width, after.width);
|
||||
const height = Math.min(base.height, after.height);
|
||||
const out = Buffer.from(after.data);
|
||||
const fade = !mode.startsWith('green');
|
||||
let changed = 0;
|
||||
|
||||
if (fade) {
|
||||
for (let pos = 0; pos < out.length; pos += 4) {
|
||||
const grey = y(out[pos], out[pos + 1], out[pos + 2]);
|
||||
const value = 255 + (grey - 255) * 0.1;
|
||||
out[pos] = value;
|
||||
out[pos + 1] = value;
|
||||
out[pos + 2] = value;
|
||||
out[pos + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
for (let column = 0; column < width; column += 1) {
|
||||
const basePos = (row * base.width + column) * 4;
|
||||
const afterPos = (row * after.width + column) * 4;
|
||||
const delta = colorDelta(base.data, after.data, basePos, afterPos);
|
||||
if (Math.abs(delta) <= maxDelta) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!opts['include-aa'] &&
|
||||
(antialiased(base.data, column, row, base.width, base.height, after.data) ||
|
||||
antialiased(after.data, column, row, after.width, after.height, base.data))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
changed += 1;
|
||||
out[afterPos] = highlight[0];
|
||||
out[afterPos + 1] = highlight[1];
|
||||
out[afterPos + 2] = highlight[2];
|
||||
out[afterPos + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// A shot that grew or shrank has no counterpart for the extra rows and
|
||||
// columns, so all of them are a change.
|
||||
const extra =
|
||||
after.width * after.height -
|
||||
width * height +
|
||||
(base.width * base.height - width * height);
|
||||
|
||||
return {
|
||||
data: out,
|
||||
width: after.width,
|
||||
height: after.height,
|
||||
changed: changed + extra,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* What each run was and how it was configured, from the `shots.json`
|
||||
* story-shots.mjs leaves beside its output. A run shot before that existed, or
|
||||
* a directory assembled by hand, simply gets no caption.
|
||||
*/
|
||||
const manifest = async (dir) => {
|
||||
try {
|
||||
return JSON.parse(await readFile(path.join(dir, 'shots.json'), 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const [baseRun, afterRun] = await Promise.all([
|
||||
manifest(baseDir),
|
||||
manifest(afterDir),
|
||||
]);
|
||||
|
||||
/** The settings the two runs disagree on: what a difference in the shots may be. */
|
||||
const changedKeys = CONFIG_KEYS.filter(
|
||||
(key) => (baseRun?.config?.[key] ?? '') !== (afterRun?.config?.[key] ?? ''),
|
||||
);
|
||||
|
||||
const settings = (run, keys) => settingsLine(run?.config, keys);
|
||||
|
||||
const shotOf = (run, rel) =>
|
||||
run?.shots?.find((shot) => shot.file === rel.split(path.sep).join('/'));
|
||||
|
||||
const captionOf = (run, rel) => shotOf(run, rel)?.caption ?? 0;
|
||||
|
||||
/** ImageMagick's inline crop, so a tile shows the shot without its caption. */
|
||||
const withoutCaption = (file, { width, height }, top) =>
|
||||
top > 0 ? `${file}[${width}x${height}+0+${top}]` : file;
|
||||
|
||||
/** Story, then the settings both runs shared. One line each, widest font first. */
|
||||
const header = (rel) => {
|
||||
const shot = shotOf(afterRun, rel) ?? shotOf(baseRun, rel);
|
||||
const shared = settings(
|
||||
afterRun,
|
||||
CONFIG_KEYS.filter((key) => !changedKeys.includes(key)),
|
||||
);
|
||||
return [
|
||||
shot ? `${shot.title}/${shot.name}` : rel.replace(/\.png$/, ''),
|
||||
[shot?.id ?? '', shot?.theme ?? '', shot?.status === 'busy' ? '(busy)' : '']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
shared,
|
||||
].filter(Boolean);
|
||||
};
|
||||
|
||||
/** A tile's own line: which side it is, and where its run differed. */
|
||||
const sideLabel = (side, run) =>
|
||||
[side, settings(run, changedKeys)].filter(Boolean).join(' ');
|
||||
|
||||
const captionLines = (rel, lines) =>
|
||||
opts['no-caption'] ? [] : [...header(rel), ...lines].filter(Boolean);
|
||||
|
||||
const pngs = async (dir, prefix = '') => {
|
||||
const entries = await readdir(path.join(dir, prefix), { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const rel = path.join(prefix, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await pngs(dir, rel)));
|
||||
} else if (entry.name.endsWith('.png')) {
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const results = [];
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
for (const rel of (await pngs(baseDir)).sort()) {
|
||||
const afterFile = path.join(afterDir, rel);
|
||||
const base = readRgba(path.join(baseDir, rel), captionOf(baseRun, rel));
|
||||
let after;
|
||||
try {
|
||||
after = readRgba(afterFile, captionOf(afterRun, rel));
|
||||
} catch {
|
||||
console.error(`missing in after: ${rel}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await mkdir(path.join(outDir, path.dirname(rel)), { recursive: true });
|
||||
const diff = diffPair(base, after, opts.mode);
|
||||
const target = path.join(outDir, rel);
|
||||
const parallel = opts.mode.endsWith('-parallel');
|
||||
// With no tiles to label, a run's own settings go in the caption instead.
|
||||
const caption = captionLines(
|
||||
rel,
|
||||
parallel || !changedKeys.length
|
||||
? []
|
||||
: [sideLabel('previous', baseRun), sideLabel('current', afterRun)],
|
||||
);
|
||||
const diffFile = path.join(os.tmpdir(), `story-shots-${process.pid}.png`);
|
||||
const body = path.join(os.tmpdir(), `story-shots-${process.pid}-body.png`);
|
||||
|
||||
// The gutter is the opposite of the theme's own background, so the tiles and
|
||||
// the caption keep an edge instead of bleeding into it.
|
||||
const shot = shotOf(afterRun, rel) ?? shotOf(baseRun, rel);
|
||||
const theme = shot?.theme ?? rel.split(path.sep)[0];
|
||||
const { background, foreground } = palette(theme);
|
||||
|
||||
if (parallel) {
|
||||
await writeRgba(diff, diffFile);
|
||||
const tile = (label, file) => [
|
||||
'(',
|
||||
`label:${literal(label)}`,
|
||||
file,
|
||||
'-gravity',
|
||||
'center',
|
||||
'-append',
|
||||
'-bordercolor',
|
||||
background,
|
||||
'-border',
|
||||
'12',
|
||||
')',
|
||||
];
|
||||
magick([
|
||||
'-background',
|
||||
background,
|
||||
'-fill',
|
||||
foreground,
|
||||
...bodyFont(),
|
||||
'-pointsize',
|
||||
// The tiles end up side by side, so they are read at the montage's width.
|
||||
String(Math.round(pointsize(after.width * 3) * 0.62)),
|
||||
...tile(
|
||||
sideLabel('previous', baseRun),
|
||||
withoutCaption(path.join(baseDir, rel), base, captionOf(baseRun, rel)),
|
||||
),
|
||||
...tile(
|
||||
sideLabel('current', afterRun),
|
||||
withoutCaption(afterFile, after, captionOf(afterRun, rel)),
|
||||
),
|
||||
...tile('diff', diffFile),
|
||||
'-gravity',
|
||||
'north',
|
||||
'+append',
|
||||
caption.length ? body : target,
|
||||
]);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: body, to: target, theme });
|
||||
}
|
||||
} else {
|
||||
await writeRgba(diff, caption.length ? body : target);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: body, to: target, theme });
|
||||
}
|
||||
}
|
||||
|
||||
results.push([diff.changed, rel]);
|
||||
}
|
||||
|
||||
results
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.forEach(([changed, rel]) =>
|
||||
console.log(`${String(changed).padStart(10)} ${rel}`),
|
||||
);
|
||||
|
||||
console.error(`diffs in ${outDir}`);
|
||||
514
frontend/scripts/story-shots.mjs
Executable file
514
frontend/scripts/story-shots.mjs
Executable file
@@ -0,0 +1,514 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import {
|
||||
CONFIG_KEYS,
|
||||
hasMagick,
|
||||
settingsLine,
|
||||
stamp,
|
||||
} from './story-shots-caption.mjs';
|
||||
|
||||
/**
|
||||
* The wall clock every shot is taken at, passed to the preview as `storyClock`.
|
||||
* `.storybook/preview-head.html` freezes the same instant by itself, so a
|
||||
* Chromatic build reads the clock this run does.
|
||||
*/
|
||||
const FROZEN_CLOCK = '2026-06-15T12:00:00.000Z';
|
||||
|
||||
const { values: opts, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
out: { type: 'string', short: 'o' },
|
||||
stories: { type: 'string', multiple: true, default: [] },
|
||||
title: { type: 'string', default: '' },
|
||||
name: { type: 'string', default: '' },
|
||||
theme: { type: 'string', multiple: true, default: [] },
|
||||
args: { type: 'string', multiple: true, default: [] },
|
||||
port: { type: 'string', default: process.env.SB_PORT ?? '6006' },
|
||||
width: { type: 'string', default: '1680' },
|
||||
height: { type: 'string', default: '1200' },
|
||||
'max-height': { type: 'string', default: '8000' },
|
||||
grow: { type: 'string', default: 'scrollers' },
|
||||
settle: { type: 'string', default: '1500' },
|
||||
clock: { type: 'string', default: FROZEN_CLOCK },
|
||||
motion: { type: 'boolean', default: false },
|
||||
ignore: { type: 'string', multiple: true, default: [] },
|
||||
flat: { type: 'boolean', default: false },
|
||||
'no-caption': { type: 'boolean', default: false },
|
||||
list: { type: 'boolean', default: false },
|
||||
help: { type: 'boolean', short: 'h', default: false },
|
||||
},
|
||||
});
|
||||
|
||||
const outDir = opts.out ?? positionals[0];
|
||||
const themes = opts.theme.flatMap((value) => value.split(',')).filter(Boolean);
|
||||
const storyArgs = opts.args.filter(Boolean).join(';');
|
||||
|
||||
if (opts.help || (!outDir && !opts.list)) {
|
||||
console.log(`usage: node scripts/story-shots.mjs <out-dir> [options]
|
||||
|
||||
--stories <match> only stories whose id or title/name path contains <match>
|
||||
(repeatable, comma-separated; default: every story)
|
||||
--title <prefix> only stories whose title starts with <prefix>
|
||||
--name <match> only stories whose name contains <match>
|
||||
--theme <themes> themes to shoot, e.g. dark,light (default: story default)
|
||||
--args <k:v;k2:v2> arg overrides, storybook's own ?args= syntax (repeatable).
|
||||
A value containing a dot is dropped by storybook itself
|
||||
--port <port> storybook dev server port (default 6006, or $SB_PORT)
|
||||
--width <px> viewport width, the only fixed dimension (default 1680)
|
||||
--height <px> shortest the viewport may be (default 1200)
|
||||
--max-height <px> tallest the viewport may grow to (default 8000)
|
||||
--grow <what> scrollers (default) grows the viewport until the page's
|
||||
own scrollers fit, document only follows the document
|
||||
height (a no-op on any page with the app shell), none
|
||||
keeps --height
|
||||
--settle <ms> wait after the page goes quiet (default 1500)
|
||||
--clock <iso|live> wall clock the page reads (default ${FROZEN_CLOCK})
|
||||
--motion keep animations and transitions running
|
||||
--ignore <selector> hide matching elements, on top of [data-shot-ignore]
|
||||
--flat write <out>/<id>.png instead of <out>/<theme>/<id>.png
|
||||
--no-caption do not stamp the story and the run settings on the shot
|
||||
--list print the matched stories and exit
|
||||
|
||||
Screenshots land in <out-dir>/<theme>/<story-id>.png, alongside a shots.json
|
||||
recording what each shot is, how the run was configured, and how tall the
|
||||
caption on it is. story-shots-diff.mjs reads that to crop the caption off before
|
||||
comparing, so two runs never diff their own captions.
|
||||
|
||||
Captioning needs ImageMagick; without it the shots are written bare.
|
||||
|
||||
Playwright is looked up in tests/e2e, then in the global install; override with
|
||||
PLAYWRIGHT_MODULE. The browser is playwright's own chromium, else an installed
|
||||
Chrome; override with CHROME_PATH.`);
|
||||
process.exit(opts.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const base = `http://localhost:${opts.port}`;
|
||||
|
||||
// `index.json` carries raw control characters from story jsdoc, so it is read as
|
||||
// text rather than piped through anything that revalidates it.
|
||||
const index = JSON.parse(await (await fetch(`${base}/index.json`)).text());
|
||||
|
||||
const matches = opts.stories
|
||||
.flatMap((value) => value.split(','))
|
||||
.filter(Boolean);
|
||||
|
||||
const stories = Object.values(index.entries)
|
||||
.filter((entry) => {
|
||||
if (entry.type !== 'story') {
|
||||
return false;
|
||||
}
|
||||
if (opts.title && !entry.title.startsWith(opts.title)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
opts.name &&
|
||||
!entry.name.toLowerCase().includes(opts.name.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!matches.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = `${entry.id} ${entry.title}/${entry.name}`.toLowerCase();
|
||||
return matches.some((match) => haystack.includes(match.toLowerCase()));
|
||||
})
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
if (opts.list) {
|
||||
stories.forEach((story) =>
|
||||
console.log(`${story.id}\t${story.title}/${story.name}`),
|
||||
);
|
||||
console.log(`${stories.length} stories`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!stories.length) {
|
||||
console.error('no story matched');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ignoreSelectors = opts.ignore
|
||||
.flatMap((value) => value.split(','))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (opts.clock !== 'live' && Number.isNaN(Date.parse(opts.clock))) {
|
||||
console.error(`--clock: not a date: ${opts.clock}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* `[data-shot-ignore]` and `--ignore` hide what cannot be settled, the local
|
||||
* half of Chromatic's `data-chromatic="ignore"`. Everything else the shot needs
|
||||
* held still - the frozen clock, the parked animations, the lists snapped onto
|
||||
* their bottom - is done by the preview itself, so a Chromatic build and a shot
|
||||
* from here see the same page.
|
||||
*/
|
||||
const ignoreCss = (
|
||||
ignore,
|
||||
) => `[data-shot-ignore], [data-chromatic='ignore']${ignore
|
||||
.map((selector) => `, ${selector}`)
|
||||
.join('')} {
|
||||
visibility: hidden !important;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Playwright is not a frontend dependency: it lives in `tests/e2e`, or globally,
|
||||
* or wherever `$PLAYWRIGHT_MODULE` points. `@playwright/test` re-exports
|
||||
* `chromium`, so an e2e install alone is enough.
|
||||
*/
|
||||
const resolvePlaywright = () => {
|
||||
const specifiers = process.env.PLAYWRIGHT_MODULE
|
||||
? [process.env.PLAYWRIGHT_MODULE]
|
||||
: ['playwright', '@playwright/test'];
|
||||
|
||||
const find = (roots) => {
|
||||
for (const specifier of specifiers) {
|
||||
for (const root of roots) {
|
||||
try {
|
||||
return createRequire(path.join(root, '-')).resolve(specifier);
|
||||
} catch {
|
||||
/* next candidate */
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const local = find([
|
||||
import.meta.dirname,
|
||||
path.resolve(import.meta.dirname, '../../tests/e2e'),
|
||||
]);
|
||||
if (local) {
|
||||
return local;
|
||||
}
|
||||
|
||||
// `npm root -g` prints the global node_modules; resolution starts a level up.
|
||||
const globalRoot = spawnSync('npm', ['root', '-g'], { encoding: 'utf8' });
|
||||
const global =
|
||||
globalRoot.status === 0 && find([path.dirname(globalRoot.stdout.trim())]);
|
||||
if (global) {
|
||||
return global;
|
||||
}
|
||||
|
||||
console.error(
|
||||
'playwright not found. Install it (pnpm -C tests/e2e install, or npm i -g playwright) or set PLAYWRIGHT_MODULE.',
|
||||
);
|
||||
return process.exit(1);
|
||||
};
|
||||
|
||||
const pwModule = await import(pathToFileURL(resolvePlaywright()).href);
|
||||
const pw = pwModule.chromium ? pwModule : pwModule.default;
|
||||
|
||||
console.log(
|
||||
`${stories.length} stories x ${themes.length || 1} theme(s) -> ${outDir}`,
|
||||
);
|
||||
|
||||
/**
|
||||
* A playwright install carries no browser of its own, and the revision it wants
|
||||
* is often not the one that was downloaded, so an installed Chrome is the
|
||||
* fallback before giving up.
|
||||
*/
|
||||
const launch = async () => {
|
||||
if (process.env.CHROME_PATH) {
|
||||
return pw.chromium.launch({ executablePath: process.env.CHROME_PATH });
|
||||
}
|
||||
try {
|
||||
return await pw.chromium.launch();
|
||||
} catch (error) {
|
||||
try {
|
||||
return await pw.chromium.launch({ channel: 'chrome' });
|
||||
} catch {
|
||||
console.error(
|
||||
`${error.message.split('\n')[0]}\nRun 'playwright install chromium' or set CHROME_PATH to a browser binary.`,
|
||||
);
|
||||
return process.exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const browser = await launch();
|
||||
|
||||
const failures = [];
|
||||
const shots = [];
|
||||
|
||||
const runConfig = {
|
||||
args: storyArgs,
|
||||
clock: opts.clock,
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
grow: opts.grow,
|
||||
motion: opts.motion ? 'live' : 'still',
|
||||
settle: opts.settle,
|
||||
ignore: ignoreSelectors.join(', '),
|
||||
};
|
||||
|
||||
const captioning = !opts['no-caption'] && hasMagick();
|
||||
|
||||
if (!opts['no-caption'] && !captioning) {
|
||||
console.error('ImageMagick not found: shots are written without a caption.');
|
||||
}
|
||||
|
||||
const configLine = settingsLine(runConfig, CONFIG_KEYS);
|
||||
|
||||
for (const theme of themes.length ? themes : [null]) {
|
||||
const dir = opts.flat ? outDir : path.join(outDir, theme ?? 'default');
|
||||
await mkdir(dir, { recursive: true });
|
||||
if (theme) {
|
||||
console.log(`\n[${theme}]`);
|
||||
}
|
||||
|
||||
for (const story of stories) {
|
||||
// A context per story: reusing one page loses the msw worker
|
||||
// re-registration race after a few navigations and the story then dies on
|
||||
// a missing worker.
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: Number(opts.width), height: Number(opts.height) },
|
||||
reducedMotion: opts.motion ? 'no-preference' : 'reduce',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// react-query retries and msw both keep requests going long after load, so
|
||||
// the settle waits on the page being quiet rather than on a fixed delay.
|
||||
let inFlight = 0;
|
||||
let lastActivity = Date.now();
|
||||
page.on('request', () => {
|
||||
inFlight += 1;
|
||||
lastActivity = Date.now();
|
||||
});
|
||||
const done = () => {
|
||||
inFlight = Math.max(inFlight - 1, 0);
|
||||
lastActivity = Date.now();
|
||||
};
|
||||
page.on('requestfinished', done);
|
||||
page.on('requestfailed', done);
|
||||
|
||||
// The height the rounds had reached when the page turned out to grow with
|
||||
// the viewport, kept only to flag the story in the log.
|
||||
let chasing = 0;
|
||||
|
||||
const url = new URL(`${base}/iframe.html`);
|
||||
url.searchParams.set('viewMode', 'story');
|
||||
url.searchParams.set('id', story.id);
|
||||
// The preview owns the clock and the motion state, so both are asked for in
|
||||
// the URL rather than injected here: a Chromatic build gets the defaults.
|
||||
url.searchParams.set('storyClock', opts.clock);
|
||||
const globals = [theme && `theme:${theme}`, opts.motion && 'motion:live']
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
if (globals) {
|
||||
url.searchParams.set('globals', globals);
|
||||
}
|
||||
if (storyArgs) {
|
||||
url.searchParams.set('args', storyArgs);
|
||||
}
|
||||
|
||||
try {
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Storybook's own render phase is the readiness signal: it reaches
|
||||
// `finished` only once the loaders, the decorators and the story's `play`
|
||||
// are all done, which a DOM check cannot see. The dev server transforms
|
||||
// each page module on first visit, so this is the slow wait.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window.__STORYBOOK_PREVIEW__?.storyRenders ?? []).some((render) =>
|
||||
['finished', 'errored', 'aborted'].includes(render.phase),
|
||||
) || document.body.classList.contains('sb-show-errordisplay'),
|
||||
undefined,
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
|
||||
await page.addStyleTag({ content: ignoreCss(ignoreSelectors) });
|
||||
if (!opts.motion) {
|
||||
// Videos and GIFs are parked on their first frame, as Chromatic does.
|
||||
await page.evaluate(() =>
|
||||
document.querySelectorAll('video').forEach((video) => video.pause?.()),
|
||||
);
|
||||
}
|
||||
|
||||
// Text reflows when a webfont lands, so the shot waits for the faces the
|
||||
// page asked for. Some stories keep a request open by design, hence the
|
||||
// cap on the quiet wait rather than a plain networkidle.
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const quietUntil = Date.now() + 15_000;
|
||||
while (
|
||||
Date.now() < quietUntil &&
|
||||
(inFlight > 0 || Date.now() - lastActivity < 600)
|
||||
) {
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
|
||||
// The width is the fixed dimension and the height follows the page, the
|
||||
// way a Chromatic viewport does. `src/styles.scss` pins
|
||||
// `html, body, #root` to `height: 100%; overflow: hidden`, so the
|
||||
// document can never outgrow the viewport and its height says nothing
|
||||
// about what is on the page: what overflows are the shell's inner
|
||||
// scrollers. `scrollers` grows the viewport until the tallest of those
|
||||
// fits, so nothing is cut off and no scrollbar is left in the shot.
|
||||
// Growing changes the layout, hence the rounds. A page that sizes a panel
|
||||
// in `vh` grows its own content as the viewport grows, so no height ever
|
||||
// fits it and the rounds only chase: `.alert-chart-container` is `57vh`,
|
||||
// which puts Create Alert's fixed point at 4344px with an empty band on
|
||||
// top. Such a page is shot at `--height` with its own scrollbar instead,
|
||||
// which is what it looks like in a browser.
|
||||
if (opts.grow !== 'none') {
|
||||
const maximum = Number(opts['max-height']);
|
||||
const requested = Number(opts.height);
|
||||
let height = requested;
|
||||
let fits = false;
|
||||
for (let round = 0; round < 3 && !fits; round += 1) {
|
||||
const needed = Math.min(
|
||||
maximum,
|
||||
await page.evaluate((withScrollers) => {
|
||||
const document_ = Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight,
|
||||
);
|
||||
if (!withScrollers) {
|
||||
return document_;
|
||||
}
|
||||
|
||||
// Popups are skipped: they are out of the flow, and a tall
|
||||
// dropdown or tooltip would otherwise drag the shot to a
|
||||
// height nothing on the page itself needs.
|
||||
const inFlow = (element) => {
|
||||
for (
|
||||
let node = element;
|
||||
node && node !== document.documentElement;
|
||||
node = node.parentElement
|
||||
) {
|
||||
const { position } = getComputedStyle(node);
|
||||
if (position === 'fixed' || position === 'absolute') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return [...document.querySelectorAll('*')].reduce((tallest, element) => {
|
||||
const { overflowY } = getComputedStyle(element);
|
||||
if (
|
||||
!['auto', 'scroll', 'overlay'].includes(overflowY) ||
|
||||
element.scrollHeight - element.clientHeight <= 1 ||
|
||||
!inFlow(element)
|
||||
) {
|
||||
return tallest;
|
||||
}
|
||||
|
||||
const box = element.getBoundingClientRect();
|
||||
const above = box.top + window.scrollY;
|
||||
const below = Math.max(0, document_ - (box.bottom + window.scrollY));
|
||||
return Math.max(tallest, above + element.scrollHeight + below);
|
||||
}, document_);
|
||||
}, opts.grow === 'scrollers'),
|
||||
);
|
||||
fits = needed <= height;
|
||||
if (fits) {
|
||||
break;
|
||||
}
|
||||
|
||||
height = needed;
|
||||
await page.setViewportSize({ width: Number(opts.width), height });
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
}
|
||||
|
||||
if (!fits && height !== requested) {
|
||||
chasing = height;
|
||||
height = requested;
|
||||
await page.setViewportSize({ width: Number(opts.width), height });
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
}
|
||||
}
|
||||
|
||||
// The preview snapped its bottom-pinned lists at `afterEach`, before the
|
||||
// page went quiet; a virtuoso list is usually still measuring then.
|
||||
await page.evaluate(() => window.__signozSnapPinnedScrollers?.());
|
||||
|
||||
// A page that is still moving — a list scrolling itself to the bottom, a
|
||||
// monaco editor re-measuring, a tooltip being repositioned — is shot
|
||||
// twice in a row until two frames come back identical, since what the
|
||||
// page is waiting on is not observable from here.
|
||||
let shot = await page.screenshot();
|
||||
let stable = false;
|
||||
for (let attempt = 0; attempt < 8 && !stable; attempt += 1) {
|
||||
await page.waitForTimeout(400);
|
||||
const next = await page.screenshot();
|
||||
stable = next.equals(shot);
|
||||
shot = next;
|
||||
}
|
||||
|
||||
const file = path.join(dir, `${story.id}.png`);
|
||||
await writeFile(file, shot);
|
||||
|
||||
// The band goes on the shot itself so a single screenshot says what it
|
||||
// is, and its height is recorded so a diff can take it back off.
|
||||
let caption = 0;
|
||||
if (captioning) {
|
||||
const temporary = path.join(
|
||||
os.tmpdir(),
|
||||
`story-shots-caption-${process.pid}.png`,
|
||||
);
|
||||
caption = stamp({
|
||||
lines: [
|
||||
`${story.title}/${story.name}`,
|
||||
[story.id, theme ?? 'default', stable ? '' : '(busy)']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
configLine,
|
||||
].filter(Boolean),
|
||||
from: file,
|
||||
to: temporary,
|
||||
theme: theme ?? 'dark',
|
||||
});
|
||||
await rename(temporary, file);
|
||||
}
|
||||
|
||||
shots.push({
|
||||
file: path.posix.join(
|
||||
opts.flat ? '' : (theme ?? 'default'),
|
||||
`${story.id}.png`,
|
||||
),
|
||||
id: story.id,
|
||||
title: story.title,
|
||||
name: story.name,
|
||||
theme: theme ?? 'default',
|
||||
status: stable ? 'ok' : 'busy',
|
||||
caption,
|
||||
});
|
||||
console.log(
|
||||
` ${stable ? 'ok ' : 'busy'} ${story.id}${
|
||||
chasing ? ` (viewport-sized content, stopped chasing ${chasing}px)` : ''
|
||||
}`,
|
||||
);
|
||||
} catch (error) {
|
||||
failures.push(`${theme ?? 'default'}/${story.id}`);
|
||||
console.log(` FAIL ${story.id}: ${error.message.split('\n')[0]}`);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// The diff script captions its output from this, so the run's own settings sit
|
||||
// next to the shots they produced rather than only in the shell history.
|
||||
await writeFile(
|
||||
path.join(outDir, 'shots.json'),
|
||||
`${JSON.stringify({ config: runConfig, shots }, null, '\t')}\n`,
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failed: ${failures.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
31
frontend/scripts/test-storybook.sh
Executable file
31
frontend/scripts/test-storybook.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
story_count=$(find src -name '*.stories.tsx' | wc -l)
|
||||
if [ "$story_count" -eq 0 ]; then
|
||||
echo "No *.stories.tsx found under src/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# jest splits the sorted story files into contiguous shards and exits 1 when a
|
||||
# shard is empty, which happens on every shard above the file count. The runner
|
||||
# rejects jest's own `--passWithNoTests`, so skip those shards here.
|
||||
for arg in "$@"; do
|
||||
if [[ $arg == --shard=* ]]; then
|
||||
shard_index=${arg#--shard=}
|
||||
if [ "${shard_index%%/*}" -gt "$story_count" ]; then
|
||||
echo "Skipping ${arg#--shard=}: only ${story_count} story files"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
pnpm storybook --ci --quiet &
|
||||
SB_PID=$!
|
||||
trap 'kill "$SB_PID" 2>/dev/null || true' EXIT
|
||||
|
||||
until curl -sf http://127.0.0.1:6006/index.json >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
pnpm exec test-storybook --ci --maxWorkers=2 "$@"
|
||||
@@ -1558,6 +1558,18 @@ describe('PrivateRoute', () => {
|
||||
keyof typeof routeWithInitialAuthZSupport,
|
||||
AuthzRouteCase
|
||||
> = {
|
||||
ALL_DASHBOARD: { path: ROUTES.ALL_DASHBOARD, deniedRoles: DENIED_ROLES },
|
||||
DASHBOARD: {
|
||||
path: ROUTES.DASHBOARD.replace(':dashboardId', 'dashboard-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
DASHBOARD_PANEL_EDITOR: {
|
||||
path: ROUTES.DASHBOARD_PANEL_EDITOR.replace(
|
||||
':dashboardId',
|
||||
'dashboard-id-1',
|
||||
).replace(':panelId', 'panel-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
// Everything under /settings resolves to the non-exact SETTINGS route
|
||||
SETTINGS: { path: ROUTES.SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
MY_SETTINGS: { path: ROUTES.MY_SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { AxiosError, AxiosResponse, isCancel } from 'axios';
|
||||
import { ErrorResponse } from 'types/api';
|
||||
import { ErrorStatusCode } from 'types/common';
|
||||
|
||||
@@ -42,6 +42,16 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
|
||||
};
|
||||
}
|
||||
if (request) {
|
||||
// Avoid logging error when the request was just cancelled for whatever reason
|
||||
if (isCancel(error)) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
// client never received a response, or request never left
|
||||
console.error('client never received a response, or request never left');
|
||||
|
||||
|
||||
@@ -21,14 +21,23 @@ import type {
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
AlertmanagertypesPostableNotificationChannelDTO,
|
||||
AlertmanagertypesReceiverDTO,
|
||||
AlertmanagertypesTestableNotificationChannelDTO,
|
||||
AlertmanagertypesUpdatableNotificationChannelDTO,
|
||||
CreateChannel201,
|
||||
CreateNotificationChannel201,
|
||||
DeleteChannelByIDPathParameters,
|
||||
DeleteNotificationChannelPathParameters,
|
||||
GetChannelByID200,
|
||||
GetChannelByIDPathParameters,
|
||||
GetNotificationChannel200,
|
||||
GetNotificationChannelPathParameters,
|
||||
ListChannels200,
|
||||
ListNotificationChannels200,
|
||||
ListNotificationChannelsParams,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateChannelByIDPathParameters,
|
||||
UpdateNotificationChannel200,
|
||||
UpdateNotificationChannelPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -649,6 +658,105 @@ export const useTestChannelDeprecated = <
|
||||
> => {
|
||||
return useMutation(getTestChannelDeprecatedMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a page of notification channels for the org. Each entry carries the channel's identity and kind but not its configuration; fetch a channel by ID for that. Supports a case-insensitive display name search (`query`), a kind filter (`kind`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
|
||||
* @summary List notification channels
|
||||
*/
|
||||
export const listNotificationChannels = (
|
||||
params?: ListNotificationChannelsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListNotificationChannels200>({
|
||||
url: `/api/v2/notification_channels`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListNotificationChannelsQueryKey = (
|
||||
params?: ListNotificationChannelsParams,
|
||||
) => {
|
||||
return [`/api/v2/notification_channels`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListNotificationChannelsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListNotificationChannelsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListNotificationChannelsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>
|
||||
> = ({ signal }) => listNotificationChannels(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListNotificationChannelsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>
|
||||
>;
|
||||
export type ListNotificationChannelsQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List notification channels
|
||||
*/
|
||||
|
||||
export function useListNotificationChannels<
|
||||
TData = Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListNotificationChannelsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListNotificationChannelsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List notification channels
|
||||
*/
|
||||
export const invalidateListNotificationChannels = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListNotificationChannelsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListNotificationChannelsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a notification channel
|
||||
* @summary Create notification channel
|
||||
@@ -733,3 +841,370 @@ export const useCreateNotificationChannel = <
|
||||
> => {
|
||||
return useMutation(getCreateNotificationChannelMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint deletes a notification channel by ID
|
||||
* @summary Delete notification channel
|
||||
*/
|
||||
export const deleteNotificationChannel = (
|
||||
{ id }: DeleteNotificationChannelPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/notification_channels/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteNotificationChannelMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
TError,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
TError,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteNotificationChannel'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteNotificationChannel(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteNotificationChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>
|
||||
>;
|
||||
|
||||
export type DeleteNotificationChannelMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete notification channel
|
||||
*/
|
||||
export const useDeleteNotificationChannel = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
TError,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
TError,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteNotificationChannelMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns a notification channel by ID. A channel written by the v1 API can carry a configuration this API does not model.
|
||||
* @summary Get notification channel by ID
|
||||
*/
|
||||
export const getNotificationChannel = (
|
||||
{ id }: GetNotificationChannelPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetNotificationChannel200>({
|
||||
url: `/api/v2/notification_channels/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetNotificationChannelQueryKey = ({
|
||||
id,
|
||||
}: GetNotificationChannelPathParameters) => {
|
||||
return [`/api/v2/notification_channels/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetNotificationChannelQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetNotificationChannelPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetNotificationChannelQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>
|
||||
> = ({ signal }) => getNotificationChannel({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetNotificationChannelQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>
|
||||
>;
|
||||
export type GetNotificationChannelQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get notification channel by ID
|
||||
*/
|
||||
|
||||
export function useGetNotificationChannel<
|
||||
TData = Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetNotificationChannelPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetNotificationChannelQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get notification channel by ID
|
||||
*/
|
||||
export const invalidateGetNotificationChannel = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetNotificationChannelPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetNotificationChannelQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint replaces a notification channel's configuration in full. Neither name is part of the request body: both are immutable. The kind may change, which replaces the channel's notifier configuration.
|
||||
* @summary Update notification channel
|
||||
*/
|
||||
export const updateNotificationChannel = (
|
||||
{ id }: UpdateNotificationChannelPathParameters,
|
||||
alertmanagertypesUpdatableNotificationChannelDTO?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateNotificationChannel200>({
|
||||
url: `/api/v2/notification_channels/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: alertmanagertypesUpdatableNotificationChannelDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateNotificationChannelMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateNotificationChannel'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateNotificationChannel(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateNotificationChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>
|
||||
>;
|
||||
export type UpdateNotificationChannelMutationBody =
|
||||
| BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>
|
||||
| undefined;
|
||||
export type UpdateNotificationChannelMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update notification channel
|
||||
*/
|
||||
export const useUpdateNotificationChannel = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateNotificationChannelMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint sends a test notification for the configuration in the request body. The channel need not exist and nothing is persisted, so the body carries a configuration only.
|
||||
* @summary Test notification channel
|
||||
*/
|
||||
export const testNotificationChannel = (
|
||||
alertmanagertypesTestableNotificationChannelDTO?: BodyType<AlertmanagertypesTestableNotificationChannelDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/notification_channels/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: alertmanagertypesTestableNotificationChannelDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestNotificationChannelMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testNotificationChannel'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testNotificationChannel(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestNotificationChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>
|
||||
>;
|
||||
export type TestNotificationChannelMutationBody =
|
||||
| BodyType<AlertmanagertypesTestableNotificationChannelDTO>
|
||||
| undefined;
|
||||
export type TestNotificationChannelMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Test notification channel
|
||||
*/
|
||||
export const useTestNotificationChannel = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestNotificationChannelMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -507,6 +507,15 @@ export enum AlertmanagertypesChannelKindDTO {
|
||||
jsmops = 'jsmops',
|
||||
incidentio = 'incidentio',
|
||||
}
|
||||
export enum AlertmanagertypesChannelListOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export enum AlertmanagertypesChannelListSortDTO {
|
||||
updated_at = 'updated_at',
|
||||
created_at = 'created_at',
|
||||
name = 'name',
|
||||
}
|
||||
export interface ModelLabelSetDTO {
|
||||
[key: string]: string;
|
||||
}
|
||||
@@ -1000,6 +1009,44 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesListedNotificationChannelDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
kind: AlertmanagertypesChannelKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesListableNotificationChannelDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
channels: AlertmanagertypesListedNotificationChannelDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesMaintenanceKindDTO {
|
||||
fixed = 'fixed',
|
||||
recurring = 'recurring',
|
||||
@@ -2391,6 +2438,14 @@ export interface AlertmanagertypesReceiverDTO {
|
||||
wechat_configs?: ConfigWechatConfigDTO[];
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesTestableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesUpdatableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesAttributeMappingDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4079,6 +4134,53 @@ export interface Querybuildertypesv5LogAggregationDTO {
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketOptionsLinearDTOKind {
|
||||
linear = 'linear',
|
||||
}
|
||||
export interface Querybuildertypesv5LinearBucketsSpecDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
maxValue: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
numBuckets?: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketOptionsLinearDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum linear
|
||||
*/
|
||||
kind: Querybuildertypesv5BucketOptionsLinearDTOKind;
|
||||
spec: Querybuildertypesv5LinearBucketsSpecDTO;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketOptionsLogDTOKind {
|
||||
log = 'log',
|
||||
}
|
||||
export interface Querybuildertypesv5LogBucketsSpecDTO {
|
||||
/**
|
||||
* @type integer,null
|
||||
*/
|
||||
scale?: number | null;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketOptionsLogDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum log
|
||||
*/
|
||||
kind: Querybuildertypesv5BucketOptionsLogDTOKind;
|
||||
spec: Querybuildertypesv5LogBucketsSpecDTO;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5BucketOptionsDTO =
|
||||
| Querybuildertypesv5BucketOptionsLinearDTO
|
||||
| Querybuildertypesv5BucketOptionsLogDTO;
|
||||
|
||||
export interface Querybuildertypesv5FilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4272,6 +4374,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5LogAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4399,6 +4502,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5MetricAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4474,6 +4578,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4914,6 +5019,57 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesListPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind {
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
}
|
||||
export interface DashboardtypesHeaderOptionsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hide?: boolean;
|
||||
}
|
||||
|
||||
export enum DashboardtypesTextModeDTO {
|
||||
markdown = 'markdown',
|
||||
}
|
||||
export enum DashboardtypesTextAlignDTO {
|
||||
left = 'left',
|
||||
center = 'center',
|
||||
right = 'right',
|
||||
}
|
||||
export enum DashboardtypesVerticalAlignDTO {
|
||||
top = 'top',
|
||||
center = 'center',
|
||||
bottom = 'bottom',
|
||||
}
|
||||
export interface DashboardtypesTextPresentationDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
background?: string | null;
|
||||
textAlign?: DashboardtypesTextAlignDTO;
|
||||
verticalAlign?: DashboardtypesVerticalAlignDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesTextPanelSpecDTO {
|
||||
headerOptions?: DashboardtypesHeaderOptionsDTO;
|
||||
mode?: DashboardtypesTextModeDTO;
|
||||
presentation?: DashboardtypesTextPresentationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/TextPanel
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind;
|
||||
spec: DashboardtypesTextPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -4921,7 +5077,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -4929,6 +5086,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
|
||||
raw = 'raw',
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
heatmap = 'heatmap',
|
||||
}
|
||||
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind {
|
||||
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
|
||||
@@ -4975,6 +5133,7 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -5843,6 +6002,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TablePanel' = 'signoz/TablePanel',
|
||||
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
@@ -8542,16 +8702,7 @@ export interface Querybuildertypesv5LabelDTO {
|
||||
value?: Querybuildertypesv5LabelDTOValue;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5TimeSeriesValueDTO {
|
||||
bucket?: Querybuildertypesv5BucketDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -9102,12 +9253,16 @@ export interface PromotetypesPromotePathDTO {
|
||||
promote?: boolean;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5AggregationBucketDTOMeta = {
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
buckets?: number[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
/**
|
||||
@@ -9126,10 +9281,7 @@ export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
* @type array
|
||||
*/
|
||||
lowerBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
meta?: Querybuildertypesv5AggregationBucketDTOMeta;
|
||||
meta?: Querybuildertypesv5AggregationMetaDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -9144,6 +9296,10 @@ export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
upperBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketsKindDTO {
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
}
|
||||
export type Querybuildertypesv5ColumnDescriptorDTOMeta = {
|
||||
/**
|
||||
* @type string
|
||||
@@ -9610,6 +9766,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
|
||||
* @type boolean
|
||||
*/
|
||||
overallStateChanged: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedAITracesLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9658,6 +9818,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryContributorDTO {
|
||||
* @type array,null
|
||||
*/
|
||||
labels: Querybuildertypesv5LabelDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedAITracesLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9753,6 +9917,7 @@ export enum RuletypesAlertTypeDTO {
|
||||
TRACES_BASED_ALERT = 'TRACES_BASED_ALERT',
|
||||
LOGS_BASED_ALERT = 'LOGS_BASED_ALERT',
|
||||
EXCEPTIONS_BASED_ALERT = 'EXCEPTIONS_BASED_ALERT',
|
||||
AI_TRACES_BASED_ALERT = 'AI_TRACES_BASED_ALERT',
|
||||
}
|
||||
export enum RuletypesMatchTypeDTO {
|
||||
at_least_once = 'at_least_once',
|
||||
@@ -13162,6 +13327,44 @@ export type GetMetricsTreemap200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListNotificationChannelsParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
query?: string;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
kind?: AlertmanagertypesChannelKindDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
sort?: AlertmanagertypesChannelListSortDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
order?: AlertmanagertypesChannelListOrderDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ListNotificationChannels200 = {
|
||||
data: AlertmanagertypesListableNotificationChannelDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateNotificationChannel201 = {
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
/**
|
||||
@@ -13170,6 +13373,31 @@ export type CreateNotificationChannel201 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteNotificationChannelPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetNotificationChannelPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetNotificationChannel200 = {
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateNotificationChannelPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateNotificationChannel200 = {
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMyOrganization200 = {
|
||||
data: TypesOrganizationDTO;
|
||||
/**
|
||||
|
||||
45
frontend/src/components/MarkdownEditor/EditorStatusBar.tsx
Normal file
45
frontend/src/components/MarkdownEditor/EditorStatusBar.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { CursorPosition } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
interface EditorStatusBarProps {
|
||||
cursor: CursorPosition;
|
||||
length: number;
|
||||
maxLength: number;
|
||||
hint?: ReactNode;
|
||||
}
|
||||
|
||||
function EditorStatusBar({
|
||||
cursor,
|
||||
length,
|
||||
maxLength,
|
||||
hint,
|
||||
}: EditorStatusBarProps): JSX.Element {
|
||||
const isOverLimit = length > maxLength;
|
||||
|
||||
return (
|
||||
<div className={styles.statusBar} data-testid="markdown-editor-status">
|
||||
<Typography.Text className={styles.statusPosition}>
|
||||
{`Ln ${cursor.line}, Col ${cursor.column}`}
|
||||
<span className={styles.statusSeparator}>·</span>
|
||||
<span
|
||||
className={cx(styles.statusCount, {
|
||||
[styles.statusCountOverLimit]: isOverLimit,
|
||||
})}
|
||||
data-testid="markdown-editor-char-count"
|
||||
>
|
||||
{isOverLimit ? `${length} / ${maxLength} chars` : `${length} chars`}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
{hint && (
|
||||
<Typography.Text className={styles.statusHint}>{hint}</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorStatusBar;
|
||||
93
frontend/src/components/MarkdownEditor/EditorToolbar.tsx
Normal file
93
frontend/src/components/MarkdownEditor/EditorToolbar.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Bold,
|
||||
CodeXml,
|
||||
Heading,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
ListOrdered,
|
||||
Table,
|
||||
Type,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import InsertVariableMenu from './InsertVariableMenu';
|
||||
import MarkdownHelp from './MarkdownHelp';
|
||||
import type { EditorCommand, EditorVariable } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
const COMMAND_ICONS: Record<string, ReactNode> = {
|
||||
heading: <Heading size={14} />,
|
||||
bold: <Bold size={14} />,
|
||||
italic: <Italic size={14} />,
|
||||
'bulleted-list': <List size={14} />,
|
||||
'numbered-list': <ListOrdered size={14} />,
|
||||
link: <Link size={14} />,
|
||||
code: <CodeXml size={14} />,
|
||||
table: <Table size={14} />,
|
||||
};
|
||||
|
||||
interface EditorToolbarProps {
|
||||
formatLabel: string;
|
||||
commands: EditorCommand[];
|
||||
onRunCommand: (command: EditorCommand) => void;
|
||||
variables: EditorVariable[];
|
||||
onInsertVariable: (name: string) => void;
|
||||
disabled: boolean;
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
function EditorToolbar({
|
||||
formatLabel,
|
||||
commands,
|
||||
onRunCommand,
|
||||
variables,
|
||||
onInsertVariable,
|
||||
disabled,
|
||||
extra,
|
||||
}: EditorToolbarProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.toolbar} data-testid="markdown-editor-toolbar">
|
||||
<span className={styles.formatChip}>
|
||||
<Type size={14} />
|
||||
<Typography.Text className={styles.formatLabel}>
|
||||
{formatLabel}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span className={styles.toolbarDivider} />
|
||||
<div className={styles.commands}>
|
||||
{commands.map((command) => (
|
||||
<TooltipSimple key={command.id} title={command.label}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={command.label}
|
||||
data-testid={`markdown-command-${command.id}`}
|
||||
onClick={(): void => onRunCommand(command)}
|
||||
>
|
||||
{COMMAND_ICONS[command.id]}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.toolbarEnd}>
|
||||
{extra}
|
||||
<InsertVariableMenu
|
||||
variables={variables}
|
||||
onSelect={onInsertVariable}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<MarkdownHelp />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorToolbar;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, DollarSign, Search } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { EditorVariable } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
interface InsertVariableMenuProps {
|
||||
variables: EditorVariable[];
|
||||
/** Receives the variable name; the caller decides the token syntax. */
|
||||
onSelect: (name: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function toMenuItems(
|
||||
variables: EditorVariable[],
|
||||
onSelect: (name: string) => void,
|
||||
): MenuItem[] {
|
||||
return variables.map((variable) => ({
|
||||
key: variable.name,
|
||||
label: (
|
||||
<span
|
||||
className={styles.variableRow}
|
||||
data-testid={`markdown-variable-${variable.name}`}
|
||||
>
|
||||
<span className={styles.variableName}>{`$${variable.name}`}</span>
|
||||
{variable.badge && (
|
||||
<span className={styles.variableBadge}>{variable.badge}</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: (): void => onSelect(variable.name),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Searchable variable picker; hidden entirely when there is nothing to insert. */
|
||||
function InsertVariableMenu({
|
||||
variables,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: InsertVariableMenuProps): JSX.Element | null {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return query
|
||||
? variables.filter((variable) => variable.name.toLowerCase().includes(query))
|
||||
: variables;
|
||||
}, [variables, search]);
|
||||
|
||||
const items = useMemo(
|
||||
() => toMenuItems(matches, onSelect),
|
||||
[matches, onSelect],
|
||||
);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple
|
||||
className={styles.variableMenu}
|
||||
menu={{
|
||||
items,
|
||||
search: {
|
||||
placeholder: 'Search variables',
|
||||
searchIcon: <Search size={14} />,
|
||||
onSearchChange: setSearch,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
prefix={<DollarSign size={14} className={styles.insertVariableIcon} />}
|
||||
suffix={<ChevronDown size={14} />}
|
||||
className={styles.insertVariable}
|
||||
data-testid="markdown-insert-variable"
|
||||
>
|
||||
Insert variable
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default InsertVariableMenu;
|
||||
@@ -0,0 +1,253 @@
|
||||
@use '../../styles/scrollbar' as *;
|
||||
|
||||
.container {
|
||||
// Read by the decoration theme in `markdownHighlight`, which can't see SCSS.
|
||||
--md-syntax-heading: var(--text-vanilla-100);
|
||||
--md-syntax-strong: var(--text-vanilla-100);
|
||||
--md-syntax-emphasis: var(--text-vanilla-300);
|
||||
--md-syntax-quote: var(--text-vanilla-400);
|
||||
--md-syntax-marker: var(--text-robin-300);
|
||||
--md-syntax-code: var(--text-forest-400);
|
||||
--md-syntax-link: var(--text-robin-400);
|
||||
--md-syntax-variable: var(--text-amber-400);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
:global(body.lightMode) .container {
|
||||
--md-syntax-heading: var(--text-ink-400);
|
||||
--md-syntax-strong: var(--text-ink-400);
|
||||
--md-syntax-emphasis: var(--text-ink-200);
|
||||
--md-syntax-quote: var(--text-neutral-light-100);
|
||||
--md-syntax-marker: var(--text-robin-500);
|
||||
--md-syntax-code: var(--text-forest-700);
|
||||
--md-syntax-link: var(--text-robin-500);
|
||||
--md-syntax-variable: var(--text-sienna-500);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.formatChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
color: var(--text-sienna-400);
|
||||
}
|
||||
|
||||
.formatLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.toolbarDivider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.commands {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbarEnd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.insertVariable {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.insertVariableIcon {
|
||||
color: var(--text-amber-400);
|
||||
}
|
||||
|
||||
// The ui library's dropdown assumes a global border-box reset this app doesn't
|
||||
// have (`box-sizing` is set on `body` only and doesn't inherit): its items are
|
||||
// `width: 100%` + padding, so in the portal they lay out content-box and
|
||||
// overflow the popup by the padding — clipping the flush-right badge.
|
||||
.variableMenu,
|
||||
.variableMenu * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.variableMenu {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
// Shrinkable, so a clamped popup truncates the name instead of clipping the
|
||||
// badge at the content's `overflow: hidden` edge.
|
||||
.variableRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.variableName {
|
||||
font-family: var(--font-family-sf-mono);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.variableBadge {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-amber-400) 40%, transparent);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-amber-400);
|
||||
}
|
||||
|
||||
.editorArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codeMirror {
|
||||
height: 100%;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 13px;
|
||||
|
||||
:global(.cm-editor) {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.cm-editor.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(.cm-gutters) {
|
||||
background: transparent;
|
||||
border-right: none;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
:global(.cm-scroller) {
|
||||
line-height: 20px;
|
||||
padding: 0 12px;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
:global(.cm-content) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.statusPosition {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.statusSeparator {
|
||||
color: var(--l1-border);
|
||||
}
|
||||
|
||||
.statusCount {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.statusCountOverLimit {
|
||||
color: var(--text-cherry-400);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statusHint {
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.helpContent {
|
||||
width: 280px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.helpTitle {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.helpList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.helpRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
dt {
|
||||
margin: 0;
|
||||
|
||||
code {
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-forest-400);
|
||||
}
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
}
|
||||
|
||||
// The help popover portals out of `.container`, so it can't inherit its tokens.
|
||||
:global(body.lightMode) .helpRow dt code {
|
||||
color: var(--text-forest-700);
|
||||
}
|
||||
245
frontend/src/components/MarkdownEditor/MarkdownEditor.tsx
Normal file
245
frontend/src/components/MarkdownEditor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { copilot } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, {
|
||||
type BasicSetupOptions,
|
||||
EditorView,
|
||||
type ViewUpdate,
|
||||
} from '@uiw/react-codemirror';
|
||||
import cx from 'classnames';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { formatVariableToken, MARKDOWN_MAX_LENGTH } from './constants';
|
||||
import EditorStatusBar from './EditorStatusBar';
|
||||
import EditorToolbar from './EditorToolbar';
|
||||
import { applyTransform, replaceDocument } from './editorDocument';
|
||||
import { insertText, MARKDOWN_COMMANDS } from './markdownCommands';
|
||||
import { markdownHighlight } from './markdownHighlight';
|
||||
import type {
|
||||
CursorPosition,
|
||||
EditorCommand,
|
||||
EditorTransform,
|
||||
EditorVariable,
|
||||
} from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
/** What the status bar reports. */
|
||||
type DocumentStatus = CursorPosition & { length: number };
|
||||
|
||||
// No language grammar is loaded, so bracket/indent/completion behaviour would only
|
||||
// get in the way of prose. `indentWithTab` stays off so Tab keeps moving focus.
|
||||
const BASIC_SETUP: BasicSetupOptions = {
|
||||
lineNumbers: true,
|
||||
highlightActiveLine: true,
|
||||
highlightActiveLineGutter: true,
|
||||
foldGutter: false,
|
||||
autocompletion: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
indentOnInput: false,
|
||||
syntaxHighlighting: false,
|
||||
highlightSelectionMatches: false,
|
||||
rectangularSelection: false,
|
||||
crosshairCursor: false,
|
||||
searchKeymap: false,
|
||||
foldKeymap: false,
|
||||
lintKeymap: false,
|
||||
completionKeymap: false,
|
||||
closeBracketsKeymap: false,
|
||||
};
|
||||
|
||||
const EMPTY_VARIABLES: EditorVariable[] = [];
|
||||
|
||||
export interface MarkdownEditorProps {
|
||||
/** Seeds the document; replaced only from outside. See the sync effect. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Offered by the "Insert variable" menu; the button disables when empty. */
|
||||
variables?: EditorVariable[];
|
||||
/** What the character counter reports against. */
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
/** Shown on the toolbar chip. */
|
||||
formatLabel?: string;
|
||||
/** Rendered before the "Insert variable" menu. */
|
||||
toolbarExtra?: ReactNode;
|
||||
/** Right-hand status-bar note, e.g. "Preview updates as you type". */
|
||||
statusHint?: ReactNode;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source editor for Markdown bodies. Source-only: it neither parses nor renders
|
||||
* the body, so the preview surface and its sanitisation stay the caller's concern.
|
||||
*/
|
||||
function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
variables = EMPTY_VARIABLES,
|
||||
maxLength = MARKDOWN_MAX_LENGTH,
|
||||
placeholder = 'Write Markdown…',
|
||||
readOnly = false,
|
||||
formatLabel = 'Markdown',
|
||||
toolbarExtra,
|
||||
statusHint,
|
||||
autoFocus = false,
|
||||
className,
|
||||
testId = 'markdown-editor',
|
||||
}: MarkdownEditorProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
// Set while a programmatic replacement is in flight, so the caller isn't told
|
||||
// about a change it asked for. `dispatch` runs listeners synchronously, so the
|
||||
// window is exactly one call.
|
||||
const isSyncingRef = useRef(false);
|
||||
const previousValueRef = useRef(value);
|
||||
const hasSeededRef = useRef(false);
|
||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||
const [status, setStatus] = useState<DocumentStatus>(() => ({
|
||||
line: 1,
|
||||
column: 1,
|
||||
length: value.length,
|
||||
}));
|
||||
|
||||
const syncDocument = useCallback((view: EditorView, next: string): void => {
|
||||
isSyncingRef.current = true;
|
||||
replaceDocument(view, next);
|
||||
isSyncingRef.current = false;
|
||||
}, []);
|
||||
|
||||
const onCreateEditor = useCallback((view: EditorView): void => {
|
||||
viewRef.current = view;
|
||||
setIsEditorReady(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Seeds the document, then applies external replacements — nothing else. Keeping
|
||||
* keystrokes out of this round-trip is what stops a stale `value` from replacing
|
||||
* the document and resetting the caret when typing outpaces React.
|
||||
*
|
||||
* The seed can't go in `onCreateEditor`: the wrapper defaults its own `value` to
|
||||
* `''` and reconciles against it once the view exists, wiping anything written
|
||||
* before that. `isEditorReady` puts this effect after that pass, since a parent's
|
||||
* effects flush after its children's.
|
||||
*
|
||||
* Focus marks ownership: a replacement arriving mid-typing is dropped rather than
|
||||
* applied over the author.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = previousValueRef.current;
|
||||
previousValueRef.current = value;
|
||||
const isSeeding = !hasSeededRef.current;
|
||||
hasSeededRef.current = true;
|
||||
|
||||
if (!isSeeding && (value === previous || view.hasFocus)) {
|
||||
return;
|
||||
}
|
||||
if (view.state.doc.toString() !== value) {
|
||||
syncDocument(view, value);
|
||||
}
|
||||
}, [value, isEditorReady, syncDocument]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(next: string): void => {
|
||||
if (!isSyncingRef.current) {
|
||||
onChange(next);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const runTransform = useCallback((transform: EditorTransform): void => {
|
||||
const view = viewRef.current;
|
||||
if (view) {
|
||||
applyTransform(view, transform);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onRunCommand = useCallback(
|
||||
(command: EditorCommand): void => runTransform(command.run),
|
||||
[runTransform],
|
||||
);
|
||||
|
||||
const onInsertVariable = useCallback(
|
||||
(name: string): void =>
|
||||
runTransform((snapshot) => insertText(snapshot, formatVariableToken(name))),
|
||||
[runTransform],
|
||||
);
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [markdownHighlight(), EditorView.lineWrapping],
|
||||
[],
|
||||
);
|
||||
|
||||
// From the document, not `value`: the caller may debounce or drop a change, and
|
||||
// the counter has to match what the author sees.
|
||||
const onUpdate = useCallback((update: ViewUpdate): void => {
|
||||
if (!update.selectionSet && !update.docChanged) {
|
||||
return;
|
||||
}
|
||||
const { head } = update.state.selection.main;
|
||||
const line = update.state.doc.lineAt(head);
|
||||
setStatus({
|
||||
line: line.number,
|
||||
column: head - line.from + 1,
|
||||
length: update.state.doc.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container, className)} data-testid={testId}>
|
||||
<EditorToolbar
|
||||
formatLabel={formatLabel}
|
||||
commands={MARKDOWN_COMMANDS}
|
||||
onRunCommand={onRunCommand}
|
||||
variables={variables}
|
||||
onInsertVariable={onInsertVariable}
|
||||
disabled={readOnly}
|
||||
extra={toolbarExtra}
|
||||
/>
|
||||
<div className={styles.editorArea}>
|
||||
<CodeMirror
|
||||
className={styles.codeMirror}
|
||||
// No `value`: passing it re-enables the wrapper's own reconciliation,
|
||||
// and with it the caret reset.
|
||||
onCreateEditor={onCreateEditor}
|
||||
onChange={handleChange}
|
||||
onUpdate={onUpdate}
|
||||
theme={isDarkMode ? copilot : githubLight}
|
||||
basicSetup={BASIC_SETUP}
|
||||
placeholder={placeholder}
|
||||
editable={!readOnly}
|
||||
readOnly={readOnly}
|
||||
indentWithTab={false}
|
||||
autoFocus={autoFocus}
|
||||
extensions={extensions}
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
<EditorStatusBar
|
||||
cursor={status}
|
||||
length={status.length}
|
||||
maxLength={maxLength}
|
||||
hint={statusHint}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownEditor;
|
||||
44
frontend/src/components/MarkdownEditor/MarkdownHelp.tsx
Normal file
44
frontend/src/components/MarkdownEditor/MarkdownHelp.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { MARKDOWN_HELP_ITEMS } from './constants';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
function MarkdownHelp(): JSX.Element {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Markdown syntax help"
|
||||
data-testid="markdown-help-trigger"
|
||||
>
|
||||
<CircleHelp size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className={styles.helpContent}>
|
||||
<Typography.Text className={styles.helpTitle}>
|
||||
Markdown syntax
|
||||
</Typography.Text>
|
||||
<dl className={styles.helpList}>
|
||||
{MARKDOWN_HELP_ITEMS.map((item) => (
|
||||
<div key={item.syntax} className={styles.helpRow}>
|
||||
<dt>
|
||||
<code>{item.syntax}</code>
|
||||
</dt>
|
||||
<dd>{item.label}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownHelp;
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
import { mockCodeMirrorDomApis } from 'components/QueryBuilderV2/QueryV2/__tests__/codemirrorDomMocks';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import MarkdownEditor from '../MarkdownEditor';
|
||||
import type { EditorVariable } from '../types';
|
||||
|
||||
beforeAll(() => {
|
||||
mockCodeMirrorDomApis();
|
||||
});
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => true,
|
||||
}));
|
||||
|
||||
const VARIABLES: EditorVariable[] = [
|
||||
{ name: 'environment', badge: 'QUERY' },
|
||||
{ name: 'service', badge: 'CUSTOM' },
|
||||
];
|
||||
|
||||
/** A caller whose state trails the editor by one keystroke. */
|
||||
function LaggingHarness(): JSX.Element {
|
||||
const [value, setValue] = useState('');
|
||||
const previousRef = useRef('');
|
||||
const onChange = useCallback((next: string): void => {
|
||||
setValue(previousRef.current);
|
||||
previousRef.current = next;
|
||||
}, []);
|
||||
|
||||
return <MarkdownEditor value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
/** Pushes a replacement in from outside the editor. */
|
||||
function ExternalHarness(): JSX.Element {
|
||||
const [value, setValue] = useState('before');
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={(): void => setValue('after')}>
|
||||
push
|
||||
</button>
|
||||
<MarkdownEditor value={value} onChange={setValue} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Harness({
|
||||
initialValue = '',
|
||||
maxLength,
|
||||
variables = VARIABLES,
|
||||
}: {
|
||||
initialValue?: string;
|
||||
maxLength?: number;
|
||||
variables?: EditorVariable[];
|
||||
}): JSX.Element {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
return (
|
||||
<MarkdownEditor
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
variables={variables}
|
||||
maxLength={maxLength}
|
||||
statusHint="Preview updates as you type"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getView = (): EditorView => {
|
||||
const dom = document.querySelector('.cm-editor');
|
||||
const view = dom ? EditorView.findFromDOM(dom as HTMLElement) : null;
|
||||
if (!view) {
|
||||
throw new Error('editor view not mounted');
|
||||
}
|
||||
return view;
|
||||
};
|
||||
|
||||
const select = (from: number, to: number): void => {
|
||||
act(() => {
|
||||
getView().dispatch({ selection: { anchor: from, head: to } });
|
||||
});
|
||||
};
|
||||
|
||||
const documentText = (): string => getView().state.doc.toString();
|
||||
|
||||
describe('MarkdownEditor', () => {
|
||||
it('reports the caret position and character count', async () => {
|
||||
render(<Harness initialValue={'one\ntwo'} />);
|
||||
|
||||
select(5, 5);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-status')).toHaveTextContent(
|
||||
'Ln 2, Col 2',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'7 chars',
|
||||
);
|
||||
});
|
||||
|
||||
it('flags a body over the character cap', async () => {
|
||||
render(<Harness initialValue="123456" maxLength={5} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'6 / 5 chars',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies a toolbar command to the selection', async () => {
|
||||
render(<Harness initialValue="a word b" />);
|
||||
|
||||
select(2, 6);
|
||||
await userEvent.click(screen.getByTestId('markdown-command-bold'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(documentText()).toBe('a **word** b');
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts a variable token at the caret', async () => {
|
||||
render(<Harness initialValue="env: " />);
|
||||
|
||||
select(5, 5);
|
||||
await userEvent.click(screen.getByTestId('markdown-insert-variable'));
|
||||
|
||||
// The row shows the name and kind badge.
|
||||
const row = await screen.findByTestId('markdown-variable-environment');
|
||||
expect(row).toHaveTextContent('$environment');
|
||||
expect(row).toHaveTextContent('QUERY');
|
||||
|
||||
// fireEvent: userEvent's pointer-down path walks DOM selection APIs the
|
||||
// CodeMirror mocks stub out.
|
||||
fireEvent.click(row);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(documentText()).toBe('env: $environment');
|
||||
});
|
||||
});
|
||||
|
||||
it('colours Markdown syntax and variable tokens in the source', async () => {
|
||||
render(<Harness initialValue={'## Runbook\nowner {{team}}'} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.cm-md-heading')).toBeInTheDocument();
|
||||
});
|
||||
expect(document.querySelector('.cm-md-variable')).toHaveTextContent(
|
||||
'{{team}}',
|
||||
);
|
||||
});
|
||||
|
||||
describe('uncontrolled document', () => {
|
||||
const type = (at: number, text: string): void => {
|
||||
act(() => {
|
||||
getView().dispatch({
|
||||
changes: { from: at, insert: text },
|
||||
selection: { anchor: at + text.length },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const focusEditor = (): void => {
|
||||
act(() => {
|
||||
getView().focus();
|
||||
});
|
||||
};
|
||||
|
||||
it('keeps the document and caret while the caller lags behind the typing', () => {
|
||||
render(<LaggingHarness />);
|
||||
focusEditor();
|
||||
|
||||
type(0, 'a');
|
||||
type(1, 'b');
|
||||
type(2, 'c');
|
||||
|
||||
expect(documentText()).toBe('abc');
|
||||
expect(getView().state.selection.main.head).toBe(3);
|
||||
});
|
||||
|
||||
it('reports every keystroke to the caller', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<MarkdownEditor value="ab" onChange={onChange} />);
|
||||
|
||||
type(2, 'c');
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith('abc');
|
||||
});
|
||||
|
||||
it('does not report the seed back as a change', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<MarkdownEditor value="seeded" onChange={onChange} />);
|
||||
|
||||
expect(documentText()).toBe('seeded');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies an external replacement while the editor is unfocused', async () => {
|
||||
render(<ExternalHarness />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'push' }));
|
||||
|
||||
expect(documentText()).toBe('after');
|
||||
});
|
||||
|
||||
it('ignores a replacement that arrives while the author is still typing', () => {
|
||||
render(<ExternalHarness />);
|
||||
focusEditor();
|
||||
|
||||
// fireEvent: a real click would blur the editor first. This covers an update
|
||||
// arriving on its own, while the author is still in the document.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'push' }));
|
||||
|
||||
expect(documentText()).toBe('before');
|
||||
});
|
||||
|
||||
it('counts characters from the document, not from the lagging value', async () => {
|
||||
render(<MarkdownEditor value="ab" onChange={jest.fn()} />);
|
||||
|
||||
type(2, 'cde');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'5 chars',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('offers both list kinds in the toolbar', () => {
|
||||
render(<Harness />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('markdown-command-bulleted-list'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('markdown-command-numbered-list'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables authoring affordances when read-only', () => {
|
||||
render(
|
||||
<MarkdownEditor
|
||||
value="body"
|
||||
onChange={jest.fn()}
|
||||
variables={VARIABLES}
|
||||
readOnly
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('markdown-command-bold')).toBeDisabled();
|
||||
expect(screen.getByTestId('markdown-insert-variable')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('hides the insert-variable control when none are available', () => {
|
||||
render(<Harness variables={[]} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('markdown-insert-variable'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { insertText, MARKDOWN_COMMANDS } from '../markdownCommands';
|
||||
import type { EditorSnapshot, EditorTransform } from '../types';
|
||||
|
||||
const commandById = (id: string): EditorTransform => {
|
||||
const command = MARKDOWN_COMMANDS.find((entry) => entry.id === id);
|
||||
if (!command) {
|
||||
throw new Error(`unknown command: ${id}`);
|
||||
}
|
||||
return command.run;
|
||||
};
|
||||
|
||||
const heading = commandById('heading');
|
||||
const bold = commandById('bold');
|
||||
const italic = commandById('italic');
|
||||
const bulletedList = commandById('bulleted-list');
|
||||
const numberedList = commandById('numbered-list');
|
||||
const link = commandById('link');
|
||||
const code = commandById('code');
|
||||
const table = commandById('table');
|
||||
|
||||
/** `|` marks a caret, `[...]` a range, so expectations read like the editor looks. */
|
||||
const snapshot = (marked: string): EditorSnapshot => {
|
||||
if (marked.includes('|')) {
|
||||
const caret = marked.indexOf('|');
|
||||
return {
|
||||
text: marked.replace('|', ''),
|
||||
selectionStart: caret,
|
||||
selectionEnd: caret,
|
||||
};
|
||||
}
|
||||
const start = marked.indexOf('[');
|
||||
const end = marked.indexOf(']') - 1;
|
||||
return {
|
||||
text: marked.replace('[', '').replace(']', ''),
|
||||
selectionStart: start,
|
||||
selectionEnd: end,
|
||||
};
|
||||
};
|
||||
|
||||
const selectionOf = (result: EditorSnapshot): string =>
|
||||
result.text.slice(result.selectionStart, result.selectionEnd);
|
||||
|
||||
describe('heading', () => {
|
||||
it('prefixes the caret line and keeps the caret on the same character', () => {
|
||||
const result = heading(snapshot('Chec|kout'));
|
||||
|
||||
expect(result.text).toBe('## Checkout');
|
||||
expect(result.selectionStart).toBe(7);
|
||||
});
|
||||
|
||||
it('strips the prefix when every selected line already has one', () => {
|
||||
const result = heading({
|
||||
text: '## one\n### two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
|
||||
it('adds the prefix when only some selected lines have one', () => {
|
||||
const result = heading({
|
||||
text: '## one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('## ## one\n## two');
|
||||
});
|
||||
|
||||
it('does not pull in the line after a selection ending on a line break', () => {
|
||||
const result = heading({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 4,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('## one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulleted list', () => {
|
||||
it('bullets every line of a multi-line selection', () => {
|
||||
const result = bulletedList({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('- one\n- two');
|
||||
expect(selectionOf(result)).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('unbullets a list written with a different marker', () => {
|
||||
const result = bulletedList({
|
||||
text: '* one\n+ two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 11,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('numbered list', () => {
|
||||
it('numbers each line of the selection in order', () => {
|
||||
const result = numberedList({
|
||||
text: 'one\ntwo\nthree',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n2. two\n3. three');
|
||||
});
|
||||
|
||||
it('unnumbers a list whose numbering is not sequential', () => {
|
||||
const result = numberedList({
|
||||
text: '1. one\n5. two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('switching between list kinds', () => {
|
||||
it('converts bullets to numbers rather than marking them twice', () => {
|
||||
const result = numberedList({
|
||||
text: '- one\n- two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 11,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n2. two');
|
||||
});
|
||||
|
||||
it('converts numbers to bullets', () => {
|
||||
const result = bulletedList({
|
||||
text: '1. one\n2. two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('keeps indentation so nested items stay nested', () => {
|
||||
const result = numberedList({
|
||||
text: 'one\n - nested',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 16,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n 2. nested');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bold and italic', () => {
|
||||
it('wraps the selection and keeps the original text selected', () => {
|
||||
const result = bold(snapshot('a [word] b'));
|
||||
|
||||
expect(result.text).toBe('a **word** b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('unwraps when the markers sit inside the selection', () => {
|
||||
const result = bold({
|
||||
text: 'a **word** b',
|
||||
selectionStart: 2,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('a word b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('unwraps when the markers sit just outside the selection', () => {
|
||||
const result = bold({
|
||||
text: 'a **word** b',
|
||||
selectionStart: 4,
|
||||
selectionEnd: 8,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('a word b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('leaves the caret between the markers when nothing is selected', () => {
|
||||
const result = italic(snapshot('a |b'));
|
||||
|
||||
expect(result.text).toBe('a __b');
|
||||
expect(result.selectionStart).toBe(3);
|
||||
expect(result.selectionEnd).toBe(3);
|
||||
});
|
||||
|
||||
it('does not mistake a leading document boundary for a marker', () => {
|
||||
const result = bold(snapshot('[word] tail'));
|
||||
|
||||
expect(result.text).toBe('**word** tail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('link', () => {
|
||||
it('selects the url when the label came from the selection', () => {
|
||||
const result = link(snapshot('see [docs] now'));
|
||||
|
||||
expect(result.text).toBe('see [docs](https://) now');
|
||||
expect(selectionOf(result)).toBe('https://');
|
||||
});
|
||||
|
||||
it('selects the label placeholder when nothing was selected', () => {
|
||||
const result = link(snapshot('see |'));
|
||||
|
||||
expect(result.text).toBe('see [text](https://)');
|
||||
expect(selectionOf(result)).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('code', () => {
|
||||
it('uses backticks for a single-line selection', () => {
|
||||
const result = code(snapshot('run [npm] here'));
|
||||
|
||||
expect(result.text).toBe('run `npm` here');
|
||||
});
|
||||
|
||||
it('fences a multi-line selection and selects its content', () => {
|
||||
const result = code({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('```\none\ntwo\n```');
|
||||
expect(selectionOf(result)).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
it('starts the skeleton on its own line and selects the first header cell', () => {
|
||||
const result = table(snapshot('intro|'));
|
||||
|
||||
expect(result.text).toBe(
|
||||
'intro\n| Column | Column |\n| --- | --- |\n| | |',
|
||||
);
|
||||
expect(selectionOf(result)).toBe('Column');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertText', () => {
|
||||
it('replaces the selection and leaves the caret after the insertion', () => {
|
||||
const result = insertText(snapshot('env is [old]'), '{{env}}');
|
||||
|
||||
expect(result.text).toBe('env is {{env}}');
|
||||
expect(result.selectionStart).toBe(14);
|
||||
expect(result.selectionEnd).toBe(14);
|
||||
});
|
||||
});
|
||||
24
frontend/src/components/MarkdownEditor/constants.ts
Normal file
24
frontend/src/components/MarkdownEditor/constants.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// The body is persisted inline in the dashboard JSON, so its length is capped.
|
||||
export const MARKDOWN_MAX_LENGTH = 16000;
|
||||
|
||||
/** The canonical syntax; the renderer resolves the other three too. */
|
||||
export const formatVariableToken = (name: string): string => `$${name}`;
|
||||
|
||||
export const MARKDOWN_HELP_ITEMS: { syntax: string; label: string }[] = [
|
||||
// First: consecutive lines joining into one paragraph is the CommonMark rule
|
||||
// authors trip over before any of the formatting syntax.
|
||||
{ syntax: 'blank line', label: 'New paragraph' },
|
||||
{ syntax: '2 spaces + ⏎', label: 'Line break' },
|
||||
{ syntax: '# Heading', label: 'Heading (1–6 #)' },
|
||||
{ syntax: '**bold**', label: 'Bold' },
|
||||
{ syntax: '_italic_', label: 'Italic' },
|
||||
{ syntax: '- item', label: 'Bulleted list' },
|
||||
{ syntax: '1. item', label: 'Numbered list' },
|
||||
{ syntax: '- [ ] task', label: 'Task list' },
|
||||
{ syntax: '[label](url)', label: 'Link' },
|
||||
{ syntax: '', label: 'Image' },
|
||||
{ syntax: '`code`', label: 'Inline code' },
|
||||
{ syntax: '```lang', label: 'Code block' },
|
||||
{ syntax: '> quote', label: 'Blockquote' },
|
||||
{ syntax: '| a | b |', label: 'Table' },
|
||||
];
|
||||
69
frontend/src/components/MarkdownEditor/editorDocument.ts
Normal file
69
frontend/src/components/MarkdownEditor/editorDocument.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
|
||||
import type { EditorSnapshot, EditorTransform } from './types';
|
||||
|
||||
// Narrows a whole-document replacement to the range that changed, so a toolbar
|
||||
// action doesn't invalidate the document's decorations or scroll position.
|
||||
function toChangeSpec(
|
||||
previous: string,
|
||||
next: string,
|
||||
): { from: number; to: number; insert: string } | null {
|
||||
if (previous === next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shorter = Math.min(previous.length, next.length);
|
||||
let start = 0;
|
||||
while (start < shorter && previous[start] === next[start]) {
|
||||
start += 1;
|
||||
}
|
||||
|
||||
let previousEnd = previous.length;
|
||||
let nextEnd = next.length;
|
||||
while (
|
||||
previousEnd > start &&
|
||||
nextEnd > start &&
|
||||
previous[previousEnd - 1] === next[nextEnd - 1]
|
||||
) {
|
||||
previousEnd -= 1;
|
||||
nextEnd -= 1;
|
||||
}
|
||||
|
||||
return { from: start, to: previousEnd, insert: next.slice(start, nextEnd) };
|
||||
}
|
||||
|
||||
export function readSnapshot(view: EditorView): EditorSnapshot {
|
||||
const range = view.state.selection.main;
|
||||
return {
|
||||
text: view.state.doc.toString(),
|
||||
selectionStart: range.from,
|
||||
selectionEnd: range.to,
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns whether the transform ran, as CodeMirror's keymap contract expects. */
|
||||
export function applyTransform(
|
||||
view: EditorView,
|
||||
transform: EditorTransform,
|
||||
): boolean {
|
||||
if (view.state.readOnly) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const next = transform(readSnapshot(view));
|
||||
const changes = toChangeSpec(view.state.doc.toString(), next.text);
|
||||
view.dispatch({
|
||||
...(changes ? { changes } : {}),
|
||||
selection: { anchor: next.selectionStart, head: next.selectionEnd },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replaces the whole document, for seeding and external replacements. */
|
||||
export function replaceDocument(view: EditorView, next: string): void {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: next },
|
||||
});
|
||||
}
|
||||
269
frontend/src/components/MarkdownEditor/markdownCommands.ts
Normal file
269
frontend/src/components/MarkdownEditor/markdownCommands.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import type { EditorCommand, EditorSnapshot, EditorTransform } from './types';
|
||||
|
||||
const BOLD_MARKER = '**';
|
||||
const ITALIC_MARKER = '_';
|
||||
const INLINE_CODE_MARKER = '`';
|
||||
const CODE_FENCE = '```';
|
||||
const HEADING_PREFIX = '## ';
|
||||
const BULLET_MARKER = '- ';
|
||||
|
||||
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
|
||||
const BULLET_LIST_PATTERN = /^[ \t]*[-*+] /;
|
||||
const ORDERED_LIST_PATTERN = /^[ \t]*\d+\. /;
|
||||
// Either kind of marker, matched after the indent has been split off.
|
||||
const LIST_MARKER_PATTERN = /^(?:[-*+]|\d+\.) /;
|
||||
const INDENT_PATTERN = /^[ \t]*/;
|
||||
|
||||
const LINK_LABEL_PLACEHOLDER = 'text';
|
||||
const LINK_URL_PLACEHOLDER = 'https://';
|
||||
const TABLE_CELL_PLACEHOLDER = 'Column';
|
||||
const TABLE_SNIPPET = [
|
||||
`| ${TABLE_CELL_PLACEHOLDER} | ${TABLE_CELL_PLACEHOLDER} |`,
|
||||
'| --- | --- |',
|
||||
'| | |',
|
||||
].join('\n');
|
||||
|
||||
interface LineRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
// A selection ending exactly on a line break stops there rather than pulling in
|
||||
// the next line, so "select the line, hit list" doesn't bullet the line below too.
|
||||
function expandToLines(text: string, from: number, to: number): LineRange {
|
||||
const end = to > from && text[to - 1] === '\n' ? to - 1 : to;
|
||||
const breakBefore = from === 0 ? -1 : text.lastIndexOf('\n', from - 1);
|
||||
const breakAfter = text.indexOf('\n', end);
|
||||
return {
|
||||
start: breakBefore + 1,
|
||||
end: breakAfter === -1 ? text.length : breakAfter,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pads `block` so it starts and ends on its own line. */
|
||||
function replaceWithBlock(
|
||||
snapshot: EditorSnapshot,
|
||||
block: string,
|
||||
): { text: string; blockStart: number } {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const before = text.slice(0, selectionStart);
|
||||
const after = text.slice(selectionEnd);
|
||||
const lead = before === '' || before.endsWith('\n') ? '' : '\n';
|
||||
const trail = after === '' || after.startsWith('\n') ? '' : '\n';
|
||||
return {
|
||||
text: before + lead + block + trail + after,
|
||||
blockStart: before.length + lead.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rewrites every line the selection touches. */
|
||||
function replaceLines(
|
||||
snapshot: EditorSnapshot,
|
||||
mapLines: (lines: string[]) => string[],
|
||||
): EditorSnapshot {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const { start, end } = expandToLines(text, selectionStart, selectionEnd);
|
||||
const lines = text.slice(start, end).split('\n');
|
||||
const nextLines = mapLines(lines);
|
||||
const block = nextLines.join('\n');
|
||||
const nextText = text.slice(0, start) + block + text.slice(end);
|
||||
|
||||
if (selectionStart !== selectionEnd) {
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: start,
|
||||
selectionEnd: start + block.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Caret-only: the range covers one line, so shift by that line's delta.
|
||||
const shifted = selectionStart + nextLines[0].length - lines[0].length;
|
||||
const caret = Math.min(Math.max(shifted, start), start + nextLines[0].length);
|
||||
return { text: nextText, selectionStart: caret, selectionEnd: caret };
|
||||
}
|
||||
|
||||
/** Strips `prefix` when every selected line already matches `pattern`, else adds it. */
|
||||
function toggleLinePrefix(prefix: string, pattern: RegExp): EditorTransform {
|
||||
return (snapshot): EditorSnapshot =>
|
||||
replaceLines(snapshot, (lines) => {
|
||||
const isApplied = lines.every((line) => pattern.test(line));
|
||||
return lines.map((line) =>
|
||||
isApplied ? line.replace(pattern, '') : `${prefix}${line}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles this kind of list marker. A line carrying the *other* kind is converted
|
||||
* rather than marked twice, and indentation is preserved so nesting survives.
|
||||
* `markerAt` takes the line's position, which is what lets an ordered list number.
|
||||
*/
|
||||
function toggleList(
|
||||
pattern: RegExp,
|
||||
markerAt: (index: number) => string,
|
||||
): EditorTransform {
|
||||
return (snapshot): EditorSnapshot =>
|
||||
replaceLines(snapshot, (lines) => {
|
||||
const isApplied = lines.every((line) => pattern.test(line));
|
||||
return lines.map((line, index) => {
|
||||
const indent = INDENT_PATTERN.exec(line)?.[0] ?? '';
|
||||
const body = line.slice(indent.length).replace(LIST_MARKER_PATTERN, '');
|
||||
return isApplied
|
||||
? `${indent}${body}`
|
||||
: `${indent}${markerAt(index)}${body}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps when the markers are already there, whether they sit inside the selection
|
||||
* (`**bold**` selected whole) or just outside it (only `bold` selected).
|
||||
*/
|
||||
function toggleWrap(marker: string): EditorTransform {
|
||||
return ({ text, selectionStart, selectionEnd }): EditorSnapshot => {
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
const width = marker.length;
|
||||
|
||||
if (
|
||||
selected.length >= width * 2 &&
|
||||
selected.startsWith(marker) &&
|
||||
selected.endsWith(marker)
|
||||
) {
|
||||
const inner = selected.slice(width, -width);
|
||||
return {
|
||||
text: text.slice(0, selectionStart) + inner + text.slice(selectionEnd),
|
||||
selectionStart,
|
||||
selectionEnd: selectionStart + inner.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
selectionStart >= width &&
|
||||
text.slice(selectionStart - width, selectionStart) === marker &&
|
||||
text.slice(selectionEnd, selectionEnd + width) === marker
|
||||
) {
|
||||
return {
|
||||
text:
|
||||
text.slice(0, selectionStart - width) +
|
||||
selected +
|
||||
text.slice(selectionEnd + width),
|
||||
selectionStart: selectionStart - width,
|
||||
selectionEnd: selectionStart - width + selected.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text:
|
||||
text.slice(0, selectionStart) +
|
||||
marker +
|
||||
selected +
|
||||
marker +
|
||||
text.slice(selectionEnd),
|
||||
selectionStart: selectionStart + width,
|
||||
selectionEnd: selectionStart + width + selected.length,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Lands the selection on whichever half is still a placeholder. */
|
||||
const insertLink: EditorTransform = ({
|
||||
text,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
}): EditorSnapshot => {
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
const label = selected || LINK_LABEL_PLACEHOLDER;
|
||||
const snippet = `[${label}](${LINK_URL_PLACEHOLDER})`;
|
||||
const nextText =
|
||||
text.slice(0, selectionStart) + snippet + text.slice(selectionEnd);
|
||||
// `[` + label + `](` is label.length + 3 characters.
|
||||
const target = selected
|
||||
? {
|
||||
from: selectionStart + label.length + 3,
|
||||
length: LINK_URL_PLACEHOLDER.length,
|
||||
}
|
||||
: { from: selectionStart + 1, length: label.length };
|
||||
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: target.from,
|
||||
selectionEnd: target.from + target.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Backticks for a single-line selection, a fence for a multi-line one. */
|
||||
const insertCode: EditorTransform = (snapshot): EditorSnapshot => {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
if (!selected.includes('\n')) {
|
||||
return toggleWrap(INLINE_CODE_MARKER)(snapshot);
|
||||
}
|
||||
|
||||
const { text: nextText, blockStart } = replaceWithBlock(
|
||||
snapshot,
|
||||
`${CODE_FENCE}\n${selected}\n${CODE_FENCE}`,
|
||||
);
|
||||
const contentStart = blockStart + CODE_FENCE.length + 1;
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: contentStart,
|
||||
selectionEnd: contentStart + selected.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Selects the first header cell, for immediate typing. */
|
||||
const insertTable: EditorTransform = (snapshot): EditorSnapshot => {
|
||||
const { text, blockStart } = replaceWithBlock(snapshot, TABLE_SNIPPET);
|
||||
const firstCell = blockStart + TABLE_SNIPPET.indexOf(TABLE_CELL_PLACEHOLDER);
|
||||
return {
|
||||
text,
|
||||
selectionStart: firstCell,
|
||||
selectionEnd: firstCell + TABLE_CELL_PLACEHOLDER.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Replaces the selection and leaves the caret after the insertion. */
|
||||
export function insertText(
|
||||
snapshot: EditorSnapshot,
|
||||
value: string,
|
||||
): EditorSnapshot {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const caret = selectionStart + value.length;
|
||||
return {
|
||||
text: text.slice(0, selectionStart) + value + text.slice(selectionEnd),
|
||||
selectionStart: caret,
|
||||
selectionEnd: caret,
|
||||
};
|
||||
}
|
||||
|
||||
/** Display order. A new action is an entry here plus an icon in `EditorToolbar`. */
|
||||
export const MARKDOWN_COMMANDS: EditorCommand[] = [
|
||||
{
|
||||
id: 'heading',
|
||||
label: 'Heading',
|
||||
run: toggleLinePrefix(HEADING_PREFIX, HEADING_PATTERN),
|
||||
},
|
||||
{
|
||||
id: 'bold',
|
||||
label: 'Bold',
|
||||
run: toggleWrap(BOLD_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'italic',
|
||||
label: 'Italic',
|
||||
run: toggleWrap(ITALIC_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'bulleted-list',
|
||||
label: 'Bulleted list',
|
||||
run: toggleList(BULLET_LIST_PATTERN, () => BULLET_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'numbered-list',
|
||||
label: 'Numbered list',
|
||||
run: toggleList(ORDERED_LIST_PATTERN, (index) => `${index + 1}. `),
|
||||
},
|
||||
{ id: 'link', label: 'Link', run: insertLink },
|
||||
{ id: 'code', label: 'Code', run: insertCode },
|
||||
{ id: 'table', label: 'Table', run: insertTable },
|
||||
];
|
||||
149
frontend/src/components/MarkdownEditor/markdownHighlight.ts
Normal file
149
frontend/src/components/MarkdownEditor/markdownHighlight.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { Extension, Line, Range } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
type ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
|
||||
const FENCE_PATTERN = /^ {0,3}(```|~~~)/;
|
||||
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
|
||||
const QUOTE_PATTERN = /^ {0,3}> ?/;
|
||||
const LIST_MARKER_PATTERN = /^ {0,3}([-*+]|\d+\.) /;
|
||||
|
||||
/**
|
||||
* Convention: capture group 1, when present, is a left guard the token excludes —
|
||||
* the token runs from the end of that group to the end of the match. Lookbehind is
|
||||
* avoided for Safari compatibility, so guards are captured rather than asserted.
|
||||
*/
|
||||
const INLINE_PATTERNS: { pattern: RegExp; className: string }[] = [
|
||||
{ pattern: /`[^`\n]+`/g, className: 'cm-md-code' },
|
||||
{ pattern: /\*\*[^*\n]+\*\*/g, className: 'cm-md-strong' },
|
||||
{ pattern: /(^|[^\w*_`])_[^_\n]+_(?![\w_])/g, className: 'cm-md-emphasis' },
|
||||
{ pattern: /!?\[[^\]\n]*\]\([^)\n]*\)/g, className: 'cm-md-link' },
|
||||
{
|
||||
// The four variable syntaxes a dashboard body may carry.
|
||||
pattern:
|
||||
/\{\{\s*\.?[\w.-]+\s*\}\}|\[\[\s*[\w.-]+\s*\]\]|\$(?!__)[A-Za-z_]\w*(?:\.\w+)*/g,
|
||||
className: 'cm-md-variable',
|
||||
},
|
||||
];
|
||||
|
||||
const MARKS = {
|
||||
heading: Decoration.mark({ class: 'cm-md-heading' }),
|
||||
quote: Decoration.mark({ class: 'cm-md-quote' }),
|
||||
listMarker: Decoration.mark({ class: 'cm-md-list-marker' }),
|
||||
code: Decoration.mark({ class: 'cm-md-code' }),
|
||||
} as const;
|
||||
|
||||
const INLINE_MARKS = INLINE_PATTERNS.map(({ pattern, className }) => ({
|
||||
pattern,
|
||||
mark: Decoration.mark({ class: className }),
|
||||
}));
|
||||
|
||||
function pushInlineMarks(
|
||||
lineText: string,
|
||||
lineFrom: number,
|
||||
ranges: Range<Decoration>[],
|
||||
): void {
|
||||
INLINE_MARKS.forEach(({ pattern, mark }) => {
|
||||
pattern.lastIndex = 0;
|
||||
let match = pattern.exec(lineText);
|
||||
while (match !== null) {
|
||||
const guardLength = match[1]?.length ?? 0;
|
||||
const from = lineFrom + match.index + guardLength;
|
||||
const to = lineFrom + match.index + match[0].length;
|
||||
if (to > from) {
|
||||
ranges.push(mark.range(from, to));
|
||||
}
|
||||
match = pattern.exec(lineText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pushBlockMark(line: Line, ranges: Range<Decoration>[]): void {
|
||||
if (HEADING_PATTERN.test(line.text)) {
|
||||
ranges.push(MARKS.heading.range(line.from, line.to));
|
||||
return;
|
||||
}
|
||||
if (QUOTE_PATTERN.test(line.text)) {
|
||||
ranges.push(MARKS.quote.range(line.from, line.to));
|
||||
return;
|
||||
}
|
||||
const listMarker = LIST_MARKER_PATTERN.exec(line.text);
|
||||
if (listMarker) {
|
||||
ranges.push(
|
||||
MARKS.listMarker.range(line.from, line.from + listMarker[0].length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scans the whole document rather than the viewport: fenced blocks opening above
|
||||
// the visible range would otherwise be mis-detected. Bounded by the length cap.
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const { doc } = view.state;
|
||||
const ranges: Range<Decoration>[] = [];
|
||||
let isInsideFence = false;
|
||||
|
||||
for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
|
||||
const line = doc.line(lineNumber);
|
||||
const isFenceDelimiter = FENCE_PATTERN.test(line.text);
|
||||
|
||||
if (isFenceDelimiter || isInsideFence) {
|
||||
if (line.to > line.from) {
|
||||
ranges.push(MARKS.code.range(line.from, line.to));
|
||||
}
|
||||
isInsideFence = isFenceDelimiter ? !isInsideFence : isInsideFence;
|
||||
} else {
|
||||
pushBlockMark(line, ranges);
|
||||
pushInlineMarks(line.text, line.from, ranges);
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(ranges, true);
|
||||
}
|
||||
|
||||
// Colours come from custom properties so the SCSS module owns light/dark.
|
||||
const syntaxTheme = EditorView.theme({
|
||||
'.cm-md-heading': {
|
||||
color: 'var(--md-syntax-heading)',
|
||||
fontWeight: '600',
|
||||
},
|
||||
'.cm-md-quote': { color: 'var(--md-syntax-quote)', fontStyle: 'italic' },
|
||||
'.cm-md-list-marker': { color: 'var(--md-syntax-marker)' },
|
||||
'.cm-md-code': { color: 'var(--md-syntax-code)' },
|
||||
'.cm-md-strong': { color: 'var(--md-syntax-strong)', fontWeight: '600' },
|
||||
'.cm-md-emphasis': {
|
||||
color: 'var(--md-syntax-emphasis)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'.cm-md-link': { color: 'var(--md-syntax-link)' },
|
||||
'.cm-md-variable': { color: 'var(--md-syntax-variable)' },
|
||||
});
|
||||
|
||||
const highlightPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (plugin): DecorationSet => plugin.decorations },
|
||||
);
|
||||
|
||||
/**
|
||||
* Decorations rather than a grammar, so the editor stays on the CodeMirror packages
|
||||
* already bundled — no `@codemirror/lang-markdown` / `@lezer` for what is only a
|
||||
* colouring pass over a body the renderer parses for real.
|
||||
*/
|
||||
export function markdownHighlight(): Extension {
|
||||
return [highlightPlugin, syntaxTheme];
|
||||
}
|
||||
27
frontend/src/components/MarkdownEditor/types.ts
Normal file
27
frontend/src/components/MarkdownEditor/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** The value every editor command reads and returns. */
|
||||
export interface EditorSnapshot {
|
||||
text: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
export type EditorTransform = (snapshot: EditorSnapshot) => EditorSnapshot;
|
||||
|
||||
export interface EditorVariable {
|
||||
name: string;
|
||||
/** Short tag for the variable's kind, e.g. "QUERY". */
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export interface EditorCommand {
|
||||
id: string;
|
||||
/** Accessible name and tooltip for the toolbar button. */
|
||||
label: string;
|
||||
run: EditorTransform;
|
||||
}
|
||||
|
||||
/** 1-based, as the status bar reports it. */
|
||||
export interface CursorPosition {
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.BAR]: Uplot,
|
||||
[PANEL_TYPES.PIE]: null,
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -376,6 +376,7 @@ export enum PANEL_TYPES {
|
||||
BAR = 'bar',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
|
||||
@@ -376,7 +376,9 @@ export default function BillingContainer(): JSX.Element {
|
||||
</Typography.Link>
|
||||
</AuthZTooltip>
|
||||
{` if your payment information has changed. Email us at `}
|
||||
<Typography.Text color="muted">cloud-support@signoz.io</Typography.Text>
|
||||
<Typography.Text as="span" color="muted">
|
||||
cloud-support@signoz.io
|
||||
</Typography.Text>
|
||||
{` otherwise. Be sure to provide this information immediately to avoid interruption to your service.`}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
@@ -174,6 +174,7 @@ export default function ServiceTraces({
|
||||
columns={columns}
|
||||
dataSource={top5Services}
|
||||
pagination={false}
|
||||
rowKey="serviceName"
|
||||
className="services-table"
|
||||
onRow={(record: ServicesList): Record<string, unknown> => ({
|
||||
onClick: (event: React.MouseEvent): void => {
|
||||
|
||||
@@ -216,7 +216,7 @@ function K8sOptionsSidePanel<TData>({
|
||||
);
|
||||
return (
|
||||
<div className={styles.columnItem} key={column.id}>
|
||||
<Typography.Text size="sm" className={styles.columnLabel}>
|
||||
<Typography.Text as="span" size="sm" className={styles.columnLabel}>
|
||||
{column.label}
|
||||
</Typography.Text>
|
||||
{column.canBeHidden ? (
|
||||
|
||||
@@ -329,9 +329,10 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
expect(result).toHaveLength(9);
|
||||
expect(result).toHaveLength(10);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
'license',
|
||||
'logs',
|
||||
'meter-metrics',
|
||||
@@ -420,9 +421,10 @@ describe('createEmptyRolePermissions', () => {
|
||||
it('creates permissions for all resources in RESOURCE_ORDER', () => {
|
||||
const result = createEmptyRolePermissions();
|
||||
|
||||
expect(result).toHaveLength(9);
|
||||
expect(result).toHaveLength(10);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
'license',
|
||||
'logs',
|
||||
'meter-metrics',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DraftingCompass,
|
||||
FileKey,
|
||||
Gauge,
|
||||
Grid3X3,
|
||||
Key,
|
||||
Logs,
|
||||
Receipt,
|
||||
@@ -41,6 +42,14 @@ export interface ResourcePanelConfig {
|
||||
* not all of them
|
||||
*/
|
||||
export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
dashboard: {
|
||||
label: 'Dashboards',
|
||||
description: 'Dashboards and their panels across the workspace.',
|
||||
icon: Grid3X3,
|
||||
selectorPlaceholder:
|
||||
'Type dashboard ID, separate multiple with comma or space',
|
||||
docsAnchor: 'dashboard',
|
||||
},
|
||||
'factor-api-key': {
|
||||
label: 'API Keys',
|
||||
description: 'Programmatic access tokens for the workspace.',
|
||||
|
||||
@@ -29,5 +29,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
BAR: true,
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
TEXT: false,
|
||||
EMPTY_WIDGET: false,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.LIST]: ListPanelWrapper,
|
||||
[PANEL_TYPES.VALUE]: ValuePanelWrapper,
|
||||
[PANEL_TYPES.TRACE]: null,
|
||||
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
[PANEL_TYPES.PIE]: PiePanelWrapper,
|
||||
[PANEL_TYPES.BAR]: BarPanel,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
import { rest } from 'msw';
|
||||
import { server } from 'mocks-server/server';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzAdmin,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDenyAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import {
|
||||
buildDashboardReadPermission,
|
||||
buildDashboardUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import { useDashboardPermissions } from '../useDashboardPermissions';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
|
||||
describe('useDashboardPermissions - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
it('resolves every verb when all are granted', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.isReadPermissionLoading).toBe(false),
|
||||
);
|
||||
expect(result.current.canRead).toBe(true);
|
||||
expect(result.current.canUpdate).toBe(true);
|
||||
expect(result.current.canDelete).toBe(true);
|
||||
expect(result.current.canEdit).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission denied', () => {
|
||||
it('resolves every verb as false when all are denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.isReadPermissionLoading).toBe(false),
|
||||
);
|
||||
expect(result.current.canRead).toBe(false);
|
||||
expect(result.current.canUpdate).toBe(false);
|
||||
expect(result.current.canDelete).toBe(false);
|
||||
expect(result.current.canEdit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('partial', () => {
|
||||
// Authz guide rule 2: an edit affordance needs read as well as update.
|
||||
it('denies canEdit when update is granted but read is not', async () => {
|
||||
server.use(setupAuthzAllow(buildDashboardUpdatePermission(DASHBOARD_ID)));
|
||||
|
||||
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.isReadPermissionLoading).toBe(false),
|
||||
);
|
||||
expect(result.current.canUpdate).toBe(true);
|
||||
expect(result.current.canRead).toBe(false);
|
||||
expect(result.current.canEdit).toBe(false);
|
||||
});
|
||||
|
||||
it('denies canEdit when read is granted but update is not', async () => {
|
||||
server.use(setupAuthzAllow(buildDashboardReadPermission(DASHBOARD_ID)));
|
||||
|
||||
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.isReadPermissionLoading).toBe(false),
|
||||
);
|
||||
expect(result.current.canRead).toBe(true);
|
||||
expect(result.current.canUpdate).toBe(false);
|
||||
expect(result.current.canEdit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('check failure', () => {
|
||||
// An authz outage must not read as a denial — callers fall open and let the
|
||||
// API decide, matching AuthZGuard's onFailRenderContent default.
|
||||
it('reports hasError and grants nothing when the check fails', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
// A check that cannot answer is not a grant.
|
||||
await waitFor(() =>
|
||||
expect(result.current.isReadPermissionLoading).toBe(false),
|
||||
);
|
||||
expect(result.current.canRead).toBe(false);
|
||||
expect(result.current.canEdit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled', () => {
|
||||
it('fires no check when disabled', async () => {
|
||||
const onCheck = jest.fn();
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
onCheck();
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: payload, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderHook(() => useDashboardPermissions(DASHBOARD_ID, { enabled: false }), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(onCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
DashboardCreatePermission,
|
||||
DashboardListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
export interface DashboardCollectionPermissions {
|
||||
canList: boolean;
|
||||
canCreate: boolean;
|
||||
isLoading: boolean;
|
||||
/**
|
||||
* The check itself failed. Callers should fall open — behave as before authz
|
||||
* and let the API decide — rather than treat an outage as a denial.
|
||||
*/
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
// Module-level so the useQueries identity stays stable across renders.
|
||||
const CHECKS = [DashboardListPermission, DashboardCreatePermission];
|
||||
|
||||
/** Collection-level dashboard permissions (wildcard selector). */
|
||||
export function useDashboardCollectionPermissions(): DashboardCollectionPermissions {
|
||||
const { isGranted, isLoading, error } = useAuthZ(CHECKS);
|
||||
|
||||
return {
|
||||
canList: isGranted(DashboardListPermission),
|
||||
canCreate: isGranted(DashboardCreatePermission),
|
||||
isLoading,
|
||||
hasError: !!error,
|
||||
};
|
||||
}
|
||||
42
frontend/src/hooks/dashboards/useDashboardLockPermission.ts
Normal file
42
frontend/src/hooks/dashboards/useDashboardLockPermission.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DashboardtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useDashboardPermissions } from './useDashboardPermissions';
|
||||
|
||||
export interface DashboardLockPermission {
|
||||
canToggleLock: boolean;
|
||||
isLoading: boolean;
|
||||
/** Non-permission obstacle only; empty when a permission is what's missing. */
|
||||
disabledTooltip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Needs `dashboard:update`, and the handler then rejects integration dashboards;
|
||||
* ownership is not a factor. It rejects system dashboards too, unchecked here
|
||||
* because the list query filters them out.
|
||||
*/
|
||||
export function useDashboardLockPermission({
|
||||
dashboardId,
|
||||
source,
|
||||
enabled = true,
|
||||
}: {
|
||||
dashboardId: string;
|
||||
source: DashboardtypesSourceDTO;
|
||||
enabled?: boolean;
|
||||
}): DashboardLockPermission {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { canEdit, areOtherPermissionsLoading } = useDashboardPermissions(
|
||||
dashboardId,
|
||||
{ enabled },
|
||||
);
|
||||
|
||||
const isLockable = source !== DashboardtypesSourceDTO.integration;
|
||||
|
||||
return {
|
||||
canToggleLock: !areOtherPermissionsLoading && isLockable && canEdit,
|
||||
isLoading: areOtherPermissionsLoading,
|
||||
// Empty without `canEdit` so the missing permission surfaces instead.
|
||||
disabledTooltip:
|
||||
canEdit && !isLockable ? t('lock_integration_dashboard') : '',
|
||||
};
|
||||
}
|
||||
76
frontend/src/hooks/dashboards/useDashboardPermissions.ts
Normal file
76
frontend/src/hooks/dashboards/useDashboardPermissions.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
buildDashboardDeletePermission,
|
||||
buildDashboardReadPermission,
|
||||
buildDashboardUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
export interface DashboardPermissions {
|
||||
canRead: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
/** Per the authz guide, an edit affordance needs `read` as well as `update`. */
|
||||
canEdit: boolean;
|
||||
/** `read` renders the dashboard, so a page gates its mount on this alone. */
|
||||
isReadPermissionLoading: boolean;
|
||||
/** `update`/`delete` gate controls only; the page does not wait on them. */
|
||||
areOtherPermissionsLoading: boolean;
|
||||
readPermission: BrandedPermission;
|
||||
updatePermission: BrandedPermission;
|
||||
deletePermission: BrandedPermission;
|
||||
/** `[read, update]`, so a denial names both. */
|
||||
editChecks: BrandedPermission[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource-level dashboard permissions. Pass `enabled: false` while the id is
|
||||
* unknown, so no check fires against an empty selector.
|
||||
*/
|
||||
export function useDashboardPermissions(
|
||||
dashboardId: string,
|
||||
options?: { enabled?: boolean },
|
||||
): DashboardPermissions {
|
||||
const enabled = options?.enabled ?? true;
|
||||
|
||||
const { readPermission, updatePermission, deletePermission } = useMemo(
|
||||
() => ({
|
||||
readPermission: buildDashboardReadPermission(dashboardId),
|
||||
updatePermission: buildDashboardUpdatePermission(dashboardId),
|
||||
deletePermission: buildDashboardDeletePermission(dashboardId),
|
||||
}),
|
||||
[dashboardId],
|
||||
);
|
||||
|
||||
const checks = useMemo(
|
||||
() => [readPermission, updatePermission, deletePermission],
|
||||
[readPermission, updatePermission, deletePermission],
|
||||
);
|
||||
|
||||
const { isGranted, isLoading } = useAuthZ(checks, { enabled });
|
||||
|
||||
const canRead = isGranted(readPermission);
|
||||
const canUpdate = isGranted(updatePermission);
|
||||
const canDelete = isGranted(deletePermission);
|
||||
|
||||
const editChecks = useMemo(
|
||||
() => [readPermission, updatePermission],
|
||||
[readPermission, updatePermission],
|
||||
);
|
||||
|
||||
return {
|
||||
canRead,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canEdit: canRead && canUpdate,
|
||||
// One request covers all three, so the two flags only differ when a page
|
||||
// preloads `read` ahead of the rest — see `preloadChecks`.
|
||||
isReadPermissionLoading: isLoading,
|
||||
areOtherPermissionsLoading: isLoading,
|
||||
readPermission,
|
||||
updatePermission,
|
||||
deletePermission,
|
||||
editChecks,
|
||||
};
|
||||
}
|
||||
62
frontend/src/hooks/dashboards/useToggleDashboardLock.ts
Normal file
62
frontend/src/hooks/dashboards/useToggleDashboardLock.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
lockDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
interface Args {
|
||||
dashboardId: string;
|
||||
isLocked: boolean;
|
||||
/** Called with the new lock state — for analytics and any extra invalidation. */
|
||||
onSuccess?: (locked: boolean) => void;
|
||||
/** Called on failure — for rolling back optimistic state. */
|
||||
onError?: (error: APIError) => void;
|
||||
}
|
||||
|
||||
export interface ToggleDashboardLock {
|
||||
toggleLock: () => void;
|
||||
isTogglingLock: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles a dashboard's lock and patches the detail-page cache, which runs
|
||||
* `staleTime: Infinity` + `refetchOnMount: false` and would otherwise show the
|
||||
* stale state. Only the flag is patched: a refetch would reload every panel.
|
||||
*/
|
||||
export function useToggleDashboardLock({
|
||||
dashboardId,
|
||||
isLocked,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: Args): ToggleDashboardLock {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate, isLoading } = useMutation({
|
||||
mutationFn: () =>
|
||||
isLocked
|
||||
? unlockDashboardV2({ id: dashboardId })
|
||||
: lockDashboardV2({ id: dashboardId }),
|
||||
onSuccess: () => {
|
||||
const next = !isLocked;
|
||||
toast.success(next ? 'Dashboard locked' : 'Dashboard unlocked');
|
||||
const key = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
|
||||
if (cached) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(key, {
|
||||
...cached,
|
||||
data: { ...cached.data, locked: next },
|
||||
});
|
||||
}
|
||||
onSuccess?.(next);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
onError?.(error);
|
||||
},
|
||||
});
|
||||
|
||||
return { toggleLock: mutate, isTogglingLock: isLoading };
|
||||
}
|
||||
@@ -53,22 +53,6 @@ describe('AuthZButton', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a custom tooltipMessage', () => {
|
||||
render(
|
||||
<AuthZButton
|
||||
checks={[createPerm]}
|
||||
tooltipMessage="Ask an admin"
|
||||
testId="create-btn"
|
||||
>
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(mockTooltip.mock.calls[0][0]).toMatchObject({
|
||||
tooltipMessage: 'Ask an admin',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes authZEnabled through as the tooltip enabled flag', () => {
|
||||
render(
|
||||
<AuthZButton checks={[createPerm]} authZEnabled={false} testId="create-btn">
|
||||
|
||||
@@ -7,14 +7,19 @@ export type AuthZButtonProps = ButtonProps & {
|
||||
* Permissions required to enable the button (AND semantics).
|
||||
*/
|
||||
checks: BrandedPermission[];
|
||||
/**
|
||||
* Override the default denial tooltip message.
|
||||
*/
|
||||
tooltipMessage?: string;
|
||||
/**
|
||||
* Gate the permission check itself. When false, renders a plain button.
|
||||
*/
|
||||
authZEnabled?: boolean;
|
||||
/** Replace the standard denial wording; prefer the default. */
|
||||
tooltipMessage?: string;
|
||||
/**
|
||||
* A non-permission block the consumer already knows about — a lock, an
|
||||
* immutable resource. Takes precedence over `checks`, which are then skipped.
|
||||
*/
|
||||
disabledTooltip?: string;
|
||||
/** Which side of the button to render the tooltip against. */
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
/**
|
||||
* Set this false when this button is used inside a modal/drawer of signozhq/ui,
|
||||
* otherwise the tooltip will not have the correct z-index
|
||||
@@ -24,8 +29,10 @@ export type AuthZButtonProps = ButtonProps & {
|
||||
|
||||
function AuthZButton({
|
||||
checks,
|
||||
tooltipMessage,
|
||||
authZEnabled = true,
|
||||
tooltipMessage,
|
||||
disabledTooltip,
|
||||
side,
|
||||
withPortal,
|
||||
...buttonProps
|
||||
}: AuthZButtonProps): JSX.Element {
|
||||
@@ -34,6 +41,8 @@ function AuthZButton({
|
||||
checks={checks}
|
||||
enabled={authZEnabled}
|
||||
tooltipMessage={tooltipMessage}
|
||||
disabledTooltip={disabledTooltip}
|
||||
side={side}
|
||||
withPortal={withPortal}
|
||||
>
|
||||
<Button {...buttonProps} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import type {
|
||||
AuthZObject,
|
||||
BrandedPermission,
|
||||
UseAuthZResult,
|
||||
} from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import AuthZTooltip from './AuthZTooltip';
|
||||
@@ -11,15 +12,26 @@ import AuthZTooltip from './AuthZTooltip';
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const noPermissions = {
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [] as BrandedPermission[],
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
// Builds a full UseAuthZResult so `isGranted` stays consistent with `permissions`
|
||||
// rather than being a stub that could drift from it.
|
||||
function authZResult(overrides: Partial<UseAuthZResult> = {}): UseAuthZResult {
|
||||
const base: UseAuthZResult = {
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [] as BrandedPermission[],
|
||||
isGranted: (): boolean => false,
|
||||
refetchPermissions: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
isGranted: (permission: BrandedPermission): boolean =>
|
||||
base.permissions?.[permission]?.isGranted === true,
|
||||
};
|
||||
}
|
||||
|
||||
const TestButton = (
|
||||
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
@@ -42,10 +54,11 @@ const attachRolePerm = buildPermission(
|
||||
|
||||
describe('AuthZTooltip — single check', () => {
|
||||
it('renders child unchanged when permission is granted', () => {
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: { [createPerm]: { isGranted: true } },
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: { [createPerm]: { isGranted: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
@@ -57,10 +70,11 @@ describe('AuthZTooltip — single check', () => {
|
||||
});
|
||||
|
||||
it('disables child when permission is denied', () => {
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
@@ -72,10 +86,11 @@ describe('AuthZTooltip — single check', () => {
|
||||
});
|
||||
|
||||
it('shows formatted permission message in tooltip when denied', async () => {
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
@@ -95,7 +110,7 @@ describe('AuthZTooltip — single check', () => {
|
||||
});
|
||||
|
||||
it('disables child while loading', () => {
|
||||
mockUseAuthZ.mockReturnValue({ ...noPermissions, isLoading: true });
|
||||
mockUseAuthZ.mockReturnValue(authZResult({ isLoading: true }));
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
@@ -110,13 +125,14 @@ describe('AuthZTooltip — single check', () => {
|
||||
describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
it('renders child enabled when all checks are granted', () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: true },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: {
|
||||
[sa]: { isGranted: true },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
@@ -129,13 +145,14 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
|
||||
it('disables child when first check is denied, second granted', () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
@@ -148,13 +165,14 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
|
||||
it('disables child when both checks are denied and lists denied permissions in data attr', () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
@@ -173,13 +191,14 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
|
||||
it('shows multiple formatted permissions in tooltip when both denied', async () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
.errorContent {
|
||||
background: var(--callout-error-background) !important;
|
||||
border-color: var(--callout-error-border) !important;
|
||||
backdrop-filter: blur(15px);
|
||||
border-radius: 4px !important;
|
||||
color: var(--foreground) !important;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import { cloneElement, CSSProperties, ReactElement, useMemo } from 'react';
|
||||
import {
|
||||
cloneElement,
|
||||
CSSProperties,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
@@ -9,7 +17,9 @@ import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import styles from './AuthZTooltip.module.scss';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from '../tooltipContent.module.scss';
|
||||
|
||||
const DISABLED_STYLE: CSSProperties = {
|
||||
pointerEvents: 'all',
|
||||
@@ -22,7 +32,23 @@ interface AuthZTooltipProps {
|
||||
checks: BrandedPermission[];
|
||||
children: ReactElement;
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Replace the standard denial wording. Prefer the default — it names the exact
|
||||
* scopes — and reach for this only when a surface genuinely needs its own.
|
||||
*/
|
||||
tooltipMessage?: string;
|
||||
/**
|
||||
* A block the consumer already knows about that is not a permission — a lock,
|
||||
* an immutable resource, a forced read-only mount.
|
||||
*
|
||||
* It takes precedence over the checks, which are skipped entirely: the control
|
||||
* is unavailable either way, so running them would only cost a request. Set it
|
||||
* only when the non-permission block is the real obstacle, so a missing
|
||||
* permission still surfaces its own message.
|
||||
*/
|
||||
disabledTooltip?: string;
|
||||
/** Which side of the control to render against. Defaults to the top. */
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
/**
|
||||
* Set this false when this button is used inside a modal/drawer of signozhq/ui,
|
||||
* otherwise the tooltip will not have the correct z-index
|
||||
@@ -47,10 +73,17 @@ function AuthZTooltip({
|
||||
children,
|
||||
enabled = true,
|
||||
tooltipMessage,
|
||||
disabledTooltip,
|
||||
side,
|
||||
withPortal,
|
||||
}: AuthZTooltipProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const shouldCheck = enabled && checks.length > 0;
|
||||
const isPointerOverRef = useRef(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// The block the consumer passed is already decisive, so the check is not run.
|
||||
const isBlocked = !!disabledTooltip;
|
||||
const shouldCheck = enabled && checks.length > 0 && !isBlocked;
|
||||
|
||||
const { permissions, isLoading } = useAuthZ(checks, { enabled: shouldCheck });
|
||||
|
||||
@@ -61,6 +94,20 @@ function AuthZTooltip({
|
||||
return checks.filter((p) => permissions[p]?.isGranted === false);
|
||||
}, [checks, permissions]);
|
||||
|
||||
/**
|
||||
* Radix closes the tooltip on pointerdown and on click, and merges its own
|
||||
* handlers after the trigger's regardless of `preventDefault`, so the close is
|
||||
* filtered here. Clicking a dead control does nothing, which is exactly when
|
||||
* its reason is still wanted, so a close is ignored while the pointer remains
|
||||
* on it. Everything else stays Radix's to decide.
|
||||
*/
|
||||
const handleOpenChange = useCallback((next: boolean): void => {
|
||||
if (!next && isPointerOverRef.current) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(next);
|
||||
}, []);
|
||||
|
||||
if (shouldCheck && isLoading) {
|
||||
return cloneElement(children, {
|
||||
disabled: true,
|
||||
@@ -71,7 +118,7 @@ function AuthZTooltip({
|
||||
});
|
||||
}
|
||||
|
||||
if (!shouldCheck || deniedPermissions.length === 0) {
|
||||
if (!isBlocked && (!shouldCheck || deniedPermissions.length === 0)) {
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -79,7 +126,7 @@ function AuthZTooltip({
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<TooltipRoot open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<TooltipTrigger asChild testId={childTestId}>
|
||||
{cloneElement(children, {
|
||||
disabled: true,
|
||||
@@ -87,11 +134,31 @@ function AuthZTooltip({
|
||||
onClick: noOp,
|
||||
onMouseDown: noOp,
|
||||
onPointerDown: noOp,
|
||||
'data-denied-permissions': deniedPermissions.join(','),
|
||||
onPointerEnter: (): void => {
|
||||
isPointerOverRef.current = true;
|
||||
},
|
||||
onPointerLeave: (): void => {
|
||||
isPointerOverRef.current = false;
|
||||
},
|
||||
...(isBlocked
|
||||
? {}
|
||||
: { 'data-denied-permissions': deniedPermissions.join(',') }),
|
||||
})}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={styles.errorContent} withPortal={withPortal}>
|
||||
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
|
||||
<TooltipContent
|
||||
side={side}
|
||||
// A denial has no arrow; a state the user can act on is not an error
|
||||
// and reads as a normal tooltip.
|
||||
arrow={isBlocked}
|
||||
className={cx(
|
||||
isBlocked ? styles.blockedContent : styles.errorContent,
|
||||
styles.aboveOverlay,
|
||||
)}
|
||||
withPortal={withPortal}
|
||||
>
|
||||
{isBlocked
|
||||
? disabledTooltip
|
||||
: formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
|
||||
42
frontend/src/lib/authz/components/tooltipContent.module.scss
Normal file
42
frontend/src/lib/authz/components/tooltipContent.module.scss
Normal file
@@ -0,0 +1,42 @@
|
||||
// Shared presentation for every "you can't use this, here's why" tooltip, so the
|
||||
// explanation looks the same wherever it surfaces.
|
||||
//
|
||||
// These drive the bubble AND the arrow: @signozhq/ui paints both from
|
||||
// --tooltip-background / --tooltip-border-color, so setting the variables keeps
|
||||
// them in step. Overriding `background`/`border-color` directly styles only the
|
||||
// bubble and leaves the arrow on the default fill.
|
||||
.errorContent {
|
||||
--tooltip-background: var(--callout-error-background);
|
||||
--tooltip-border-color: var(--callout-error-border);
|
||||
--tooltip-foreground: var(--foreground);
|
||||
|
||||
backdrop-filter: blur(15px);
|
||||
border-radius: 4px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
// Reasons naming two permissions run long; wrap rather than stretch the
|
||||
// bubble across the viewport.
|
||||
max-width: 260px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
// A block the user can act on themselves — a lock, an integration-owned or
|
||||
// legacy dashboard — reads as state, not as an access error.
|
||||
.blockedContent {
|
||||
--tooltip-background: var(--l2-background);
|
||||
--tooltip-border-color: var(--l2-border);
|
||||
--tooltip-foreground: var(--l1-foreground);
|
||||
|
||||
backdrop-filter: blur(15px);
|
||||
border-radius: 4px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
max-width: 260px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.aboveOverlay {
|
||||
// Lift above the dropdown menu (z 50) and the antd Drawer (z 1000) so the
|
||||
// tooltip is never clipped behind them. The arrow reads this too.
|
||||
--tooltip-z-index: 1100;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'react-router-dom';
|
||||
import type { AuthZGuardProps } from 'lib/authz/components/AuthZGuard/AuthZGuard';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
export type RouterContext = {
|
||||
/**
|
||||
@@ -45,6 +46,18 @@ export type WithAuthZOptions<P> = {
|
||||
checks:
|
||||
| BrandedPermission[]
|
||||
| ((props: P, router: RouterContext) => BrandedPermission[]);
|
||||
/**
|
||||
* Extra permissions to fetch in the same batch as `checks`, without gating on
|
||||
* them. `useAuthZ` coalesces everything requested in the same tick into one
|
||||
* request and caches per permission, so a component below the guard that
|
||||
* needs these resolves from cache instead of firing a second round trip and
|
||||
* flipping its controls once it lands.
|
||||
*
|
||||
* Never affects whether the content renders — a denial here is ignored.
|
||||
*/
|
||||
preloadChecks?:
|
||||
| BrandedPermission[]
|
||||
| ((props: P, router: RouterContext) => BrandedPermission[]);
|
||||
fallback?: AuthZGuardProps['fallback'];
|
||||
fallbackOnLoading?: AuthZGuardProps['fallbackOnLoading'];
|
||||
failOpenOnError?: AuthZGuardProps['onFailRenderContent'];
|
||||
@@ -86,12 +99,21 @@ export function createAuthZHOC<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
const { checks, ...guardProps } = opts;
|
||||
const { checks, preloadChecks, ...guardProps } = opts;
|
||||
|
||||
function Wrapped(props: P): ReactElement | null {
|
||||
const router = useRouterContext();
|
||||
const resolvedChecks =
|
||||
typeof checks === 'function' ? checks(props, router) : checks;
|
||||
const resolvedPreload =
|
||||
typeof preloadChecks === 'function'
|
||||
? preloadChecks(props, router)
|
||||
: preloadChecks;
|
||||
|
||||
// Requested here rather than through the guard: `useAuthZ` coalesces
|
||||
// everything asked for in the same tick into one request, so this rides
|
||||
// along with the guard's own checks without being able to gate rendering.
|
||||
useAuthZ(resolvedPreload ?? [], { enabled: !!resolvedPreload?.length });
|
||||
|
||||
return (
|
||||
<Guard checks={resolvedChecks} {...guardProps}>
|
||||
|
||||
@@ -3,6 +3,11 @@ export default {
|
||||
status: 'success',
|
||||
data: {
|
||||
resources: [
|
||||
{
|
||||
kind: 'dashboard',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'factor-api-key',
|
||||
type: 'metaresource',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { buildPermission } from '../utils';
|
||||
import type { BrandedPermission } from '../types';
|
||||
|
||||
// Collection-level — wildcard selector required for correct response key matching.
|
||||
// `list` also covers pin/unpin and saved-view CRUD, which the backend gates on it.
|
||||
export const DashboardListPermission = buildPermission('list', 'dashboard:*');
|
||||
export const DashboardCreatePermission = buildPermission(
|
||||
'create',
|
||||
'dashboard:*',
|
||||
);
|
||||
|
||||
// Resource-level — require a specific dashboard id
|
||||
export const buildDashboardReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `dashboard:${id}`);
|
||||
export const buildDashboardUpdatePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('update', `dashboard:${id}`);
|
||||
export const buildDashboardDeletePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('delete', `dashboard:${id}`);
|
||||
@@ -97,5 +97,9 @@ export type UseAuthZResult = {
|
||||
* Checks that resolved as not granted (empty while loading/error).
|
||||
*/
|
||||
deniedPermissions: BrandedPermission[];
|
||||
/**
|
||||
* Use this to check if a specific permission is granted, false while loading or on error.
|
||||
*/
|
||||
isGranted: (permission: BrandedPermission) => boolean;
|
||||
refetchPermissions: () => void;
|
||||
};
|
||||
|
||||
@@ -240,6 +240,12 @@ export function useAuthZ(
|
||||
return permissions.every((check) => data[check]?.isGranted === true);
|
||||
}, [permissions, data, isLoading, error]);
|
||||
|
||||
const isGranted = useCallback(
|
||||
(permission: BrandedPermission): boolean =>
|
||||
data?.[permission]?.isGranted === true,
|
||||
[data],
|
||||
);
|
||||
|
||||
const deniedPermissions = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
@@ -254,6 +260,7 @@ export function useAuthZ(
|
||||
permissions: data ?? null,
|
||||
allowed,
|
||||
deniedPermissions,
|
||||
isGranted,
|
||||
refetchPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ export function mockUseAuthZGrantAll(
|
||||
) as UseAuthZResult['permissions'],
|
||||
allowed: true,
|
||||
deniedPermissions: [],
|
||||
isGranted: (): boolean => true,
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -187,6 +188,7 @@ export function mockUseAuthZDenyAll(
|
||||
) as UseAuthZResult['permissions'],
|
||||
allowed: false,
|
||||
deniedPermissions: permissions,
|
||||
isGranted: (): boolean => false,
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -213,6 +215,8 @@ export function mockUseAuthZGrantByPrefix(
|
||||
) as UseAuthZResult['permissions'],
|
||||
allowed: denied.length === 0,
|
||||
deniedPermissions: denied,
|
||||
isGranted: (permission): boolean =>
|
||||
prefixes.some((prefix) => permission.startsWith(prefix)),
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// Let the flex children shrink below their content height so the series list
|
||||
// scrolls within the capped legend height instead of overflowing the wrapper
|
||||
// (the default min-height:auto would block the shrink).
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
// flex:1 + min-height:0 pins the scroller to the space left after the
|
||||
// toolbar instead of growing to fit every row.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding-right: var(--spacing-2);
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.gridItem {
|
||||
// Or the item keeps its content width and the label never ellipsizes.
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.gridList {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
// min() keeps the column inside a narrow panel, where a wider one would push
|
||||
// the row's actions out of the clipped area.
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(min(var(--legend-item-width, 240px), 100%), 1fr)
|
||||
);
|
||||
gap: var(--spacing-1) var(--spacing-4);
|
||||
}
|
||||
|
||||
.container.isRight .gridList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: var(--spacing-16) 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.legend-search-container {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding-right: 8px;
|
||||
|
||||
.legend-search-input {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// Allow the flex children to shrink below their content height so the
|
||||
// virtualized grid scrolls within the capped legend height instead of
|
||||
// overflowing the wrapper (default min-height:auto would block the shrink).
|
||||
min-height: 0;
|
||||
|
||||
&:has(.legend-item-focused) .legend-item {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&:has(.legend-item-focused) .legend-item.legend-item-focused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend-empty-state {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.legend-virtuoso-container {
|
||||
// flex:1 + min-height:0 pins the scroller to the space left after the
|
||||
// search box (RIGHT legend) and lets it scroll instead of growing to fit
|
||||
// every row — without this the grid overflows a BOTTOM legend's fixed height.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.virtuoso-grid-list {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(var(--legend-average-width, 240px), 1fr)
|
||||
);
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
.virtuoso-grid-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&.legend-virtuoso-container-right {
|
||||
.virtuoso-grid-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&.legend-virtuoso-container-single-row {
|
||||
.virtuoso-grid-list {
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(var(--legend-average-width, 240px), max-content)
|
||||
);
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-row {
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
|
||||
&.legend-single-row {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.legend-row-right {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
&.legend-row-bottom {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
// Include padding within the width so a full-width row (legend-item-right) fits its
|
||||
// column instead of overflowing by the 16px horizontal padding — there is no global
|
||||
// border-box reset, so the default content-box would make it overflow.
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&.legend-item-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.legend-item-off {
|
||||
opacity: 0.3;
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
&.legend-item-focused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend-item-label-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.legend-marker {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-radius: 50%;
|
||||
min-width: 11px;
|
||||
min-height: 11px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.legend-copy-button {
|
||||
// Always laid out (space reserved) but transparent, so revealing it on
|
||||
// hover fades the icon in without reflowing the row / shifting the label.
|
||||
// Shrink the shared icon Button (defaults to a 2rem square) to the
|
||||
// compact legend row via its size tokens.
|
||||
--button-height: auto;
|
||||
--button-width: auto;
|
||||
--button-padding: 2px;
|
||||
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
.legend-copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,106 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { Input } from 'antd';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import { LegendPosition, LegendProps } from '../types';
|
||||
import { LegendAction, LegendPosition, LegendProps } from '../types';
|
||||
|
||||
import './Legend.styles.scss';
|
||||
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
|
||||
import LegendRow from './LegendRow';
|
||||
import LegendToolbar from './LegendToolbar';
|
||||
import { filterLegendItems, getShownSeriesState } from './utils';
|
||||
|
||||
export const MAX_LEGEND_WIDTH = 240;
|
||||
import styles from './Legend.module.scss';
|
||||
|
||||
/**
|
||||
* Presentational legend. Renders the supplied `items` (markers + labels, an
|
||||
* optional copy button, and a search box for the RIGHT position) and delegates
|
||||
* all interaction to the container handlers. Source-agnostic — the uPlot
|
||||
* charts feed it via UPlotLegend; Pie feeds it directly.
|
||||
* Presentational legend, source-agnostic: the uPlot charts feed it via
|
||||
* UPlotLegend, Pie feeds it directly. Every state change is delegated.
|
||||
*/
|
||||
export default function Legend({
|
||||
items,
|
||||
position,
|
||||
averageLegendWidth = MAX_LEGEND_WIDTH,
|
||||
focusedSeriesIndex,
|
||||
onClick,
|
||||
onMouseMove,
|
||||
onMouseLeave,
|
||||
onAction,
|
||||
showCopy = true,
|
||||
}: LegendProps): JSX.Element {
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [legendSearchQuery, setLegendSearchQuery] = useState('');
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
|
||||
// Search is intrinsic to the right-positioned legend.
|
||||
const searchEnabled = position === LegendPosition.RIGHT;
|
||||
const { width: containerWidth } = useResizeObserver(legendContainerRef);
|
||||
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
|
||||
const isRightPosition = position === LegendPosition.RIGHT;
|
||||
|
||||
const isSingleRow = useMemo(() => {
|
||||
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
|
||||
return false;
|
||||
}
|
||||
const totalLegendWidth = items.length * (averageLegendWidth + 16);
|
||||
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
|
||||
return totalRows <= 1;
|
||||
}, [averageLegendWidth, items.length, position, containerWidth]);
|
||||
|
||||
const visibleLegendItems = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const query = legendSearchQuery.trim().toLowerCase();
|
||||
return items.filter((item) => item.label?.toLowerCase().includes(query));
|
||||
}, [searchEnabled, legendSearchQuery, items]);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => {
|
||||
// `color` is uPlot's stroke union (string | fn | gradient); only a string
|
||||
// is a usable CSS colour for the marker.
|
||||
const markerColor = typeof item.color === 'string' ? item.color : undefined;
|
||||
return (
|
||||
<div
|
||||
key={item.seriesIndex}
|
||||
data-legend-item-id={item.seriesIndex}
|
||||
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
|
||||
'legend-item-off': !item.show,
|
||||
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
|
||||
})}
|
||||
>
|
||||
<TooltipSimple title={item.label} arrow side="top" disableHoverableContent>
|
||||
<div className="legend-item-label-trigger">
|
||||
<div
|
||||
className="legend-marker"
|
||||
style={{ borderColor: markerColor }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className="legend-label">{item.label}</span>
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
{showCopy && (
|
||||
<CopyButton
|
||||
value={item.label ?? ''}
|
||||
size={12}
|
||||
className="legend-copy-button"
|
||||
ariaLabel={`Copy ${item.label}`}
|
||||
testId="legend-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[focusedSeriesIndex, position, showCopy],
|
||||
const { visibleCount, soleShownSeriesIndex } = useMemo(
|
||||
() => getShownSeriesState(items),
|
||||
[items],
|
||||
);
|
||||
|
||||
const isEmptyState = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return false;
|
||||
}
|
||||
return visibleLegendItems.length === 0;
|
||||
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
|
||||
// A bottom legend gets two rows; spending one on chrome costs more chart than
|
||||
// the readout is worth.
|
||||
const showToolbar = isRightPosition && items.length > 0;
|
||||
const showFilter = showToolbar;
|
||||
|
||||
const effectiveQuery = showFilter ? filterQuery : '';
|
||||
|
||||
const visibleLegendItems = useMemo(
|
||||
() => filterLegendItems(items, effectiveQuery),
|
||||
[items, effectiveQuery],
|
||||
);
|
||||
|
||||
const isEmptyState =
|
||||
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
|
||||
|
||||
const isAllShown = visibleCount === items.length;
|
||||
|
||||
// A row that unmounts under the pointer never fires its own mouseleave.
|
||||
const handleMouseLeave = useCallback(
|
||||
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
|
||||
[onAction],
|
||||
);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => (
|
||||
<LegendRow
|
||||
key={item.seriesIndex}
|
||||
item={item}
|
||||
isSoleShown={soleShownSeriesIndex === item.seriesIndex}
|
||||
isAllShown={isAllShown}
|
||||
isFocused={focusedSeriesIndex === item.seriesIndex}
|
||||
showCopy={showCopy}
|
||||
onAction={onAction}
|
||||
/>
|
||||
),
|
||||
[soleShownSeriesIndex, isAllShown, focusedSeriesIndex, showCopy, onAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={legendContainerRef}
|
||||
className="legend-container"
|
||||
onClick={onClick}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={{
|
||||
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
|
||||
}}
|
||||
className={cx(styles.container, {
|
||||
[styles.isRight]: isRightPosition,
|
||||
})}
|
||||
style={{ ['--legend-item-width' as string]: `${itemWidth}px` }}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-testid="legend-container"
|
||||
>
|
||||
{searchEnabled && (
|
||||
<div className="legend-search-container">
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="Search..."
|
||||
value={legendSearchQuery}
|
||||
onChange={(e): void => setLegendSearchQuery(e.target.value)}
|
||||
data-testid="legend-search-input"
|
||||
className="legend-search-input"
|
||||
/>
|
||||
</div>
|
||||
{showToolbar && (
|
||||
<LegendToolbar
|
||||
visibleCount={visibleCount}
|
||||
totalCount={items.length}
|
||||
showFilter={showFilter}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
/>
|
||||
)}
|
||||
{isEmptyState ? (
|
||||
<div className="legend-empty-state">
|
||||
No series found matching "{legendSearchQuery}"
|
||||
<div className={styles.emptyState}>
|
||||
No series found matching "{effectiveQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
className={cx(
|
||||
'legend-virtuoso-container',
|
||||
`legend-virtuoso-container-${position.toLowerCase()}`,
|
||||
{ 'legend-virtuoso-container-single-row': isSingleRow },
|
||||
)}
|
||||
className={styles.scroller}
|
||||
listClassName={styles.gridList}
|
||||
itemClassName={styles.gridItem}
|
||||
data={visibleLegendItems}
|
||||
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
|
||||
/>
|
||||
|
||||
170
frontend/src/lib/uPlotV2/components/Legend/LegendRow.module.scss
Normal file
170
frontend/src/lib/uPlotV2/components/Legend/LegendRow.module.scss
Normal file
@@ -0,0 +1,170 @@
|
||||
.row {
|
||||
// Width of the revealed actions, given up by the label on hover only.
|
||||
--legend-actions-reserve: 78px;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
height: 28px;
|
||||
padding: 0 var(--spacing-3) 0 var(--spacing-4);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background 160ms linear;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.isFocused {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.marker {
|
||||
// Reads as a checkbox without being one: filled when shown, hollow when
|
||||
// hidden, deliberately not a check glyph.
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
// Above the actions, so a narrow row's chip never covers the series colour.
|
||||
z-index: 4;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
border-width: 1.5px;
|
||||
border-style: solid;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
box-shadow 200ms ease,
|
||||
background-color 160ms linear,
|
||||
opacity 160ms linear;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--l1-foreground) 30%, transparent);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Series names run long and have no spaces to break on, so they need both a
|
||||
// cap and a break rule or the tooltip becomes one panel-wide line.
|
||||
.rowTooltip {
|
||||
max-width: 420px;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1 1 auto;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--l2-foreground);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.isHidden .marker {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.isHidden .label {
|
||||
color: var(--l3-foreground);
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
/* Row actions */
|
||||
|
||||
.actions {
|
||||
position: absolute;
|
||||
top: var(--spacing-2);
|
||||
right: var(--spacing-3);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding-left: var(--spacing-5, 10px);
|
||||
// Sits on the row's hover background and masks the label's tail behind it.
|
||||
background: var(--l3-background);
|
||||
box-shadow: -8px 0 8px var(--l3-background);
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 180ms cubic-bezier(0.08, 0.52, 0.52, 1),
|
||||
transform 180ms cubic-bezier(0.08, 0.52, 0.52, 1);
|
||||
}
|
||||
|
||||
// :focus-visible, not :focus-within — the latter also matches the click that
|
||||
// just toggled the series, leaving the actions stuck open.
|
||||
.row:hover .actions,
|
||||
.row:focus-visible .actions,
|
||||
.row:has(:focus-visible) .actions {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// The cap spares rows sized to their actions, not their label.
|
||||
.row:hover .label,
|
||||
.row:focus-visible .label,
|
||||
.row:has(:focus-visible) .label {
|
||||
padding-right: min(var(--legend-actions-reserve), 50%);
|
||||
}
|
||||
|
||||
.actionTrigger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
--button-height: 20px;
|
||||
--button-width: 20px;
|
||||
--button-padding: 0;
|
||||
--button-variant-ghost-color: var(--l3-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actionButton.scopeButton {
|
||||
--button-width: auto;
|
||||
--button-padding: 0 var(--spacing-4);
|
||||
--button-font-size: var(--font-size-xs);
|
||||
--button-border-radius: calc(var(--radius) * 4);
|
||||
--button-base-border-width: 1px;
|
||||
// --l2-border is the actions bar's own background: one step up reads.
|
||||
--button-base-border-color: var(--l3-border);
|
||||
|
||||
border-style: solid;
|
||||
}
|
||||
.actionButton.scopeButton:hover {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
182
frontend/src/lib/uPlotV2/components/Legend/LegendRow.tsx
Normal file
182
frontend/src/lib/uPlotV2/components/Legend/LegendRow.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { KeyboardEvent, memo, MouseEvent, useCallback } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import { LegendAction, OnLegendAction } from '../types';
|
||||
|
||||
import { LEGEND_TOOLTIP_DELAY_MS } from './constants';
|
||||
import styles from './LegendRow.module.scss';
|
||||
|
||||
export interface LegendRowProps {
|
||||
item: LegendItem;
|
||||
/** The only series currently shown, so hiding it is refused. */
|
||||
isSoleShown: boolean;
|
||||
/** Nothing is hidden, so the row's action can only narrow the selection. */
|
||||
isAllShown: boolean;
|
||||
isFocused: boolean;
|
||||
showCopy: boolean;
|
||||
onAction: OnLegendAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* One legend row. The marker is its own target for excluding a single series —
|
||||
* the one thing the row click can't do while everything is showing. The actions
|
||||
* overlay the label's tail rather than taking layout width, and their reveal is
|
||||
* pure CSS.
|
||||
*/
|
||||
function LegendRow({
|
||||
item,
|
||||
isSoleShown,
|
||||
isAllShown,
|
||||
isFocused,
|
||||
showCopy,
|
||||
onAction,
|
||||
}: LegendRowProps): JSX.Element {
|
||||
const { seriesIndex, show } = item;
|
||||
const label = item.label ?? '';
|
||||
const isShowAllAction = show && !isAllShown;
|
||||
const scopeActionLabel = isShowAllAction
|
||||
? 'Show all series'
|
||||
: 'Show only current series';
|
||||
// `color` is uPlot's stroke union (string | fn | gradient); only a string is
|
||||
// a usable CSS colour for the marker.
|
||||
const seriesColor = typeof item.color === 'string' ? item.color : undefined;
|
||||
|
||||
/** Everything showing -> isolate; showing alone -> restore all. */
|
||||
const handleRowClick = useCallback((): void => {
|
||||
if (isSoleShown) {
|
||||
onAction({ type: LegendAction.SHOW_ALL });
|
||||
return;
|
||||
}
|
||||
onAction({
|
||||
type: isAllShown ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
|
||||
seriesIndex,
|
||||
});
|
||||
}, [isSoleShown, isAllShown, onAction, seriesIndex]);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||
event.stopPropagation();
|
||||
onAction({ type: LegendAction.TOGGLE, seriesIndex });
|
||||
},
|
||||
[onAction, seriesIndex],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
// Let the row actions handle their own keys.
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleRowClick();
|
||||
}
|
||||
},
|
||||
[handleRowClick],
|
||||
);
|
||||
|
||||
const handleScopeClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||
event.stopPropagation();
|
||||
if (isShowAllAction) {
|
||||
onAction({ type: LegendAction.SHOW_ALL });
|
||||
return;
|
||||
}
|
||||
onAction({ type: LegendAction.SHOW_ONLY, seriesIndex });
|
||||
},
|
||||
[isShowAllAction, onAction, seriesIndex],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(): void => onAction({ type: LegendAction.HOVER, seriesIndex }),
|
||||
[onAction, seriesIndex],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
|
||||
[onAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, {
|
||||
[styles.isHidden]: !show,
|
||||
[styles.isFocused]: isFocused,
|
||||
})}
|
||||
data-legend-item-id={seriesIndex}
|
||||
data-testid={`legend-item-${seriesIndex}`}
|
||||
role="switch"
|
||||
tabIndex={0}
|
||||
aria-checked={show}
|
||||
aria-label={label}
|
||||
onClick={handleRowClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.marker}
|
||||
style={{
|
||||
borderColor: seriesColor,
|
||||
backgroundColor: show ? seriesColor : 'transparent',
|
||||
}}
|
||||
onClick={handleMarkerClick}
|
||||
disabled={isSoleShown}
|
||||
aria-label={`${show ? 'Hide' : 'Show'} ${label}`}
|
||||
data-is-legend-marker={true}
|
||||
data-testid={`legend-marker-${seriesIndex}`}
|
||||
/>
|
||||
<TooltipSimple
|
||||
title={label}
|
||||
arrow
|
||||
side="top"
|
||||
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
|
||||
disableHoverableContent
|
||||
tooltipContentProps={{ className: styles.rowTooltip }}
|
||||
>
|
||||
<span className={styles.label}>{label}</span>
|
||||
</TooltipSimple>
|
||||
<div className={styles.actions}>
|
||||
<TooltipSimple
|
||||
title={scopeActionLabel}
|
||||
arrow
|
||||
side="top"
|
||||
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
|
||||
disableHoverableContent
|
||||
tooltipContentProps={{ className: styles.rowTooltip }}
|
||||
>
|
||||
{/* Radix's asChild merge strips the button's own data-testid. */}
|
||||
<span className={styles.actionTrigger}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={cx(styles.actionButton, styles.scopeButton)}
|
||||
onClick={handleScopeClick}
|
||||
aria-label={scopeActionLabel}
|
||||
testId={`legend-scope-${seriesIndex}`}
|
||||
>
|
||||
{isShowAllAction ? 'All' : 'Only'}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
{showCopy && (
|
||||
<CopyButton
|
||||
value={label}
|
||||
size={13}
|
||||
className={styles.actionButton}
|
||||
ariaLabel={`Copy ${label}`}
|
||||
testId={`legend-copy-${seriesIndex}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(LegendRow);
|
||||
@@ -0,0 +1,35 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-5, 10px);
|
||||
padding: 0 var(--spacing-4) var(--spacing-5, 10px);
|
||||
flex-shrink: 0;
|
||||
|
||||
> * {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
// Wraps rather than losing the count at its end.
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding-right: var(--spacing-4);
|
||||
padding-bottom: var(--spacing-5, 10px);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
56
frontend/src/lib/uPlotV2/components/Legend/LegendToolbar.tsx
Normal file
56
frontend/src/lib/uPlotV2/components/Legend/LegendToolbar.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ChangeEvent, useCallback } from 'react';
|
||||
import { Input } from 'antd';
|
||||
import { Search } from '@signozhq/icons';
|
||||
|
||||
import styles from './LegendToolbar.module.scss';
|
||||
|
||||
export interface LegendToolbarProps {
|
||||
visibleCount: number;
|
||||
totalCount: number;
|
||||
/** Search is intrinsic to the right-positioned legend. */
|
||||
showFilter: boolean;
|
||||
filterQuery: string;
|
||||
onFilterQueryChange: (query: string) => void;
|
||||
}
|
||||
|
||||
/** Legend chrome: the series search box and the "Showing N of M" readout. */
|
||||
export default function LegendToolbar({
|
||||
visibleCount,
|
||||
totalCount,
|
||||
showFilter,
|
||||
filterQuery,
|
||||
onFilterQueryChange,
|
||||
}: LegendToolbarProps): JSX.Element {
|
||||
const handleFilterChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void =>
|
||||
onFilterQueryChange(event.target.value),
|
||||
[onFilterQueryChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFilter && (
|
||||
<div className={styles.searchContainer}>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<Search size={12} className={styles.searchIcon} />}
|
||||
placeholder="Search..."
|
||||
value={filterQuery}
|
||||
onChange={handleFilterChange}
|
||||
className={styles.searchInput}
|
||||
data-testid="legend-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.toolbar}>
|
||||
<span
|
||||
className={styles.status}
|
||||
aria-live="polite"
|
||||
data-testid="legend-status"
|
||||
>
|
||||
{`Showing ${visibleCount} of ${totalCount} series`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import Legend from './Legend';
|
||||
|
||||
/**
|
||||
* uPlot legend controller. Derives the legend items + focus/visibility state
|
||||
* from the chart config (useLegendsSync) and the toggle/focus interactions from
|
||||
* the plot context (useLegendActions), then renders the presentational Legend.
|
||||
* from the chart config (useLegendsSync) and the series interactions from the
|
||||
* plot context (useLegendActions), then renders the presentational Legend.
|
||||
* Must be rendered inside a PlotContextProvider.
|
||||
*/
|
||||
export default function UPlotLegend({
|
||||
@@ -17,13 +17,8 @@ export default function UPlotLegend({
|
||||
config,
|
||||
averageLegendWidth,
|
||||
}: UPlotLegendProps): JSX.Element {
|
||||
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
|
||||
useLegendsSync({ config });
|
||||
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex,
|
||||
focusedSeriesIndex,
|
||||
});
|
||||
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
|
||||
const onAction = useLegendActions();
|
||||
|
||||
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
|
||||
|
||||
@@ -33,9 +28,7 @@ export default function UPlotLegend({
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import React from 'react';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
|
||||
import { useLegendActions } from '../../../hooks/useLegendActions';
|
||||
import UPlotLegend from '../UPlotLegend';
|
||||
import { LegendAction, LegendActionPayload, LegendPosition } from '../../types';
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
className,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
className?: string;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="virtuoso-grid" className={className}>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendActions');
|
||||
|
||||
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
|
||||
typeof useLegendsSync
|
||||
>;
|
||||
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
|
||||
typeof useLegendActions
|
||||
>;
|
||||
|
||||
/** The payloads of one action type, in dispatch order. */
|
||||
const dispatched = (
|
||||
onAction: jest.Mock,
|
||||
type: LegendAction,
|
||||
): LegendActionPayload[] =>
|
||||
onAction.mock.calls
|
||||
.map(([payload]) => payload as LegendActionPayload)
|
||||
.filter((payload) => payload.type === type);
|
||||
|
||||
describe('UPlotLegend', () => {
|
||||
const baseLegendItemsMap = {
|
||||
0: {
|
||||
seriesIndex: 0,
|
||||
label: 'A',
|
||||
show: true,
|
||||
color: '#ff0000',
|
||||
},
|
||||
1: {
|
||||
seriesIndex: 1,
|
||||
label: 'B',
|
||||
show: false,
|
||||
color: '#00ff00',
|
||||
},
|
||||
2: {
|
||||
seriesIndex: 2,
|
||||
label: 'C',
|
||||
show: true,
|
||||
color: '#0000ff',
|
||||
},
|
||||
};
|
||||
|
||||
let onAction: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
onAction = jest.fn();
|
||||
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: baseLegendItemsMap,
|
||||
focusedSeriesIndex: 1,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
|
||||
mockUseLegendActions.mockReturnValue(onAction);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderLegend = (position?: LegendPosition): RenderResult =>
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UPlotLegend
|
||||
position={position}
|
||||
// config is consumed by the mocked useLegendsSync hook, not directly
|
||||
config={{} as any}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
describe('layout and position', () => {
|
||||
it('renders the search input on a RIGHT legend', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
|
||||
renderLegend();
|
||||
|
||||
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('legend-status')).not.toBeInTheDocument();
|
||||
// The row interactions are the same in both placements.
|
||||
expect(screen.getByTestId('legend-item-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('legend-scope-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the marker with the series colour, filled only when shown', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
|
||||
),
|
||||
).toHaveStyle({
|
||||
'border-color': '#ff0000',
|
||||
'background-color': '#ff0000',
|
||||
});
|
||||
// Hidden series read as an empty checkbox.
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
|
||||
),
|
||||
).toHaveStyle({ 'background-color': 'transparent' });
|
||||
});
|
||||
|
||||
it('renders all legend items in the grid by default', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status readout', () => {
|
||||
it('reports how many series are showing', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-status')).toHaveTextContent(
|
||||
'Showing 2 of 3 series',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter behavior', () => {
|
||||
it('filters legend items based on the query (case-insensitive)', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'a');
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.queryByText('B')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('C')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when nothing matches', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'network');
|
||||
|
||||
expect(
|
||||
screen.getByText(/No series found matching "network"/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ignores a whitespace-only query', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), ' ');
|
||||
|
||||
expect(
|
||||
screen.queryByText(/No series found matching/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('row interactions', () => {
|
||||
const allShownItemsMap = {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1], show: true },
|
||||
2: { ...baseLegendItemsMap[2] },
|
||||
};
|
||||
|
||||
const mockAllShown = (): void => {
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: allShownItemsMap,
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
};
|
||||
|
||||
it('isolates the series when everything is showing', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockAllShown();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
// Nothing the user can see is there to exclude, so the click means Only.
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
|
||||
{ type: LegendAction.SHOW_ONLY, seriesIndex: 0 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('toggles the series once something is already hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes just that series when its marker is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockAllShown();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByTestId('legend-marker-0'));
|
||||
|
||||
// The marker is the one way to exclude a single series while
|
||||
// everything is showing — the row click isolates instead.
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stops the marker offering to hide the last series showing', () => {
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1] },
|
||||
2: { ...baseLegendItemsMap[2], show: false },
|
||||
},
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-marker-0')).toBeDisabled();
|
||||
expect(screen.getByTestId('legend-marker-1')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('labels the marker with what clicking it does', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-marker-0')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Hide A',
|
||||
);
|
||||
expect(screen.getByTestId('legend-marker-1')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Show B',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the clicked series to the selection while one is alone', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1] },
|
||||
2: { ...baseLegendItemsMap[2], show: false },
|
||||
},
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
// Series 0 is showing alone; clicking another row builds the selection
|
||||
// up rather than moving the isolation.
|
||||
await user.click(screen.getByText('B'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 1 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('toggles the series on Enter and Space', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const row = screen.getByTestId('legend-item-0');
|
||||
row.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
await user.keyboard(' ');
|
||||
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reflects visibility on the row for assistive tech', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-item-0')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByTestId('legend-item-1')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores every series from All without also toggling the row', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
// Series 0 is shown while B is hidden, so its action is All.
|
||||
await user.click(screen.getByTestId('legend-scope-0'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('isolates the series from Only on a hidden row', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByTestId('legend-scope-1'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
|
||||
{ type: LegendAction.SHOW_ONLY, seriesIndex: 1 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('highlights the hovered series and clears it on leave', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const row = screen.getByTestId('legend-item-0');
|
||||
await user.hover(row);
|
||||
expect(onAction).toHaveBeenCalledWith({
|
||||
type: LegendAction.HOVER,
|
||||
seriesIndex: 0,
|
||||
});
|
||||
|
||||
await user.unhover(row);
|
||||
expect(onAction).toHaveBeenCalledWith({
|
||||
type: LegendAction.HOVER,
|
||||
seriesIndex: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('one-series state', () => {
|
||||
const soleShownItemsMap = {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1] },
|
||||
2: { ...baseLegendItemsMap[2], show: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: soleShownItemsMap,
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('offers All on the shown row and Only on the hidden ones', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-scope-0')).toHaveTextContent('All');
|
||||
expect(screen.getByTestId('legend-scope-1')).toHaveTextContent('Only');
|
||||
expect(screen.getByTestId('legend-scope-2')).toHaveTextContent('Only');
|
||||
});
|
||||
|
||||
it('restores everything from All', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByTestId('legend-scope-0'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('restores everything when the row showing alone is clicked again', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { filterLegendItems, getShownSeriesState } from '../utils';
|
||||
|
||||
const items = (shown: boolean[]): LegendItem[] =>
|
||||
shown.map((show, index) => ({
|
||||
seriesIndex: index + 1,
|
||||
label: `series-${index}`,
|
||||
color: '#000',
|
||||
show,
|
||||
}));
|
||||
|
||||
describe('getShownSeriesState', () => {
|
||||
it('counts the shown series', () => {
|
||||
expect(getShownSeriesState(items([true, false, true]))).toStrictEqual({
|
||||
visibleCount: 2,
|
||||
soleShownSeriesIndex: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('names the series when exactly one is shown', () => {
|
||||
expect(getShownSeriesState(items([false, true, false]))).toStrictEqual({
|
||||
visibleCount: 1,
|
||||
soleShownSeriesIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports nothing shown', () => {
|
||||
expect(getShownSeriesState(items([false, false]))).toStrictEqual({
|
||||
visibleCount: 0,
|
||||
soleShownSeriesIndex: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterLegendItems', () => {
|
||||
it('matches case-insensitively on the label', () => {
|
||||
const filtered = filterLegendItems(items([true, true, true]), 'SERIES-1');
|
||||
expect(filtered.map((item) => item.label)).toStrictEqual(['series-1']);
|
||||
});
|
||||
|
||||
it('returns every item for a blank query', () => {
|
||||
expect(filterLegendItems(items([true, true]), ' ')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/uPlotV2/components/Legend/constants.ts
Normal file
20
frontend/src/lib/uPlotV2/components/Legend/constants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Widest a single legend item is allowed to get when sizing the legend grid. */
|
||||
export const MAX_LEGEND_WIDTH = 240;
|
||||
|
||||
/**
|
||||
* Enough for a row to contain its own hover actions, which a short label would
|
||||
* otherwise size a column too narrow for. Little room for the label is intended.
|
||||
*/
|
||||
export const MIN_LEGEND_ITEM_WIDTH = 110;
|
||||
|
||||
/** Marker + row padding, on top of the estimated label width. */
|
||||
export const LEGEND_ITEM_EXTRA_WIDTH = 16;
|
||||
|
||||
/** Must match `.row`'s height and the grid's row gap, or the reserved
|
||||
* rectangle clips a row. */
|
||||
export const LEGEND_ROW_HEIGHT = 28;
|
||||
export const LEGEND_ROW_GAP = 2;
|
||||
export const LEGEND_MAX_BOTTOM_ROWS = 2;
|
||||
|
||||
/** Hover delay before a row's full-name tooltip opens. */
|
||||
export const LEGEND_TOOLTIP_DELAY_MS = 500;
|
||||
34
frontend/src/lib/uPlotV2/components/Legend/utils.ts
Normal file
34
frontend/src/lib/uPlotV2/components/Legend/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
export interface ShownSeriesState {
|
||||
visibleCount: number;
|
||||
/** The series index when exactly one series is shown, else null. */
|
||||
soleShownSeriesIndex: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driven by what is actually shown, never a remembered isolation: hiding series
|
||||
* one at a time down to a single one is the same state as "Only".
|
||||
*/
|
||||
export function getShownSeriesState(items: LegendItem[]): ShownSeriesState {
|
||||
const shown = items.filter((item) => item.show);
|
||||
|
||||
return {
|
||||
visibleCount: shown.length,
|
||||
soleShownSeriesIndex: shown.length === 1 ? shown[0].seriesIndex : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterLegendItems(
|
||||
items: LegendItem[],
|
||||
query: string,
|
||||
): LegendItem[] {
|
||||
const normalisedQuery = query.trim().toLowerCase();
|
||||
if (!normalisedQuery) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((item) =>
|
||||
item.label?.toLowerCase().includes(normalisedQuery),
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Matches the legend row's marker.
|
||||
.uplotTooltipItemMarker {
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius);
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
border-width: 1.5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
@@ -30,11 +31,23 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// The legend's mono type; the container's Inter stays for the header.
|
||||
.uplotTooltipItemLabel,
|
||||
.uplotTooltipItemValue {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.uplotTooltipItemLabel {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.uplotTooltipItemValue {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.uplotTooltipItemContentSeparator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function TooltipItem({
|
||||
>
|
||||
<div
|
||||
className={Styles.uplotTooltipItemMarker}
|
||||
style={{ borderColor: item.color }}
|
||||
style={{ borderColor: item.color, backgroundColor: item.color }}
|
||||
data-is-legend-marker={true}
|
||||
data-testid={markerTestId}
|
||||
/>
|
||||
@@ -39,7 +39,7 @@ export default function TooltipItem({
|
||||
className={Styles.uplotTooltipItemContentSeparator}
|
||||
style={{ borderColor: item.color }}
|
||||
/>
|
||||
<span>{item.tooltipValue}</span>
|
||||
<span className={Styles.uplotTooltipItemValue}>{item.tooltipValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
|
||||
import { useLegendActions } from '../../hooks/useLegendActions';
|
||||
import UPlotLegend from '../Legend/UPlotLegend';
|
||||
import { LegendPosition } from '../types';
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
className,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
className?: string;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="virtuoso-grid" className={className}>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendActions');
|
||||
|
||||
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
|
||||
typeof useLegendsSync
|
||||
>;
|
||||
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
|
||||
typeof useLegendActions
|
||||
>;
|
||||
|
||||
describe('UPlotLegend', () => {
|
||||
const baseLegendItemsMap = {
|
||||
0: {
|
||||
seriesIndex: 0,
|
||||
label: 'A',
|
||||
show: true,
|
||||
color: '#ff0000',
|
||||
},
|
||||
1: {
|
||||
seriesIndex: 1,
|
||||
label: 'B',
|
||||
show: false,
|
||||
color: '#00ff00',
|
||||
},
|
||||
2: {
|
||||
seriesIndex: 2,
|
||||
label: 'C',
|
||||
show: true,
|
||||
color: '#0000ff',
|
||||
},
|
||||
};
|
||||
|
||||
let onLegendClick: jest.Mock;
|
||||
let onLegendMouseMove: jest.Mock;
|
||||
let onLegendMouseLeave: jest.Mock;
|
||||
let onFocusSeries: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
onLegendClick = jest.fn();
|
||||
onLegendMouseMove = jest.fn();
|
||||
onLegendMouseLeave = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: baseLegendItemsMap,
|
||||
focusedSeriesIndex: 1,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
|
||||
mockUseLegendActions.mockReturnValue({
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onFocusSeries,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderLegend = (position?: LegendPosition): RenderResult =>
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UPlotLegend
|
||||
position={position}
|
||||
// config is consumed by the mocked useLegendsSync hook, not directly
|
||||
config={{} as any}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
describe('layout and position', () => {
|
||||
it('renders search input when legend position is RIGHT', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render search input when legend position is BOTTOM (default)', () => {
|
||||
renderLegend();
|
||||
|
||||
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the marker with the correct border color', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const legendMarker = document.querySelector(
|
||||
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(legendMarker).toHaveStyle({
|
||||
'border-color': '#ff0000',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all legend items in the grid by default', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search behavior (RIGHT position)', () => {
|
||||
it('filters legend items based on search query (case-insensitive)', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, 'A');
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.queryByText('B')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('C')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no legend items match the search query', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, 'network');
|
||||
|
||||
expect(
|
||||
screen.getByText(/No series found matching "network"/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not filter or show empty state when search query is empty or only whitespace', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, ' ');
|
||||
|
||||
expect(
|
||||
screen.queryByText(/No series found matching/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('legend actions', () => {
|
||||
it('calls onLegendClick when a legend item is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
expect(onLegendClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls mouseMove when the mouse moves over a legend item', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const legendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
|
||||
await user.hover(legendItem);
|
||||
|
||||
expect(onLegendMouseMove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onLegendMouseLeave when the mouse leaves the legend container', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const container = document.querySelector('.legend-container') as HTMLElement;
|
||||
|
||||
await user.hover(container);
|
||||
await user.unhover(container);
|
||||
|
||||
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MouseEventHandler, ReactNode } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import uPlot from 'uplot';
|
||||
@@ -115,26 +115,39 @@ export enum LegendPosition {
|
||||
export interface LegendConfig {
|
||||
position: LegendPosition;
|
||||
}
|
||||
export enum LegendAction {
|
||||
TOGGLE = 'toggle',
|
||||
SHOW_ONLY = 'showOnly',
|
||||
SHOW_ALL = 'showAll',
|
||||
HOVER = 'hover',
|
||||
}
|
||||
|
||||
/** Everything the legend can ask of its container, as one dispatch. */
|
||||
export type LegendActionPayload =
|
||||
/** Row click / Space / Enter / marker click: hide or show that one series. */
|
||||
| { type: LegendAction.TOGGLE; seriesIndex: number }
|
||||
/** Show that series alone. */
|
||||
| { type: LegendAction.SHOW_ONLY; seriesIndex: number }
|
||||
/** Leave the narrowed selection and show every series. */
|
||||
| { type: LegendAction.SHOW_ALL }
|
||||
/** Row hover, for the chart-side highlight; null on leave. */
|
||||
| { type: LegendAction.HOVER; seriesIndex: number | null };
|
||||
|
||||
export type OnLegendAction = (payload: LegendActionPayload) => void;
|
||||
|
||||
/**
|
||||
* Presentational legend props. Source-agnostic: it renders whatever `items`
|
||||
* it's given and delegates interaction to the container handlers, so it serves
|
||||
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie). The search
|
||||
* box is intrinsic to the RIGHT position (derived from `position`, not a flag).
|
||||
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
|
||||
*/
|
||||
export interface LegendProps {
|
||||
items: LegendItem[];
|
||||
/** Legend placement; always supplied by the container. */
|
||||
position: LegendPosition;
|
||||
averageLegendWidth?: number;
|
||||
/** Series index to highlight (hovered/focused). */
|
||||
/** Series index highlighted by the chart cursor. */
|
||||
focusedSeriesIndex: number | null;
|
||||
/**
|
||||
* Container-delegated handlers. Items carry `data-legend-item-id`, so the
|
||||
* handler reads the target's id rather than binding per item.
|
||||
*/
|
||||
onClick: MouseEventHandler<HTMLDivElement>;
|
||||
onMouseMove: MouseEventHandler<HTMLDivElement>;
|
||||
onMouseLeave: () => void;
|
||||
onAction: OnLegendAction;
|
||||
/** Show the per-item copy button. Default true. */
|
||||
showCopy?: boolean;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ export const DEFAULT_HOVER_PROXIMITY_VALUE = 30; // only snap if within 30px hor
|
||||
export const DEFAULT_FOCUS_PROXIMITY_VALUE = 1e6;
|
||||
export const STEP_INTERVAL_MULTIPLIER = 3; // multiply the width computed by STEP_INTERVAL_MULTIPLIER to get the hover prox value
|
||||
|
||||
/** Opacity applied to the series that are NOT highlighted while a legend row is hovered. */
|
||||
export const LEGEND_HIGHLIGHT_DIM_ALPHA = 0.16;
|
||||
/** Stroke-width multiplier applied to the series highlighted from the legend. */
|
||||
export const LEGEND_HIGHLIGHT_WIDTH_RATIO = 1.6;
|
||||
|
||||
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
|
||||
focus: {
|
||||
alpha: 0.3,
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {
|
||||
LEGEND_HIGHLIGHT_DIM_ALPHA,
|
||||
LEGEND_HIGHLIGHT_WIDTH_RATIO,
|
||||
} from 'lib/uPlotV2/constants';
|
||||
import type { SeriesVisibilityItem } from 'lib/visualization/panels/types';
|
||||
import { updateSeriesVisibilityToLocalStorage } from 'lib/visualization/panels/utils/legendVisibilityUtils';
|
||||
import type uPlot from 'uplot';
|
||||
@@ -20,12 +24,26 @@ export interface IPlotContext {
|
||||
setPlotContextInitialState: (state: PlotContextInitialState) => void;
|
||||
onToggleSeriesVisibility: (seriesIndex: number) => void;
|
||||
onToggleSeriesOnOff: (seriesIndex: number) => void;
|
||||
/** Show this series alone. */
|
||||
onShowOnlySeries: (seriesIndex: number) => void;
|
||||
/** Show every series again. */
|
||||
onShowAllSeries: () => void;
|
||||
onFocusSeries: (seriesIndex: number | null) => void;
|
||||
/** Lift one series above the rest (dim + thicken) without changing visibility. */
|
||||
onHighlightSeries: (seriesIndex: number | null) => void;
|
||||
syncSeriesVisibilityToLocalStorage: () => void;
|
||||
}
|
||||
|
||||
export const PlotContext = createContext<IPlotContext | null>(null);
|
||||
|
||||
/** Data series (index 0 is the x-axis) currently drawn. */
|
||||
const countShownSeries = (plot: uPlot): number =>
|
||||
plot.series.reduce(
|
||||
(count, series, index) =>
|
||||
index > 0 && series.show !== false ? count + 1 : count,
|
||||
0,
|
||||
);
|
||||
|
||||
export const PlotContextProvider = ({
|
||||
children,
|
||||
}: PropsWithChildren): JSX.Element => {
|
||||
@@ -33,6 +51,9 @@ export const PlotContextProvider = ({
|
||||
const activeSeriesIndex = useRef<number | undefined>(undefined);
|
||||
const idRef = useRef<string | undefined>(undefined);
|
||||
const shouldSavePreferencesRef = useRef<boolean>(false);
|
||||
/** Pre-highlight stroke widths, captured on the first highlight so it can be undone. */
|
||||
const baseSeriesWidthsRef = useRef<Map<number, number | undefined>>(new Map());
|
||||
const highlightedSeriesIndexRef = useRef<number | null>(null);
|
||||
|
||||
const setPlotContextInitialState = useCallback(
|
||||
({
|
||||
@@ -43,6 +64,8 @@ export const PlotContextProvider = ({
|
||||
uPlotInstanceRef.current = uPlotInstance;
|
||||
idRef.current = id;
|
||||
activeSeriesIndex.current = undefined;
|
||||
baseSeriesWidthsRef.current = new Map();
|
||||
highlightedSeriesIndexRef.current = null;
|
||||
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
|
||||
},
|
||||
[],
|
||||
@@ -64,6 +87,54 @@ export const PlotContextProvider = ({
|
||||
updateSeriesVisibilityToLocalStorage(idRef.current, seriesVisibility);
|
||||
}, []);
|
||||
|
||||
const onHighlightSeries = useCallback((seriesIndex: number | null): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
return;
|
||||
}
|
||||
|
||||
highlightedSeriesIndexRef.current = seriesIndex;
|
||||
|
||||
plot.series.forEach((series, index) => {
|
||||
if (index === 0) {
|
||||
return;
|
||||
}
|
||||
if (!baseSeriesWidthsRef.current.has(index)) {
|
||||
baseSeriesWidthsRef.current.set(index, series.width);
|
||||
}
|
||||
const baseWidth = baseSeriesWidthsRef.current.get(index);
|
||||
const isHighlighted = index === seriesIndex;
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
series.alpha =
|
||||
seriesIndex === null || isHighlighted ? 1 : LEGEND_HIGHLIGHT_DIM_ALPHA;
|
||||
series.width =
|
||||
isHighlighted && baseWidth !== undefined
|
||||
? baseWidth * LEGEND_HIGHLIGHT_WIDTH_RATIO
|
||||
: baseWidth;
|
||||
/* eslint-enable no-param-reassign */
|
||||
});
|
||||
|
||||
// Only the stroke style changed, so the cached paths stay valid.
|
||||
plot.redraw(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Leaving the dim on a hidden series leaves every other one faded, which
|
||||
* reads as an isolation rather than as one series being excluded.
|
||||
*/
|
||||
const clearHighlightIfHidden = useCallback((): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
const highlightedIndex = highlightedSeriesIndexRef.current;
|
||||
if (!plot || highlightedIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (plot.series[highlightedIndex]?.show === false) {
|
||||
onHighlightSeries(null);
|
||||
}
|
||||
}, [onHighlightSeries]);
|
||||
|
||||
const onToggleSeriesVisibility = useCallback(
|
||||
(seriesIndex: number): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
@@ -103,14 +174,61 @@ export const PlotContextProvider = ({
|
||||
if (!series) {
|
||||
return;
|
||||
}
|
||||
|
||||
// An empty chart is never worth reaching.
|
||||
const isHiding = series.show !== false;
|
||||
if (isHiding && countShownSeries(plot) <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
plot.setSeries(seriesIndex, { show: !series.show });
|
||||
if (idRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
|
||||
clearHighlightIfHidden();
|
||||
},
|
||||
[syncSeriesVisibilityToLocalStorage],
|
||||
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
|
||||
);
|
||||
|
||||
/** Applies `resolveShow` to every data series in one batch, then persists. */
|
||||
const setSeriesVisibility = useCallback(
|
||||
(resolveShow: (seriesIndex: number) => boolean): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSeriesIndex.current = undefined;
|
||||
|
||||
plot.batch(() => {
|
||||
plot.series.forEach((_, index) => {
|
||||
if (index === 0) {
|
||||
return;
|
||||
}
|
||||
plot.setSeries(index, { show: resolveShow(index) });
|
||||
});
|
||||
if (idRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
});
|
||||
|
||||
clearHighlightIfHidden();
|
||||
},
|
||||
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
|
||||
);
|
||||
|
||||
const onShowOnlySeries = useCallback(
|
||||
(seriesIndex: number): void => {
|
||||
setSeriesVisibility((index) => index === seriesIndex);
|
||||
},
|
||||
[setSeriesVisibility],
|
||||
);
|
||||
|
||||
const onShowAllSeries = useCallback((): void => {
|
||||
setSeriesVisibility(() => true);
|
||||
}, [setSeriesVisibility]);
|
||||
|
||||
const onFocusSeries = useCallback((seriesIndex: number | null): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
@@ -131,14 +249,20 @@ export const PlotContextProvider = ({
|
||||
onToggleSeriesVisibility,
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
}),
|
||||
[
|
||||
onToggleSeriesVisibility,
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
|
||||
series,
|
||||
batch: jest.fn((fn: () => void) => fn()),
|
||||
setSeries: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
}) as unknown as uPlot;
|
||||
|
||||
interface TestComponentProps {
|
||||
@@ -44,7 +45,10 @@ const TestComponent = ({
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
} = usePlotContext();
|
||||
const handleInit = (): void => {
|
||||
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
|
||||
@@ -84,6 +88,13 @@ const TestComponent = ({
|
||||
>
|
||||
Toggle on/off 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-on-off-2"
|
||||
onClick={(): void => onToggleSeriesOnOff(2)}
|
||||
>
|
||||
Toggle on/off 2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-on-off-5"
|
||||
@@ -98,6 +109,34 @@ const TestComponent = ({
|
||||
>
|
||||
Focus series
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="show-only-1"
|
||||
onClick={(): void => onShowOnlySeries(1)}
|
||||
>
|
||||
Show only 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="show-all"
|
||||
onClick={(): void => onShowAllSeries()}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="highlight-1"
|
||||
onClick={(): void => onHighlightSeries(1)}
|
||||
>
|
||||
Highlight 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="clear-highlight"
|
||||
onClick={(): void => onHighlightSeries(null)}
|
||||
>
|
||||
Clear highlight
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -273,6 +312,7 @@ describe('PlotContext', () => {
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
@@ -324,6 +364,7 @@ describe('PlotContext', () => {
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
@@ -343,6 +384,48 @@ describe('PlotContext', () => {
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to hide the last series showing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
// An empty chart is never a state worth reaching.
|
||||
expect(plot.setSeries).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still shows a hidden series when only one is left showing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: true },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFocusSeries', () => {
|
||||
@@ -381,4 +464,193 @@ describe('PlotContext', () => {
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { focus: true }, false);
|
||||
});
|
||||
});
|
||||
describe('onShowOnlySeries', () => {
|
||||
const renderWithSeries = (
|
||||
series: MockSeries[],
|
||||
): { plot: uPlot; user: ReturnType<typeof userEvent.setup> } => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
return { plot, user };
|
||||
};
|
||||
|
||||
it('hides every other series, leaving the x-axis alone', async () => {
|
||||
const { plot, user } = renderWithSeries([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
]);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('show-only-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
|
||||
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps isolating the series that is already the only one shown', async () => {
|
||||
const { plot, user } = renderWithSeries([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('show-only-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onShowAllSeries', () => {
|
||||
it('shows every hidden series again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('show-all'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: true });
|
||||
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('onHighlightSeries', () => {
|
||||
const series = (): MockSeries[] => [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true, width: 2 },
|
||||
{ label: 'Memory', show: true, width: 2 },
|
||||
];
|
||||
|
||||
it('dims the other series and thickens the highlighted one', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
|
||||
expect(plot.series[1].alpha).toBe(1);
|
||||
expect(plot.series[1].width).toBe(3.2);
|
||||
expect(plot.series[2].alpha).toBe(0.16);
|
||||
expect(plot.series[2].width).toBe(2);
|
||||
// Only the stroke changed, so the cached paths are reused.
|
||||
expect(plot.redraw).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('restores every series when the highlight is cleared', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
await user.click(screen.getByTestId('clear-highlight'));
|
||||
|
||||
expect(plot.series[1].alpha).toBe(1);
|
||||
expect(plot.series[1].width).toBe(2);
|
||||
expect(plot.series[2].alpha).toBe(1);
|
||||
expect(plot.series[2].width).toBe(2);
|
||||
});
|
||||
|
||||
it('drops the dim when the highlighted series is hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
// The mock's setSeries doesn't mutate, so mirror what uPlot would do.
|
||||
(plot.setSeries as jest.Mock).mockImplementation(
|
||||
(index: number, opts: { show?: boolean }) => {
|
||||
if (typeof opts.show === 'boolean') {
|
||||
(plot.series[index] as MockSeries).show = opts.show;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
// Otherwise every remaining series stays faded and the panel reads as
|
||||
// an isolation instead of one series being excluded.
|
||||
expect(plot.series[2].alpha).toBe(1);
|
||||
expect(plot.series[2].width).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps the dim when a different series is hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
(plot.setSeries as jest.Mock).mockImplementation(
|
||||
(index: number, opts: { show?: boolean }) => {
|
||||
if (typeof opts.show === 'boolean') {
|
||||
(plot.series[index] as MockSeries).show = opts.show;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-2'));
|
||||
|
||||
expect(plot.series[1].alpha).toBe(1);
|
||||
expect(plot.series[2].alpha).toBe(0.16);
|
||||
});
|
||||
|
||||
it('leaves visibility untouched', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
|
||||
expect(plot.setSeries).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { LegendAction } from 'lib/uPlotV2/components/types';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
import { useLegendActions } from 'lib/uPlotV2/hooks/useLegendActions';
|
||||
|
||||
@@ -11,10 +12,12 @@ const mockUsePlotContext = usePlotContext as jest.MockedFunction<
|
||||
describe('useLegendActions', () => {
|
||||
let onToggleSeriesVisibility: jest.Mock;
|
||||
let onToggleSeriesOnOff: jest.Mock;
|
||||
let onFocusSeriesPlot: jest.Mock;
|
||||
let onShowOnlySeries: jest.Mock;
|
||||
let onShowAllSeries: jest.Mock;
|
||||
let onFocusSeries: jest.Mock;
|
||||
let onHighlightSeries: jest.Mock;
|
||||
let setPlotContextInitialState: jest.Mock;
|
||||
let syncSeriesVisibilityToLocalStorage: jest.Mock;
|
||||
let setFocusedSeriesIndexMock: jest.Mock;
|
||||
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -37,15 +40,20 @@ describe('useLegendActions', () => {
|
||||
beforeEach(() => {
|
||||
onToggleSeriesVisibility = jest.fn();
|
||||
onToggleSeriesOnOff = jest.fn();
|
||||
onFocusSeriesPlot = jest.fn();
|
||||
onShowOnlySeries = jest.fn();
|
||||
onShowAllSeries = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
onHighlightSeries = jest.fn();
|
||||
setPlotContextInitialState = jest.fn();
|
||||
syncSeriesVisibilityToLocalStorage = jest.fn();
|
||||
setFocusedSeriesIndexMock = jest.fn();
|
||||
|
||||
mockUsePlotContext.mockReturnValue({
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onFocusSeries: onFocusSeriesPlot,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
setPlotContextInitialState,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
});
|
||||
@@ -53,149 +61,65 @@ describe('useLegendActions', () => {
|
||||
cancelAnimationFrameSpy.mockClear();
|
||||
});
|
||||
|
||||
const createMouseEvent = (options: {
|
||||
legendItemId?: number;
|
||||
isMarker?: boolean;
|
||||
}): any => {
|
||||
const { legendItemId, isMarker = false } = options;
|
||||
describe('visibility actions', () => {
|
||||
it('toggles a single series on row click', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
return {
|
||||
target: {
|
||||
dataset: {
|
||||
...(isMarker ? { isLegendMarker: 'true' } : {}),
|
||||
},
|
||||
closest: jest.fn(() =>
|
||||
legendItemId !== undefined
|
||||
? { dataset: { legendItemId: String(legendItemId) } }
|
||||
: null,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
result.current({ type: LegendAction.TOGGLE, seriesIndex: 2 });
|
||||
|
||||
describe('onLegendClick', () => {
|
||||
it('toggles series visibility when clicking on legend label', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendClick(createMouseEvent({ legendItemId: 0 }));
|
||||
|
||||
expect(onToggleSeriesVisibility).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleSeriesVisibility).toHaveBeenCalledWith(0);
|
||||
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles series on/off when clicking on marker', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendClick(
|
||||
createMouseEvent({ legendItemId: 0, isMarker: true }),
|
||||
);
|
||||
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(0);
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(2);
|
||||
// The row must never isolate — that is what "Only" is for.
|
||||
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when click target is not inside a legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
it('forwards the Only and All actions to the plot', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendClick(createMouseEvent({}));
|
||||
result.current({ type: LegendAction.SHOW_ONLY, seriesIndex: 1 });
|
||||
result.current({ type: LegendAction.SHOW_ALL });
|
||||
|
||||
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
|
||||
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
expect(onShowOnlySeries).toHaveBeenCalledWith(1);
|
||||
expect(onShowAllSeries).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFocusSeries', () => {
|
||||
it('schedules focus update and calls plot focus handler via mouse move', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
describe('hover highlight', () => {
|
||||
it('highlights the hovered series', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 2 });
|
||||
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(0);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(0);
|
||||
expect(onHighlightSeries).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('cancels previous animation frame before scheduling new one on subsequent mouse moves', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
it('clears the highlight on leave', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: null });
|
||||
|
||||
expect(onHighlightSeries).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('coalesces rapid hovers into one frame', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 1 });
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 2 });
|
||||
|
||||
// Each new hover cancels the frame the previous one queued.
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLegendMouseMove', () => {
|
||||
it('focuses new series when hovering over different legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
it('cancels a pending highlight frame on unmount', () => {
|
||||
jest
|
||||
.spyOn(global, 'requestAnimationFrame')
|
||||
.mockImplementation((): number => 7);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
const { result, unmount } = renderHook(() => useLegendActions());
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 1 });
|
||||
unmount();
|
||||
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(1);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does nothing when hovering over already focused series', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
|
||||
expect(setFocusedSeriesIndexMock).not.toHaveBeenCalled();
|
||||
expect(onFocusSeriesPlot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLegendMouseLeave', () => {
|
||||
it('cancels pending animation frame and clears focus state', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current.onLegendMouseLeave();
|
||||
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(null);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(null);
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,117 +1,66 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
|
||||
export function useLegendActions({
|
||||
setFocusedSeriesIndex,
|
||||
focusedSeriesIndex,
|
||||
}: {
|
||||
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
|
||||
focusedSeriesIndex: number | null;
|
||||
}): {
|
||||
onLegendClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onFocusSeries: (seriesIndex: number | null) => void;
|
||||
onLegendMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
} {
|
||||
import {
|
||||
LegendAction,
|
||||
LegendActionPayload,
|
||||
OnLegendAction,
|
||||
} from '../components/types';
|
||||
|
||||
/**
|
||||
* Legend interactions, bound to the plot through PlotContext. Hover is coalesced
|
||||
* to one chart redraw per frame.
|
||||
*/
|
||||
export function useLegendActions(): OnLegendAction {
|
||||
const {
|
||||
onFocusSeries: onFocusSeriesPlot,
|
||||
onToggleSeriesOnOff,
|
||||
onToggleSeriesVisibility,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHighlightSeries,
|
||||
} = usePlotContext();
|
||||
|
||||
const rafId = useRef<number | null>(null); // requestAnimationFrame id
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
|
||||
const getLegendItemIdFromEvent = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>): string | undefined => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const legendItemElement = target.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
|
||||
return legendItemElement?.dataset.legendItemId;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onLegendClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const legendItemId = getLegendItemIdFromEvent(e);
|
||||
if (!legendItemId) {
|
||||
return;
|
||||
}
|
||||
const isLegendMarker = (e.target as HTMLElement).dataset.isLegendMarker;
|
||||
const seriesIndex = Number(legendItemId);
|
||||
|
||||
if (isLegendMarker) {
|
||||
onToggleSeriesOnOff(seriesIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
onToggleSeriesVisibility(seriesIndex);
|
||||
},
|
||||
[onToggleSeriesVisibility, onToggleSeriesOnOff, getLegendItemIdFromEvent],
|
||||
);
|
||||
|
||||
const onFocusSeries = useCallback(
|
||||
(seriesIndex: number | null): void => {
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
}
|
||||
rafId.current = requestAnimationFrame(() => {
|
||||
setFocusedSeriesIndex(seriesIndex);
|
||||
onFocusSeriesPlot(seriesIndex);
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[onFocusSeriesPlot],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const legendItemId = getLegendItemIdFromEvent(e);
|
||||
const seriesIndex = legendItemId ? Number(legendItemId) : null;
|
||||
if (seriesIndex === focusedSeriesIndex) {
|
||||
return;
|
||||
const cancelPendingHighlight = useCallback((): void => {
|
||||
if (rafIdRef.current != null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
onFocusSeries(seriesIndex);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onLegendMouseLeave = useCallback(
|
||||
(): void => {
|
||||
// Cancel any pending RAF from handleFocusSeries to prevent race condition
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
rafId.current = null;
|
||||
}
|
||||
setFocusedSeriesIndex(null);
|
||||
onFocusSeries(null);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[onFocusSeries],
|
||||
);
|
||||
useEffect(() => cancelPendingHighlight, [cancelPendingHighlight]);
|
||||
|
||||
// Cleanup pending animation frames on unmount
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
return useCallback(
|
||||
(payload: LegendActionPayload): void => {
|
||||
switch (payload.type) {
|
||||
case LegendAction.TOGGLE:
|
||||
onToggleSeriesOnOff(payload.seriesIndex);
|
||||
break;
|
||||
case LegendAction.SHOW_ONLY:
|
||||
onShowOnlySeries(payload.seriesIndex);
|
||||
break;
|
||||
case LegendAction.SHOW_ALL:
|
||||
onShowAllSeries();
|
||||
break;
|
||||
case LegendAction.HOVER: {
|
||||
const { seriesIndex } = payload;
|
||||
cancelPendingHighlight();
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
onHighlightSeries(seriesIndex);
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[
|
||||
cancelPendingHighlight,
|
||||
onHighlightSeries,
|
||||
onShowAllSeries,
|
||||
onShowOnlySeries,
|
||||
onToggleSeriesOnOff,
|
||||
],
|
||||
);
|
||||
return {
|
||||
onLegendClick,
|
||||
onFocusSeries,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,9 +45,7 @@ export default function Pie({
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onLegendAction,
|
||||
} = usePieInteractions(data, id);
|
||||
|
||||
const {
|
||||
@@ -227,9 +225,7 @@ export default function Pie({
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,17 +100,29 @@ describe('Pie', () => {
|
||||
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
|
||||
});
|
||||
|
||||
it('hides a slice when its legend marker is clicked', () => {
|
||||
it('isolates a slice when its legend row is clicked with everything showing', () => {
|
||||
renderPie();
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(3);
|
||||
|
||||
const marker = document.querySelector(
|
||||
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(marker);
|
||||
fireEvent.click(screen.getByTestId('legend-item-1'));
|
||||
|
||||
// Nothing visible to exclude, so the click isolates: one arc left.
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes a slice when its legend row is clicked with others already hidden', () => {
|
||||
renderPie();
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
|
||||
// Isolate, then add a second slice back, so nothing is isolated any more.
|
||||
fireEvent.click(screen.getByTestId('legend-item-1'));
|
||||
fireEvent.click(screen.getByTestId('legend-item-0'));
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(2);
|
||||
|
||||
fireEvent.click(screen.getByTestId('legend-item-0'));
|
||||
|
||||
// One slice hidden → one fewer arc drawn.
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(2);
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
|
||||
import {
|
||||
calculateAverageLegendWidth,
|
||||
calculateChartDimensions,
|
||||
} from 'lib/visualization/charts/utils';
|
||||
|
||||
const labels = (count: number, length = 20): string[] =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
@@ -49,63 +52,104 @@ describe('calculateChartDimensions', () => {
|
||||
expect(dims.width).toBe(784);
|
||||
});
|
||||
|
||||
it('RIGHT: never shrinks the column below the 150px floor', () => {
|
||||
it('RIGHT: never shrinks the column below the floor that fits its chrome', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(3, 3),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(150);
|
||||
expect(dims.width).toBe(850);
|
||||
expect(dims.legendWidth).toBe(190);
|
||||
expect(dims.width).toBe(810);
|
||||
});
|
||||
|
||||
it('RIGHT: on a narrow container the legend never takes more than 40% of the width', () => {
|
||||
it('RIGHT: on a narrow container the legend keeps its chrome, up to half the width', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 300,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(10, 40),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(120);
|
||||
expect(dims.width).toBe(180);
|
||||
// 40% is 120px, too narrow for the column's own toolbar.
|
||||
expect(dims.legendWidth).toBe(150);
|
||||
expect(dims.width).toBe(150);
|
||||
});
|
||||
|
||||
it('BOTTOM: a single row of items reserves one legend row', () => {
|
||||
it('RIGHT: stops widening the column once the panel is narrower than its chrome', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 200,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(10, 40),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(100);
|
||||
expect(dims.width).toBe(100);
|
||||
});
|
||||
|
||||
it('BOTTOM: items that fit one row reserve exactly one row', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(3),
|
||||
});
|
||||
// One row = line height (28) + padding (12).
|
||||
// One 28px row + the wrapper's 12px bottom padding.
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
expect(dims.height).toBe(460);
|
||||
expect(dims.legendWidth).toBe(1000);
|
||||
});
|
||||
|
||||
it('BOTTOM: many items cap at two rows on a tall container', () => {
|
||||
it('BOTTOM: more items than one row reserve exactly two rows', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
|
||||
expect(dims.legendHeight).toBe(68);
|
||||
expect(dims.height).toBe(432);
|
||||
// Two 28px rows + the 2px row gap + 12px bottom padding — no room for a
|
||||
// clipped third row, and none left over.
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
expect(dims.height).toBe(430);
|
||||
});
|
||||
|
||||
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
|
||||
it('BOTTOM: items one past a row still reserve two rows', () => {
|
||||
// 1000px wide fits 5 of these per row, so 6 items need a second row.
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 160,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(6),
|
||||
});
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
});
|
||||
|
||||
it('BOTTOM: drops to a single row rather than take half a short panel', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 120,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Without the height-relative cap the legend would take 68px of a 160px
|
||||
// panel and the chart (pie especially) collapses to a sliver.
|
||||
expect(dims.legendHeight).toBe(48); // 30% of 160
|
||||
expect(dims.height).toBe(112);
|
||||
// A whole row goes rather than a clipped one being reserved.
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
expect(dims.height).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateAverageLegendWidth', () => {
|
||||
it('scales with the label length', () => {
|
||||
// 16px of chrome + 30 chars at 8px.
|
||||
expect(calculateAverageLegendWidth(labels(4, 30))).toBe(256);
|
||||
});
|
||||
|
||||
it('never drops below what a row needs to contain its hover actions', () => {
|
||||
// Short or unnamed series would otherwise size a column the actions
|
||||
// escape, spilling over the item beside it.
|
||||
expect(calculateAverageLegendWidth(['cpu'])).toBe(110);
|
||||
expect(calculateAverageLegendWidth([''])).toBe(110);
|
||||
});
|
||||
|
||||
it('keeps the default estimate when there are no labels to measure', () => {
|
||||
expect(calculateAverageLegendWidth([])).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import {
|
||||
LEGEND_MAX_BOTTOM_ROWS,
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
LEGEND_ROW_GAP,
|
||||
LEGEND_ROW_HEIGHT,
|
||||
MAX_LEGEND_WIDTH,
|
||||
} from 'lib/uPlotV2/components/Legend/constants';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
export interface ChartDimensions {
|
||||
width: number;
|
||||
@@ -13,22 +19,31 @@ const LEGEND_WIDTH_PERCENTILE = 0.85;
|
||||
const DEFAULT_AVG_LABEL_LENGTH = 15;
|
||||
const BASE_LEGEND_WIDTH = 16;
|
||||
const LEGEND_PADDING = 12;
|
||||
const LEGEND_LINE_HEIGHT = 28;
|
||||
// Two rows are worth having, but not at the cost of half the panel.
|
||||
const MAX_SHORT_PANEL_LEGEND_RATIO = 0.5;
|
||||
|
||||
// RIGHT legend is a vertical column with its own width budget (cap protects the donut).
|
||||
const MAX_RIGHT_LEGEND_WIDTH = 320;
|
||||
const RIGHT_LEGEND_WIDTH_RATIO = 0.4;
|
||||
// Column padding + copy button, not covered by the text-length estimate.
|
||||
const RIGHT_LEGEND_RESERVED_WIDTH = 40;
|
||||
// Fits the toolbar's "Showing N of M series" readout plus the wrapper padding.
|
||||
const MIN_RIGHT_LEGEND_WIDTH = 190;
|
||||
// Past this the split inverts and the chart becomes the smaller half.
|
||||
const RIGHT_LEGEND_FLOOR_RATIO = 0.5;
|
||||
|
||||
/**
|
||||
* Calculates the average width of the legend items based on the labels of the series.
|
||||
* Never returns less than a legend row needs to hold its own hover actions.
|
||||
* @param legends - The labels of the series.
|
||||
* @returns The average width of the legend items.
|
||||
*/
|
||||
export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
if (legends.length === 0) {
|
||||
return DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
|
||||
return Math.max(
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
|
||||
@@ -36,7 +51,10 @@ export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
|
||||
const percentileLength = lengths[Math.max(0, index)];
|
||||
|
||||
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
|
||||
return Math.max(
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +70,9 @@ export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
* - Chart width is `containerWidth - legendWidth`.
|
||||
* - BOTTOM legend:
|
||||
* - Computes how many items fit per row, then uses at most 2 rows.
|
||||
* - `legendHeight` is derived from row count, capped by both a fixed pixel max and a % of container height.
|
||||
* - `legendHeight` is exactly those rows plus the wrapper's bottom padding, so
|
||||
* the rectangle never clips a row or reserves space for half of one. Two
|
||||
* rows that would take half a short panel fall back to one row.
|
||||
* - Chart height is `containerHeight - legendHeight`, never below 0.
|
||||
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
|
||||
*
|
||||
@@ -100,9 +120,14 @@ export function calculateChartDimensions({
|
||||
MAX_RIGHT_LEGEND_WIDTH,
|
||||
containerWidth * RIGHT_LEGEND_WIDTH_RATIO,
|
||||
);
|
||||
// The column's chrome outranks the 40% share on a narrow panel.
|
||||
const floorWidth = Math.min(
|
||||
MIN_RIGHT_LEGEND_WIDTH,
|
||||
containerWidth * RIGHT_LEGEND_FLOOR_RATIO,
|
||||
);
|
||||
const rightLegendWidth = Math.min(
|
||||
Math.max(150, desiredLegendWidth),
|
||||
maxRightLegendWidth,
|
||||
Math.max(MIN_RIGHT_LEGEND_WIDTH, desiredLegendWidth),
|
||||
Math.max(floorWidth, maxRightLegendWidth),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -115,8 +140,6 @@ export function calculateChartDimensions({
|
||||
};
|
||||
}
|
||||
|
||||
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
|
||||
|
||||
const legendItemWidth = Math.ceil(
|
||||
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
|
||||
);
|
||||
@@ -125,30 +148,30 @@ export function calculateChartDimensions({
|
||||
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
|
||||
);
|
||||
|
||||
const legendRowCount = Math.min(
|
||||
2,
|
||||
Math.ceil(legendItemCount / legendItemsPerRow),
|
||||
// The wrapper's bottom padding is inside this height (border-box).
|
||||
const heightForRows = (rowCount: number): number =>
|
||||
rowCount * LEGEND_ROW_HEIGHT +
|
||||
(rowCount - 1) * LEGEND_ROW_GAP +
|
||||
LEGEND_PADDING;
|
||||
|
||||
const neededRowCount = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
LEGEND_MAX_BOTTOM_ROWS,
|
||||
Math.ceil(legendItemCount / legendItemsPerRow),
|
||||
),
|
||||
);
|
||||
|
||||
const idealBottomLegendHeight =
|
||||
legendRowCount > 1
|
||||
? legendRowCount * legendRowHeight - LEGEND_PADDING
|
||||
: legendRowHeight;
|
||||
// Without this, short grid panels hand most of their area to the legend and
|
||||
// the chart — the pie donut especially — collapses to a sliver. Dropping a
|
||||
// whole row beats clipping one.
|
||||
const legendRowCount =
|
||||
neededRowCount > 1 &&
|
||||
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO
|
||||
? 1
|
||||
: neededRowCount;
|
||||
|
||||
// Cap at two rows / 80px, and never more than 30% of the container height
|
||||
// (the doc above always promised the %-cap; without it, short grid panels
|
||||
// hand most of their area to the legend and the chart — the pie donut
|
||||
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
|
||||
const maxAllowedLegendHeight = Math.min(
|
||||
2 * legendRowHeight,
|
||||
80,
|
||||
Math.floor(containerHeight * 0.3),
|
||||
);
|
||||
|
||||
const bottomLegendHeight = Math.min(
|
||||
idealBottomLegendHeight,
|
||||
maxAllowedLegendHeight,
|
||||
);
|
||||
const bottomLegendHeight = heightForRows(legendRowCount);
|
||||
|
||||
return {
|
||||
width: containerWidth,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { LegendAction } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
updateSeriesVisibilityToLocalStorage,
|
||||
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { PieSlice } from 'lib/visualization/charts/types';
|
||||
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
|
||||
@@ -24,22 +24,6 @@ const DATA: PieSlice[] = [
|
||||
{ label: 'checkout', value: 40, color: '#c' },
|
||||
];
|
||||
|
||||
// Builds a fake legend click/move event: `e.target.closest('[data-legend-item-id]')`
|
||||
// resolves to the item at `index`, and `e.target.dataset.isLegendMarker` flags marker clicks.
|
||||
function legendEvent(
|
||||
index: number | null,
|
||||
isMarker = false,
|
||||
): MouseEvent<HTMLDivElement> {
|
||||
const itemEl =
|
||||
index == null ? null : { dataset: { legendItemId: String(index) } };
|
||||
return {
|
||||
target: {
|
||||
closest: (): unknown => itemEl,
|
||||
dataset: { isLegendMarker: isMarker ? 'true' : undefined },
|
||||
},
|
||||
} as unknown as MouseEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
describe('usePieInteractions', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStored.mockReturnValue(null);
|
||||
@@ -59,11 +43,16 @@ describe('usePieInteractions', () => {
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
|
||||
describe('marker click (toggle one)', () => {
|
||||
describe('row toggle', () => {
|
||||
it('hides then unhides the clicked slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
|
||||
expect(result.current.legendItems[1].show).toBe(false);
|
||||
@@ -73,18 +62,50 @@ describe('usePieInteractions', () => {
|
||||
{ label: 'checkout', show: true },
|
||||
]);
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
expect(result.current.legendItems[1].show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('label click (isolate / reset)', () => {
|
||||
it('isolates the clicked slice, then resets on a second click', () => {
|
||||
describe('the last slice showing', () => {
|
||||
it('cannot be hidden', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// An empty donut is never a state worth reaching.
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Only', () => {
|
||||
it('isolates the slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
|
||||
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
|
||||
@@ -92,8 +113,39 @@ describe('usePieInteractions', () => {
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
it('switches the isolation to another slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('All', () => {
|
||||
it('brings every hidden slice back', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
});
|
||||
@@ -103,11 +155,37 @@ describe('usePieInteractions', () => {
|
||||
it('focuses the hovered slice and clears on leave', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(2)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 2 }),
|
||||
);
|
||||
expect(result.current.active).toStrictEqual(DATA[2]);
|
||||
expect(result.current.focusedSeriesIndex).toBe(2);
|
||||
|
||||
act(() => result.current.onLegendMouseLeave());
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.HOVER,
|
||||
seriesIndex: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current.active).toBeNull();
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the focus when the focused slice is hidden', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
// Otherwise every remaining arc stays dimmed and the donut reads as an
|
||||
// isolation instead of one slice being excluded.
|
||||
expect(result.current.active).toBeNull();
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
@@ -115,8 +193,15 @@ describe('usePieInteractions', () => {
|
||||
it('does not focus a hidden slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(1)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
|
||||
);
|
||||
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
@@ -125,7 +210,12 @@ describe('usePieInteractions', () => {
|
||||
describe('persistence', () => {
|
||||
it('does not write to storage when no id is provided', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
act(() => result.current.onLegendClick(legendEvent(0, true)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
expect(mockUpdateStored).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
LegendAction,
|
||||
LegendActionPayload,
|
||||
OnLegendAction,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
@@ -18,27 +23,15 @@ export interface UsePieInteractionsResult {
|
||||
legendItems: LegendItem[];
|
||||
/** Index of the active slice for the legend's focus highlight, or null. */
|
||||
focusedSeriesIndex: number | null;
|
||||
onLegendClick: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseMove: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
}
|
||||
|
||||
// Reads the slice index off the nearest `[data-legend-item-id]` ancestor of the
|
||||
// event target (the shared Legend tags each item with its seriesIndex).
|
||||
function getLegendIndex(e: MouseEvent<HTMLDivElement>): number | null {
|
||||
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
const id = el?.dataset.legendItemId;
|
||||
return id != null ? Number(id) : null;
|
||||
/** Every legend interaction, dispatched by type. */
|
||||
onLegendAction: OnLegendAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pie interaction + derived state: hover/focus, slice hide/unhide (mirroring the
|
||||
* uPlot legend — marker toggles one, label isolates), and persistence of the
|
||||
* hidden set to localStorage (keyed by `id`, matched by label) so it survives
|
||||
* reloads. Returns the visible slices, legend items, focus index, and the
|
||||
* legend container handlers.
|
||||
* Pie interaction + derived state: hover/focus, slice hide/show driven by the
|
||||
* shared legend's actions, and persistence of the hidden set to localStorage
|
||||
* (keyed by `id`, matched by label) so it survives reloads. Returns the visible
|
||||
* slices, legend items, focus index, and the legend action dispatch.
|
||||
*/
|
||||
export function usePieInteractions(
|
||||
data: PieSlice[],
|
||||
@@ -48,7 +41,6 @@ export function usePieInteractions(
|
||||
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const isolatedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
@@ -104,65 +96,88 @@ export function usePieInteractions(
|
||||
[id, data],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
const hoverSeries = useCallback(
|
||||
(sliceIndex: number | null): void => {
|
||||
// Don't focus/dim for hidden slices — they aren't on the donut.
|
||||
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
|
||||
setActive(
|
||||
sliceIndex != null && !hiddenIndices.has(sliceIndex)
|
||||
? data[sliceIndex]
|
||||
: null,
|
||||
);
|
||||
},
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// Marker click toggles just that slice on/off; label click isolates it
|
||||
// (clicking the isolated one again resets to all) — mirrors the uPlot legend.
|
||||
const onLegendClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
if (index == null) {
|
||||
return;
|
||||
}
|
||||
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
|
||||
|
||||
if (isMarker) {
|
||||
const next = new Set(hiddenIndices);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
const toggleSeries = useCallback(
|
||||
(sliceIndex: number): void => {
|
||||
const next = new Set(hiddenIndices);
|
||||
if (next.has(sliceIndex)) {
|
||||
next.delete(sliceIndex);
|
||||
} else {
|
||||
// An empty donut is never worth reaching.
|
||||
if (data.length - next.size <= 1) {
|
||||
return;
|
||||
}
|
||||
applyHidden(next);
|
||||
return;
|
||||
next.add(sliceIndex);
|
||||
}
|
||||
applyHidden(next);
|
||||
},
|
||||
[data.length, hiddenIndices, applyHidden],
|
||||
);
|
||||
|
||||
const isReset = isolatedIndexRef.current === index;
|
||||
isolatedIndexRef.current = isReset ? null : index;
|
||||
if (isReset) {
|
||||
applyHidden(new Set());
|
||||
return;
|
||||
}
|
||||
const showOnlySeries = useCallback(
|
||||
(sliceIndex: number): void => {
|
||||
const next = new Set<number>();
|
||||
data.forEach((_, i) => {
|
||||
if (i !== index) {
|
||||
next.add(i);
|
||||
data.forEach((_, index) => {
|
||||
if (index !== sliceIndex) {
|
||||
next.add(index);
|
||||
}
|
||||
});
|
||||
applyHidden(next);
|
||||
},
|
||||
[data, hiddenIndices, applyHidden],
|
||||
[data, applyHidden],
|
||||
);
|
||||
|
||||
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
|
||||
const showAllSeries = useCallback(
|
||||
(): void => applyHidden(new Set()),
|
||||
[applyHidden],
|
||||
);
|
||||
|
||||
const focusedIndex = active ? data.indexOf(active) : -1;
|
||||
const onLegendAction = useCallback(
|
||||
(payload: LegendActionPayload): void => {
|
||||
switch (payload.type) {
|
||||
case LegendAction.TOGGLE:
|
||||
toggleSeries(payload.seriesIndex);
|
||||
break;
|
||||
case LegendAction.SHOW_ONLY:
|
||||
showOnlySeries(payload.seriesIndex);
|
||||
break;
|
||||
case LegendAction.SHOW_ALL:
|
||||
showAllSeries();
|
||||
break;
|
||||
case LegendAction.HOVER:
|
||||
hoverSeries(payload.seriesIndex);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[toggleSeries, showOnlySeries, showAllSeries, hoverSeries],
|
||||
);
|
||||
|
||||
const activeIndex = active ? data.indexOf(active) : -1;
|
||||
// Left active, a hidden slice keeps every other arc dimmed, which reads as an
|
||||
// isolation rather than as one slice being excluded.
|
||||
const effectiveActive =
|
||||
activeIndex >= 0 && !hiddenIndices.has(activeIndex) ? active : null;
|
||||
const focusedIndex = effectiveActive ? activeIndex : -1;
|
||||
|
||||
return {
|
||||
active,
|
||||
active: effectiveActive,
|
||||
setActive,
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onLegendAction,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding: 0 12px 12px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/constants';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -29,29 +30,31 @@ import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/service
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboardCollectionPermissions } from 'hooks/dashboards/useDashboardCollectionPermissions';
|
||||
import { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import DisabledMenuItemLabel from '../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { DashboardCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import MenuActionItem from '../../components/MenuActionItem/MenuActionItem';
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
|
||||
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
|
||||
import SettingsDrawer from '../SettingsDrawer';
|
||||
import menuStyles from '../../components/MenuActionItem/MenuActionItem.module.scss';
|
||||
import styles from './DashboardActions.module.scss';
|
||||
import { useDeleteDashboardAction } from './useDeleteDashboardAction';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../../hooks/useDashboardEditGuard';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
|
||||
interface DashboardActionsProps {
|
||||
title: string;
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
handle: FullScreenHandle;
|
||||
isDashboardLocked: boolean;
|
||||
isAuthor: boolean;
|
||||
onAddPanel: () => void;
|
||||
onLockToggle: () => void;
|
||||
onOpenRename: () => void;
|
||||
@@ -62,19 +65,37 @@ function DashboardActions({
|
||||
dashboard,
|
||||
handle,
|
||||
isDashboardLocked,
|
||||
isAuthor,
|
||||
onAddPanel,
|
||||
onLockToggle,
|
||||
onOpenRename,
|
||||
}: DashboardActionsProps): JSX.Element {
|
||||
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
|
||||
const isLocked = useDashboardStore((s) => s.isLocked);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const {
|
||||
isLocked,
|
||||
isEditable,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
canDeleteDashboard,
|
||||
canReadDashboard,
|
||||
} = useDashboardEditContext();
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { canCreate } = useDashboardCollectionPermissions();
|
||||
const { canToggleLock, disabledTooltip: lockDisabledTooltip } =
|
||||
useDashboardLockPermission({
|
||||
dashboardId: dashboard.id,
|
||||
source: dashboard.source,
|
||||
});
|
||||
|
||||
// Cloning creates a new dashboard, so it needs `create` too, and no lock gate.
|
||||
const cloneChecks = useMemo(
|
||||
() => [...editChecks.slice(0, 1), DashboardCreatePermission],
|
||||
[editChecks],
|
||||
);
|
||||
const cloneDenied = !canCreate || !canReadDashboard;
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
@@ -157,101 +178,110 @@ function DashboardActions({
|
||||
void handle.enter();
|
||||
}, [dashboard.id, handle]);
|
||||
|
||||
// Shown only to edit-permitted users, so the only disabled reason is the lock.
|
||||
const editLabel = useCallback(
|
||||
(text: string): ReactNode =>
|
||||
isLocked ? (
|
||||
<DisabledMenuItemLabel reason={DASHBOARD_LOCKED_REASON}>
|
||||
{text}
|
||||
</DisabledMenuItemLabel>
|
||||
) : (
|
||||
text
|
||||
),
|
||||
[isLocked],
|
||||
// Unavailable items stay in the menu, carrying the reason.
|
||||
// The row carries icon, label and reason; the item keeps `disabled`/`onClick`.
|
||||
const row = useCallback(
|
||||
(
|
||||
text: string,
|
||||
icon: ReactElement,
|
||||
checks: BrandedPermission[],
|
||||
opts: { disabledTooltip?: string; destructive?: boolean } = {},
|
||||
): ReactNode => (
|
||||
<MenuActionItem
|
||||
label={text}
|
||||
icon={icon}
|
||||
checks={checks}
|
||||
disabledTooltip={opts.disabledTooltip}
|
||||
destructive={opts.destructive}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const dashboardGroup: MenuItem[] = [];
|
||||
if (canEditDashboard) {
|
||||
dashboardGroup.push({
|
||||
const dashboardGroup: MenuItem[] = [
|
||||
{
|
||||
key: 'rename',
|
||||
label: editLabel('Rename'),
|
||||
icon: <PenLine size={14} />,
|
||||
disabled: isLocked,
|
||||
label: row('Rename', <PenLine size={14} />, editChecks, {
|
||||
disabledTooltip: editDisabledTooltip,
|
||||
}),
|
||||
disabled: !isEditable,
|
||||
onClick: onOpenRename,
|
||||
});
|
||||
},
|
||||
// Clone creates a new dashboard, so it's not lock-gated.
|
||||
dashboardGroup.push({
|
||||
{
|
||||
key: 'clone',
|
||||
label: 'Clone dashboard',
|
||||
icon: <Copy size={14} />,
|
||||
disabled: isCloning,
|
||||
label: row('Clone dashboard', <Copy size={14} />, cloneChecks),
|
||||
disabled: isCloning || cloneDenied,
|
||||
onClick: (): void => void handleClone(),
|
||||
});
|
||||
}
|
||||
|
||||
if (canEditDashboard && (isAuthor || user.role === USER_ROLES.ADMIN)) {
|
||||
dashboardGroup.push({
|
||||
},
|
||||
{
|
||||
key: 'lock',
|
||||
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
|
||||
icon: <LockKeyhole size={14} />,
|
||||
disabled: dashboard.createdBy === 'integration',
|
||||
label: row(
|
||||
isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
|
||||
<LockKeyhole size={14} />,
|
||||
editChecks,
|
||||
{ disabledTooltip: lockDisabledTooltip },
|
||||
),
|
||||
disabled: !canToggleLock,
|
||||
onClick: onLockToggle,
|
||||
});
|
||||
}
|
||||
dashboardGroup.push({
|
||||
key: 'fullscreen',
|
||||
label: 'Full screen',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: handleEnterFullScreen,
|
||||
});
|
||||
},
|
||||
{
|
||||
key: 'fullscreen',
|
||||
label: row('Full screen', <Fullscreen size={14} />, []),
|
||||
onClick: handleEnterFullScreen,
|
||||
},
|
||||
];
|
||||
|
||||
const items: MenuItem[] = [
|
||||
return [
|
||||
{
|
||||
type: 'group',
|
||||
key: 'group-dashboard',
|
||||
label: 'Dashboard',
|
||||
children: dashboardGroup,
|
||||
},
|
||||
];
|
||||
// Omit the whole Layout group (header included) in view mode.
|
||||
if (canEditDashboard) {
|
||||
items.push({
|
||||
{
|
||||
type: 'group',
|
||||
key: 'group-layout',
|
||||
label: 'Layout',
|
||||
children: [
|
||||
{
|
||||
key: 'new-section',
|
||||
label: editLabel('New section'),
|
||||
icon: <SquareStack size={14} />,
|
||||
disabled: isLocked,
|
||||
label: row('New section', <SquareStack size={14} />, editChecks, {
|
||||
disabledTooltip: editDisabledTooltip,
|
||||
}),
|
||||
disabled: !isEditable,
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
},
|
||||
],
|
||||
});
|
||||
items.push(
|
||||
{ type: 'divider', key: 'divider-danger' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: editLabel('Delete dashboard'),
|
||||
icon: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
disabled: isLocked,
|
||||
onClick: confirmDeleteDashboard,
|
||||
},
|
||||
);
|
||||
}
|
||||
return items;
|
||||
},
|
||||
{ type: 'divider', key: 'divider-danger' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: row('Delete dashboard', <Trash2 size={14} />, deleteChecks, {
|
||||
disabledTooltip: deleteDisabledTooltip,
|
||||
destructive: true,
|
||||
}),
|
||||
// Independent of read/update, but a locked dashboard can't be removed.
|
||||
disabled: isLocked || !canDeleteDashboard,
|
||||
onClick: confirmDeleteDashboard,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
editLabel,
|
||||
canEditDashboard,
|
||||
row,
|
||||
isEditable,
|
||||
isLocked,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
canDeleteDashboard,
|
||||
cloneChecks,
|
||||
cloneDenied,
|
||||
isCloning,
|
||||
isAuthor,
|
||||
user.role,
|
||||
canToggleLock,
|
||||
lockDisabledTooltip,
|
||||
isDashboardLocked,
|
||||
dashboard.createdBy,
|
||||
onOpenRename,
|
||||
handleClone,
|
||||
onLockToggle,
|
||||
@@ -261,7 +291,10 @@ function DashboardActions({
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardActionsContainer}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<DropdownMenuSimple
|
||||
menu={{ items: menuItems }}
|
||||
className={menuStyles.menuContent}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -273,38 +306,31 @@ function DashboardActions({
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
{canEditDashboard && (
|
||||
<>
|
||||
<DisabledControlTooltip
|
||||
reason={DASHBOARD_LOCKED_REASON}
|
||||
disabled={isLocked}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={isLocked}
|
||||
onClick={handleOpenSettings}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</DisabledControlTooltip>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
destroyOnClose
|
||||
onClose={(): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
clearSettingsRequest();
|
||||
}}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={!isEditable}
|
||||
onClick={handleOpenSettings}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
destroyOnClose
|
||||
onClose={(): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
clearSettingsRequest();
|
||||
}}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -316,24 +342,19 @@ function DashboardActions({
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
{canEditDashboard && (
|
||||
<DisabledControlTooltip
|
||||
reason={DASHBOARD_LOCKED_REASON}
|
||||
disabled={isLocked}
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={!isEditable}
|
||||
size="md"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={isLocked}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</DisabledControlTooltip>
|
||||
)}
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<JsonEditorDrawer
|
||||
dashboard={dashboard}
|
||||
isOpen={isJsonEditorOpen}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDenyAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { IsAdminPermission } from 'lib/authz/hooks/useAuthZ/legacy';
|
||||
import {
|
||||
buildDashboardDeletePermission,
|
||||
buildDashboardReadPermission,
|
||||
buildDashboardUpdatePermission,
|
||||
DashboardListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import DashboardActions from '../DashboardActions';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
|
||||
const dashboard = {
|
||||
id: DASHBOARD_ID,
|
||||
createdBy: 'someone-else@signoz.io',
|
||||
locked: false,
|
||||
spec: { display: { name: 'D' }, panels: {}, layouts: [], variables: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
// Composition is what's under test here; the derivation has its own suite.
|
||||
const mockEditContext = {
|
||||
isEditable: true,
|
||||
editChecks: [],
|
||||
areOtherPermissionsLoading: false,
|
||||
deleteChecks: [],
|
||||
isLocked: false,
|
||||
canEditDashboard: true,
|
||||
canDeleteDashboard: true,
|
||||
editDisabledTooltip: '',
|
||||
deleteDisabledTooltip: '',
|
||||
};
|
||||
function setEditContextMock(next: Partial<typeof mockEditContext>): void {
|
||||
Object.assign(mockEditContext, {
|
||||
isEditable: true,
|
||||
isLocked: false,
|
||||
canEditDashboard: true,
|
||||
canDeleteDashboard: true,
|
||||
editDisabledTooltip: '',
|
||||
deleteDisabledTooltip: '',
|
||||
...next,
|
||||
});
|
||||
}
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): typeof mockEditContext => mockEditContext,
|
||||
}),
|
||||
);
|
||||
|
||||
// The dropdown trigger's testId is swallowed by Radix's asChild clone.
|
||||
function openActionsMenu(): Promise<void> {
|
||||
return userEvent.click(screen.getByRole('button', { name: /Actions/ }));
|
||||
}
|
||||
|
||||
function renderActions(): ReturnType<typeof render> {
|
||||
return render(
|
||||
<DashboardActions
|
||||
title="D"
|
||||
dashboard={dashboard}
|
||||
handle={
|
||||
{
|
||||
active: false,
|
||||
enter: jest.fn(),
|
||||
exit: jest.fn(),
|
||||
node: { current: null },
|
||||
} as never
|
||||
}
|
||||
isDashboardLocked={false}
|
||||
onAddPanel={jest.fn()}
|
||||
onLockToggle={jest.fn()}
|
||||
onOpenRename={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('DashboardActions - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('permission denied', () => {
|
||||
// These controls used to be removed from the DOM entirely.
|
||||
it('keeps the toolbar buttons visible and disabled', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
setEditContextMock({
|
||||
isEditable: false,
|
||||
canEditDashboard: false,
|
||||
editDisabledTooltip: 'no permission',
|
||||
});
|
||||
|
||||
renderActions();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('show-drawer')).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByTestId('add-panel-header')).toBeDisabled();
|
||||
// JSON stays available — it's a read-only inspect.
|
||||
expect(screen.getByTestId('edit-json')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('keeps the menu items present and disabled', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
setEditContextMock({
|
||||
isEditable: false,
|
||||
canEditDashboard: false,
|
||||
canDeleteDashboard: false,
|
||||
editDisabledTooltip: 'no permission',
|
||||
deleteDisabledTooltip: 'no permission',
|
||||
});
|
||||
|
||||
renderActions();
|
||||
await openActionsMenu();
|
||||
|
||||
await expect(screen.findByText('Rename')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('New section')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clone dashboard')).toBeInTheDocument();
|
||||
// Full screen never depended on permission.
|
||||
expect(screen.getByText('Full screen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('partial permissions', () => {
|
||||
// Delete is independent of read/update (authz guide rule 3).
|
||||
it('enables delete for a user who can only delete', async () => {
|
||||
server.use(setupAuthzAllow(buildDashboardDeletePermission(DASHBOARD_ID)));
|
||||
setEditContextMock({
|
||||
isEditable: false,
|
||||
canEditDashboard: false,
|
||||
editDisabledTooltip: 'no permission',
|
||||
});
|
||||
|
||||
renderActions();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('show-drawer')).toBeDisabled();
|
||||
});
|
||||
await openActionsMenu();
|
||||
await expect(
|
||||
screen.findByText('Delete dashboard'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables clone when create is denied but edit is allowed', async () => {
|
||||
server.use(
|
||||
setupAuthzAllow(
|
||||
buildDashboardReadPermission(DASHBOARD_ID),
|
||||
buildDashboardUpdatePermission(DASHBOARD_ID),
|
||||
DashboardListPermission,
|
||||
IsAdminPermission,
|
||||
),
|
||||
);
|
||||
setEditContextMock({});
|
||||
|
||||
renderActions();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('show-drawer')).toBeEnabled();
|
||||
});
|
||||
await openActionsMenu();
|
||||
await expect(
|
||||
screen.findByText('Clone dashboard'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
it('enables the toolbar for a full-rights user', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
setEditContextMock({});
|
||||
|
||||
renderActions();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('show-drawer')).toBeEnabled();
|
||||
});
|
||||
expect(screen.getByTestId('add-panel-header')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/Toolt
|
||||
import TagsOverflowTooltip from './TagsOverflowTooltip';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
|
||||
// The tag cluster keeps a fixed footprint so a long title ellipsizes around it
|
||||
// instead of collapsing the tags: show up to two tags, then a `+N` overflow badge.
|
||||
@@ -43,6 +44,8 @@ interface DashboardInfoProps {
|
||||
showLockToggle: boolean;
|
||||
/** When provided, the lock icon toggles lock/unlock (author/admin only). */
|
||||
onToggleLock?: () => void;
|
||||
/** Why the toggle is unavailable, when onToggleLock is absent. */
|
||||
lockDisabledTooltip?: string;
|
||||
isEditing: boolean;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
@@ -61,6 +64,7 @@ function DashboardInfo({
|
||||
isDashboardLocked,
|
||||
showLockToggle,
|
||||
onToggleLock,
|
||||
lockDisabledTooltip,
|
||||
isEditing,
|
||||
draft,
|
||||
onDraftChange,
|
||||
@@ -68,7 +72,7 @@ function DashboardInfo({
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: DashboardInfoProps): JSX.Element {
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const { isEditable: canEdit } = useDashboardEditContext();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
const hasTags = tags.length > 0;
|
||||
@@ -77,11 +81,15 @@ function DashboardInfo({
|
||||
const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);
|
||||
const remainingTags = tags.slice(MAX_VISIBLE_TAGS);
|
||||
|
||||
// Without a toggle, say why it can't be toggled rather than only restating the
|
||||
// lock state.
|
||||
let lockTooltip: string;
|
||||
if (onToggleLock) {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'Locked — click to unlock'
|
||||
: 'Unlocked — click to lock';
|
||||
} else if (lockDisabledTooltip) {
|
||||
lockTooltip = lockDisabledTooltip;
|
||||
} else {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'This dashboard is locked'
|
||||
|
||||
@@ -16,8 +16,8 @@ import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
|
||||
import styles from './JsonEditorDrawer.module.scss';
|
||||
import JsonEditorToolbar from './JsonEditorToolbar';
|
||||
import { useJsonEditor } from './useJsonEditor';
|
||||
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
|
||||
interface JsonEditorDrawerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -30,10 +30,13 @@ function JsonEditorDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: JsonEditorDrawerProps): JSX.Element {
|
||||
const {
|
||||
isEditable,
|
||||
editDisabledTooltip: readOnlyTooltip,
|
||||
editChecks: readOnlyChecks,
|
||||
} = useDashboardEditContext();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const readOnlyReason = useDashboardStore((s) => s.editDisabledReason);
|
||||
// Inspect-only when not editable: Apply/Format/Reset disabled.
|
||||
const readOnly = !isEditable;
|
||||
|
||||
@@ -175,7 +178,10 @@ function JsonEditorDrawer({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<DisabledControlTooltip reason={readOnlyReason} disabled={readOnly}>
|
||||
<AuthZTooltip
|
||||
checks={readOnlyChecks}
|
||||
disabledTooltip={readOnly ? readOnlyTooltip : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
@@ -186,7 +192,7 @@ function JsonEditorDrawer({
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
</DisabledControlTooltip>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
@@ -10,8 +10,21 @@ jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
|
||||
// Editable by default so the drawer renders in its editable (non-read-only) mode.
|
||||
jest.mock('../../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { isEditable: boolean; editDisabledReason: string }) => unknown,
|
||||
): unknown => selector({ isEditable: true, editDisabledReason: '' }),
|
||||
selector: (s: {
|
||||
isEditable: boolean;
|
||||
editChecks: unknown[];
|
||||
deleteChecks: unknown[];
|
||||
areOtherPermissionsLoading: boolean;
|
||||
editDisabledTooltip: string;
|
||||
}) => unknown,
|
||||
): unknown =>
|
||||
selector({
|
||||
isEditable: true,
|
||||
editChecks: [],
|
||||
areOtherPermissionsLoading: false,
|
||||
deleteChecks: [],
|
||||
editDisabledTooltip: '',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@monaco-editor/react', () => ({
|
||||
@@ -65,6 +78,26 @@ function hookValue(
|
||||
} as ReturnType<typeof useJsonEditor>;
|
||||
}
|
||||
|
||||
// The derivation has its own suite (useDashboardEditContext.authz); these cases are
|
||||
// about what the UI does with a given edit context, so control it directly.
|
||||
const mockEditContext = {
|
||||
isEditable: true,
|
||||
editChecks: [],
|
||||
deleteChecks: [],
|
||||
areOtherPermissionsLoading: false,
|
||||
isLocked: false,
|
||||
canEditDashboard: true,
|
||||
canDeleteDashboard: true,
|
||||
editDisabledTooltip: '',
|
||||
deleteDisabledTooltip: '',
|
||||
};
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): typeof mockEditContext => mockEditContext,
|
||||
}),
|
||||
);
|
||||
|
||||
describe('JsonEditorDrawer', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
@@ -95,7 +128,7 @@ describe('JsonEditorDrawer', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({ danglingPanelIds: ['p1', 'p2'] }),
|
||||
);
|
||||
const { rerender } = render(
|
||||
const { unmount } = render(
|
||||
<TooltipProvider>
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
@@ -104,8 +137,11 @@ describe('JsonEditorDrawer', () => {
|
||||
'2 panels not present in layout',
|
||||
);
|
||||
|
||||
// Mounted fresh rather than re-rendered: the provider wrapper holds the
|
||||
// first tree, so a rerender does not pick up the new hook value.
|
||||
unmount();
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ danglingPanelIds: [] }));
|
||||
rerender(
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
lockDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { resolveDashboardImage } from 'pages/DashboardPage/DashboardContainer/dashboardIcons';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
|
||||
import { useToggleDashboardLock } from 'hooks/dashboards/useToggleDashboardLock';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
@@ -42,7 +35,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { dashboard, handle } = props;
|
||||
|
||||
const id = dashboard.id;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Session-local lock state: the toggle appears once locked and persists for the page.
|
||||
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
|
||||
@@ -64,7 +56,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
[dashboard.tags],
|
||||
);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const {
|
||||
@@ -75,59 +66,51 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
|
||||
// Author/admin can lock-unlock (mirrors the Actions menu gate); integration-owned
|
||||
// dashboards are never toggleable.
|
||||
const canToggleLock =
|
||||
(isAuthor || user.role === USER_ROLES.ADMIN) &&
|
||||
dashboard.createdBy !== 'integration';
|
||||
// dashboard:update, then the backend's source and creator-or-admin rules.
|
||||
const { canToggleLock, disabledTooltip: lockDisabledTooltip } =
|
||||
useDashboardLockPermission({
|
||||
dashboardId: id,
|
||||
source: dashboard.source,
|
||||
});
|
||||
|
||||
// Public-sharing meta (deduped react-query read); drives the header globe.
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
|
||||
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
|
||||
|
||||
// Shared with the list's row menu — it owns the API call, the toast and the
|
||||
// detail-cache patch; the optimistic local state and this page's event stay here.
|
||||
const lockSource = useRef<'menu' | 'header'>('header');
|
||||
const { toggleLock } = useToggleDashboardLock({
|
||||
dashboardId: id,
|
||||
isLocked: isDashboardLocked,
|
||||
onSuccess: (locked) => {
|
||||
void logEvent(DashboardDetailEvents.LockToggled, {
|
||||
dashboardId: id,
|
||||
dashboardName: title,
|
||||
locked,
|
||||
source: lockSource.current,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsDashboardLocked(isDashboardLocked);
|
||||
showErrorModal(error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleLockDashboardToggle = useCallback(
|
||||
async (source: 'menu' | 'header'): Promise<void> => {
|
||||
(source: 'menu' | 'header'): void => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
lockSource.current = source;
|
||||
const next = !isDashboardLocked;
|
||||
setIsDashboardLocked(next);
|
||||
if (next) {
|
||||
setShowLockToggle(true);
|
||||
}
|
||||
try {
|
||||
if (next) {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
} else {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
}
|
||||
// Patch just the `locked` flag in the cache — a full refetch would reload
|
||||
// every panel's chart data for a metadata-only change.
|
||||
const key = getGetDashboardV2QueryKey({ id });
|
||||
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
|
||||
if (cached) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(key, {
|
||||
...cached,
|
||||
data: { ...cached.data, locked: next },
|
||||
});
|
||||
}
|
||||
void logEvent(DashboardDetailEvents.LockToggled, {
|
||||
dashboardId: id,
|
||||
dashboardName: title,
|
||||
locked: next,
|
||||
source,
|
||||
});
|
||||
} catch (error) {
|
||||
setIsDashboardLocked(!next);
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
toggleLock();
|
||||
},
|
||||
[id, title, isDashboardLocked, queryClient, showErrorModal],
|
||||
[id, isDashboardLocked, toggleLock],
|
||||
);
|
||||
|
||||
const onNameSave = useCallback(
|
||||
@@ -184,9 +167,10 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={
|
||||
canToggleLock
|
||||
? (): void => void handleLockDashboardToggle('header')
|
||||
? (): void => handleLockDashboardToggle('header')
|
||||
: undefined
|
||||
}
|
||||
lockDisabledTooltip={lockDisabledTooltip}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
@@ -199,9 +183,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
isAuthor={isAuthor}
|
||||
onAddPanel={onAddPanel}
|
||||
onLockToggle={(): void => void handleLockDashboardToggle('menu')}
|
||||
onLockToggle={(): void => handleLockDashboardToggle('menu')}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user