mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-11 05:50:41 +01:00
Compare commits
5 Commits
ns/trace-a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee9f97f28 | ||
|
|
878e938b65 | ||
|
|
206aad1acd | ||
|
|
f6cd4d31b4 | ||
|
|
2387266df5 |
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.
|
||||
@@ -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,
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
|
||||
import {
|
||||
applyCheckboxToggle,
|
||||
clearFilterFromQuery,
|
||||
deriveCheckboxState,
|
||||
getNotInOperator,
|
||||
} from './checkboxFilterQuery';
|
||||
import { clearFilterFromQuery } from '../shared/filterQuery';
|
||||
import { CheckedState } from '../../types';
|
||||
import { SectionType } from './v2/itemRules';
|
||||
|
||||
@@ -505,7 +505,7 @@ describe('clearFilterFromQuery', () => {
|
||||
|
||||
const result = clearFilterFromQuery({
|
||||
currentQuery: query,
|
||||
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
|
||||
filterKey: KEY,
|
||||
activeQueryIndex: 0,
|
||||
});
|
||||
|
||||
@@ -523,4 +523,35 @@ describe('clearFilterFromQuery', () => {
|
||||
expect(other.filters?.items).toHaveLength(1);
|
||||
expect(other.filter?.expression).toBe(`${KEY} = 'a'`);
|
||||
});
|
||||
|
||||
it('without an operators list, clears non-managed clauses too (duration >= / <=)', () => {
|
||||
const query = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
toTagItem({ key: 'durationNano', op: '>=', value: 5000000 }, 0),
|
||||
toTagItem({ key: 'durationNano', op: '<=', value: 9000000 }, 1),
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: {
|
||||
expression: `durationNano >= 5000000 AND durationNano <= 9000000 AND http.method = 'GET'`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as Query;
|
||||
|
||||
const result = clearFilterFromQuery({
|
||||
currentQuery: query,
|
||||
filterKey: 'durationNano',
|
||||
activeQueryIndex: 0,
|
||||
});
|
||||
|
||||
const active = result.builder.queryData[0];
|
||||
expect(active.filters?.items).toStrictEqual([]);
|
||||
expect(active.filter?.expression).toBe(`http.method = 'GET'`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,12 @@ export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
|
||||
// The operators this algebra emits, and so the only ones it may rewrite out of an
|
||||
// expression. A hand-written clause on the same key (CONTAINS, EXISTS, a range) is
|
||||
// none of its business and has to survive a toggle.
|
||||
const MANAGED_OPERATORS = [OPERATORS['='], OPERATORS['!='], 'in', 'not in'];
|
||||
export const MANAGED_OPERATORS = [
|
||||
OPERATORS['='],
|
||||
OPERATORS['!='],
|
||||
'in',
|
||||
'not in',
|
||||
];
|
||||
|
||||
/**
|
||||
* Drops this filter's own clauses for `key` from `expression`, leaving every other
|
||||
@@ -31,7 +36,7 @@ const MANAGED_OPERATORS = [OPERATORS['='], OPERATORS['!='], 'in', 'not in'];
|
||||
* prefixes, since `isKeyMatch` treats `service.name` and `resource.service.name` as
|
||||
* the same filter but expression rewrites match keys literally.
|
||||
*/
|
||||
function removeManagedClauses(expression: string, key: string): string {
|
||||
export function removeManagedClauses(expression: string, key: string): string {
|
||||
return removeKeysFromExpression(
|
||||
expression,
|
||||
getKeySpellings(key),
|
||||
@@ -124,49 +129,6 @@ export function deriveCheckboxState({
|
||||
return filterState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new query with this filter's clauses for the attribute key removed from
|
||||
* the active query, both from the structured filter items and the raw expression.
|
||||
*/
|
||||
export function clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filter,
|
||||
activeQueryIndex,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
filter: IQuickFiltersConfig;
|
||||
activeQueryIndex: number;
|
||||
}): Query {
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => {
|
||||
if (idx !== activeQueryIndex) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeManagedClauses(
|
||||
item.filter?.expression ?? '',
|
||||
filter.attributeKey.key,
|
||||
),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
|
||||
) || [],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function applyCheckboxToggle({
|
||||
currentQuery,
|
||||
|
||||
@@ -4,13 +4,11 @@ import {
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { isFunction } from 'lodash-es';
|
||||
import { isEqual, isFunction } from 'lodash-es';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
applyCheckboxToggle,
|
||||
clearFilterFromQuery,
|
||||
} from './checkboxFilterQuery';
|
||||
import { applyCheckboxToggle, MANAGED_OPERATORS } from './checkboxFilterQuery';
|
||||
import { clearFilterFromQuery } from '../shared/filterQuery';
|
||||
import { CheckedState } from '../../types';
|
||||
import { SectionType } from './v2/itemRules';
|
||||
|
||||
@@ -94,7 +92,17 @@ function useCheckboxFilterActions({
|
||||
};
|
||||
|
||||
const onClear = (): void => {
|
||||
dispatch(clearFilterFromQuery({ currentQuery, filter, activeQueryIndex }));
|
||||
const clearedQuery = clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filterKey: filter.attributeKey.key,
|
||||
activeQueryIndex,
|
||||
operators: MANAGED_OPERATORS,
|
||||
});
|
||||
// Nothing to clear; no dispatch
|
||||
if (isEqual(clearedQuery, currentQuery)) {
|
||||
return;
|
||||
}
|
||||
dispatch(clearedQuery);
|
||||
};
|
||||
|
||||
return { onChange, onClear };
|
||||
|
||||
@@ -6,6 +6,44 @@
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.sectionActions {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
width 0s linear 0.16s;
|
||||
}
|
||||
|
||||
.checkboxFilter:hover .sectionActions,
|
||||
.sectionActions.sectionActionsPinned {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.sectionActionsPinned .sectionActionHoverOnly {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.checkboxFilter:hover .sectionActionsPinned .sectionActionHoverOnly {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.search {
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Skeleton } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
import {
|
||||
@@ -44,8 +45,14 @@ export default function CheckboxFilterV2(
|
||||
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
|
||||
props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [isSearchOpen, setIsSearchOpen] = useState<boolean>(false);
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
|
||||
const handleToggleSearch = (): void => {
|
||||
setIsSearchOpen((prev) => !prev);
|
||||
setSearchText('');
|
||||
};
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
@@ -164,12 +171,15 @@ export default function CheckboxFilterV2(
|
||||
<CheckboxFilterV2Header
|
||||
title={filter.title}
|
||||
isOpen={isOpen}
|
||||
showClearAll={!!attributeValues.length}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
isSomeFilterPresentForCurrentAttribute={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
actionsClassName={classNames(styles.sectionActions, {
|
||||
[styles.sectionActionsPinned]: isSearchOpen,
|
||||
})}
|
||||
resetActionClassName={
|
||||
isSearchOpen ? styles.sectionActionHoverOnly : undefined
|
||||
}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
onClear={onClear}
|
||||
/>
|
||||
{isOpen && isLoading && !hasLoadedOnce.current && (
|
||||
<section>
|
||||
@@ -178,23 +188,26 @@ export default function CheckboxFilterV2(
|
||||
)}
|
||||
{isOpen && (!isLoading || hasLoadedOnce.current) && (
|
||||
<>
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
data-testid="checkbox-filter-search"
|
||||
suffix={
|
||||
isFetching ? (
|
||||
<LoaderCircle
|
||||
size={14}
|
||||
className={styles.searchSpinner}
|
||||
data-testid="checkbox-filter-search-loading"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
{isSearchOpen && (
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
data-testid="checkbox-filter-search"
|
||||
suffix={
|
||||
isFetching ? (
|
||||
<LoaderCircle
|
||||
size={14}
|
||||
className={styles.searchSpinner}
|
||||
data-testid="checkbox-filter-search-loading"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{totalCount > 0 && (
|
||||
<section className={styles.values}>
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.leftAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
// The collapse chevron must keep its size; only the title absorbs the squeeze.
|
||||
> svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -18,16 +26,18 @@
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
text-transform: capitalize;
|
||||
// Always ellipsize a long name; on hover the actions take width and it
|
||||
// compresses further.
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rightAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.clearAll {
|
||||
font-size: 12px;
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
gap: var(--spacing-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { ChevronDown, ChevronRight, Search, Undo2 } from '@signozhq/icons';
|
||||
|
||||
import { SectionActionButton } from '../../shared/SectionActionButton/SectionActionButton';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import styles from './CheckboxFilterV2Header.module.scss';
|
||||
|
||||
interface CheckboxFilterHeaderProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
showClearAll: boolean;
|
||||
actionsClassName?: string;
|
||||
resetActionClassName?: string;
|
||||
onToggleOpen: () => void;
|
||||
onToggleSearch: () => void;
|
||||
onClear: () => void;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Header({
|
||||
title,
|
||||
isOpen,
|
||||
showClearAll,
|
||||
actionsClassName,
|
||||
resetActionClassName,
|
||||
onToggleOpen,
|
||||
onToggleSearch,
|
||||
onClear,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
const [isTitleTruncated, setIsTitleTruncated] = useState(false);
|
||||
|
||||
const measureTitle = (el: HTMLElement | null): void => {
|
||||
if (el) {
|
||||
setIsTitleTruncated(el.scrollWidth > el.clientWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const titleText = (
|
||||
<Typography.Text ref={measureTitle} className={styles.title}>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
role="button"
|
||||
@@ -40,23 +62,31 @@ export function CheckboxFilterV2Header({
|
||||
) : (
|
||||
<ChevronRight size={13} cursor="pointer" />
|
||||
)}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.rightAction}>
|
||||
{isOpen && showClearAll && isSomeFilterPresentForCurrentAttribute && (
|
||||
<Typography.Text
|
||||
className={styles.clearAll}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClear();
|
||||
}}
|
||||
data-testid="checkbox-filter-clear-all"
|
||||
>
|
||||
Clear
|
||||
</Typography.Text>
|
||||
{isTitleTruncated ? (
|
||||
<TooltipSimple title={title} delayDuration={400}>
|
||||
{titleText}
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
titleText
|
||||
)}
|
||||
</section>
|
||||
{isOpen && (
|
||||
<section className={classNames(styles.rightAction, actionsClassName)}>
|
||||
<SectionActionButton
|
||||
icon={<Undo2 size={14} />}
|
||||
className={resetActionClassName}
|
||||
tooltip="Reset"
|
||||
onClick={onClear}
|
||||
testId="checkbox-filter-clear-all"
|
||||
/>
|
||||
<SectionActionButton
|
||||
icon={<Search size={14} />}
|
||||
tooltip="Search"
|
||||
onClick={onToggleSearch}
|
||||
testId="checkbox-filter-search-toggle"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -143,6 +144,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
// Related values now appear in "Related" section (no badge, uses divider instead)
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -192,6 +194,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -236,6 +239,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'xyz-no-match');
|
||||
|
||||
@@ -343,6 +347,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-pod-a-v1');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'pod-a');
|
||||
|
||||
@@ -515,7 +520,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter applied for attribute', async () => {
|
||||
it('shows the reset action when expanded even with no active filter', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
@@ -530,9 +535,45 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
// Reset is always available on an expanded section now (hover-gated via
|
||||
// CSS), not conditional on an active filter.
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not dispatch on clear when the key has no filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onFilterChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when clear clicked', async () => {
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import { CheckboxFilterV2Header } from '../CheckboxFilterV2Header';
|
||||
|
||||
@@ -7,9 +8,8 @@ describe('CheckboxFilterV2Header', () => {
|
||||
const defaultProps = {
|
||||
title: 'Environment',
|
||||
isOpen: false,
|
||||
showClearAll: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
onToggleOpen: jest.fn(),
|
||||
onToggleSearch: jest.fn(),
|
||||
onClear: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -31,11 +31,12 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
});
|
||||
|
||||
it('does not show clear button when collapsed', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen={false} showClearAll />,
|
||||
);
|
||||
it('does not render the section actions when collapsed', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-search-toggle'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -50,36 +51,13 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
});
|
||||
|
||||
it('shows clear button when expanded + showClearAll=true', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll />);
|
||||
it('renders both search and reset actions when expanded', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-search-toggle'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when showClearAll=false', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll={false} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter present for attribute', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
showClearAll
|
||||
isSomeFilterPresentForCurrentAttribute={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,28 +100,35 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClear on clear button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen onClear={onClear} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clear button click does not trigger onToggleOpen', async () => {
|
||||
it('calls onToggleSearch on search click without toggling open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleSearch = jest.fn();
|
||||
const onToggleOpen = jest.fn();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
onToggleSearch={onToggleSearch}
|
||||
onToggleOpen={onToggleOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
|
||||
expect(onToggleSearch).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClear on reset click without toggling open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
onClear={onClear}
|
||||
onToggleOpen={onToggleOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -153,4 +138,51 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(onToggleOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('title tooltip', () => {
|
||||
// jsdom has no layout, so truncation is simulated at the prototype level
|
||||
// before mount (the component measures in a layout effect).
|
||||
function mockTitleWidths(scrollWidth: number, clientWidth: number): void {
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
|
||||
.mockReturnValue(scrollWidth);
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
|
||||
.mockReturnValue(clientWidth);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows the full name on hover when the title is truncated', async () => {
|
||||
mockTitleWidths(200, 100);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<CheckboxFilterV2Header {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await user.hover(screen.getByText(defaultProps.title));
|
||||
|
||||
await expect(screen.findByRole('tooltip')).resolves.toHaveTextContent(
|
||||
defaultProps.title,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows no tooltip when the title fits', async () => {
|
||||
mockTitleWidths(100, 100);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<CheckboxFilterV2Header {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await user.hover(screen.getByText(defaultProps.title));
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,21 @@
|
||||
padding-right: 9px !important;
|
||||
}
|
||||
|
||||
.duration-reset {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
&:hover .duration-reset {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ant-collapse-header-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
@@ -105,11 +120,6 @@
|
||||
.section-body-header {
|
||||
display: flex;
|
||||
|
||||
> button {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
padding-top: 13px;
|
||||
}
|
||||
.ant-collapse {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Collapse } from 'antd';
|
||||
import { Collapse } from 'antd';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
@@ -14,12 +15,16 @@ import {
|
||||
AllTraceFilterKeys,
|
||||
AllTraceFilterKeyValue,
|
||||
HandleRunProps,
|
||||
traceFilterKeys,
|
||||
unionTagFilterItems,
|
||||
} from 'pages/TracesExplorer/Filter/filterUtils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { clearFilterFromQuery } from '../shared/filterQuery';
|
||||
import { SectionActionButton } from '../shared/SectionActionButton/SectionActionButton';
|
||||
|
||||
import './Duration.styles.scss';
|
||||
|
||||
export type FilterType = Record<
|
||||
@@ -268,12 +273,19 @@ function Duration({
|
||||
handleRun();
|
||||
}, [selectedFilters]);
|
||||
|
||||
const onClearHandler = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedFilters?.durationNanoMin || selectedFilters?.durationNanoMax) {
|
||||
handleRun({ clearByType: 'durationNano' });
|
||||
const onClearHandler = (): void => {
|
||||
if (!selectedFilters?.durationNanoMin && !selectedFilters?.durationNanoMax) {
|
||||
return;
|
||||
}
|
||||
const clearedQuery = clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filterKey: traceFilterKeys.durationNano.key,
|
||||
activeQueryIndex,
|
||||
});
|
||||
if (onFilterChange && isFunction(onFilterChange)) {
|
||||
onFilterChange(clearedQuery);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(clearedQuery);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -294,18 +306,19 @@ function Duration({
|
||||
/>
|
||||
),
|
||||
label: 'Duration',
|
||||
extra: activeKeys.includes('durationNano') ? (
|
||||
<div className="duration-reset">
|
||||
<SectionActionButton
|
||||
icon={<Undo2 size={14} />}
|
||||
tooltip="Reset"
|
||||
onClick={onClearHandler}
|
||||
testId="collapse-duration-clearBtn"
|
||||
/>
|
||||
</div>
|
||||
) : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{activeKeys.includes('durationNano') && (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={onClearHandler}
|
||||
data-testid="collapse-duration-clearBtn"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.iconBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import styles from './SectionActionButton.module.scss';
|
||||
|
||||
interface SectionActionButtonProps {
|
||||
icon: ReactNode;
|
||||
tooltip: string;
|
||||
onClick: () => void;
|
||||
testId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionActionButton({
|
||||
icon,
|
||||
tooltip,
|
||||
onClick,
|
||||
testId,
|
||||
className,
|
||||
}: SectionActionButtonProps): JSX.Element {
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={classNames(styles.iconBtn, className)}
|
||||
onMouseDown={(e): void => e.preventDefault()}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
data-testid={testId}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getKeySpellings, isKeyMatch } from '../Checkbox/utils';
|
||||
|
||||
/**
|
||||
* Returns a new query with this filter's clauses for the attribute key removed from
|
||||
* the active query, both from the structured filter items and the raw expression.
|
||||
* `operators` limits which expression clauses are removed; omit to remove every
|
||||
* clause on the key (e.g. duration's >= / <=).
|
||||
*/
|
||||
export function clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filterKey,
|
||||
activeQueryIndex,
|
||||
operators,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
filterKey: string;
|
||||
activeQueryIndex: number;
|
||||
operators?: string[];
|
||||
}): Query {
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => {
|
||||
if (idx !== activeQueryIndex) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(
|
||||
item.filter?.expression ?? '',
|
||||
getKeySpellings(filterKey),
|
||||
false,
|
||||
operators,
|
||||
),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filterKey),
|
||||
) || [],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Globe, RefreshCw, Trash } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import styles from './PublicDashboardActions.module.scss';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
interface PublicDashboardActionsProps {
|
||||
isPublic: boolean;
|
||||
disabled: boolean;
|
||||
/**
|
||||
* Why publishing is unavailable. Non-empty both disables the buttons and
|
||||
* explains them, so they cannot be disabled silently.
|
||||
*/
|
||||
checks: BrandedPermission[];
|
||||
/** In-flight config read — transient, and a spinner explains itself. */
|
||||
isLoading?: boolean;
|
||||
isPublishing: boolean;
|
||||
isUpdating: boolean;
|
||||
isUnpublishing: boolean;
|
||||
@@ -16,7 +24,8 @@ interface PublicDashboardActionsProps {
|
||||
|
||||
function PublicDashboardActions({
|
||||
isPublic,
|
||||
disabled,
|
||||
checks,
|
||||
isLoading = false,
|
||||
isPublishing,
|
||||
isUpdating,
|
||||
isUnpublishing,
|
||||
@@ -24,45 +33,53 @@ function PublicDashboardActions({
|
||||
onUpdate,
|
||||
onUnpublish,
|
||||
}: PublicDashboardActionsProps): JSX.Element {
|
||||
const disabled = isLoading;
|
||||
|
||||
return (
|
||||
<div className={styles.footer}>
|
||||
{isPublic ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
disabled={disabled}
|
||||
loading={isUnpublishing}
|
||||
prefix={<Trash size={15} />}
|
||||
testId="public-dashboard-unpublish"
|
||||
onClick={onUnpublish}
|
||||
>
|
||||
Unpublish Dashboard
|
||||
</Button>
|
||||
<AuthZTooltip checks={checks}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
disabled={disabled}
|
||||
loading={isUnpublishing}
|
||||
prefix={<Trash size={15} />}
|
||||
testId="public-dashboard-unpublish"
|
||||
onClick={onUnpublish}
|
||||
>
|
||||
Unpublish Dashboard
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<AuthZTooltip checks={checks}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isUpdating}
|
||||
prefix={<RefreshCw size={15} />}
|
||||
testId="public-dashboard-update"
|
||||
onClick={onUpdate}
|
||||
>
|
||||
Update Dashboard
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</>
|
||||
) : (
|
||||
<AuthZTooltip checks={checks}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isUpdating}
|
||||
prefix={<RefreshCw size={15} />}
|
||||
testId="public-dashboard-update"
|
||||
onClick={onUpdate}
|
||||
loading={isPublishing}
|
||||
prefix={<Globe size={15} />}
|
||||
testId="public-dashboard-publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
Update Dashboard
|
||||
Publish Dashboard
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isPublishing}
|
||||
prefix={<Globe size={15} />}
|
||||
testId="public-dashboard-publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
Publish Dashboard
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { buildDashboardUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import PublicDashboardSettings from '../index';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
const PUBLIC_URL = `http://localhost/api/v1/dashboards/${DASHBOARD_ID}/public`;
|
||||
|
||||
const dashboard = {
|
||||
id: DASHBOARD_ID,
|
||||
spec: { display: { name: 'D' }, panels: {}, layouts: [], variables: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
describe('PublicDashboard - AuthZ', () => {
|
||||
beforeEach(() => {
|
||||
// Not published yet — the tab offers Publish.
|
||||
server.use(
|
||||
rest.get(PUBLIC_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(404), ctx.json({ status: 'error', error: {} })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// The backend gates the public-config writes on dashboard:update, so a
|
||||
// licensed editor can publish — this used to be admin-only in the UI.
|
||||
it('lets a non-admin holding update publish', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(<PublicDashboardSettings dashboard={dashboard} />, undefined, {
|
||||
role: 'EDITOR',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('public-dashboard-publish')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables publishing when update is denied', async () => {
|
||||
server.use(setupAuthzDeny(buildDashboardUpdatePermission(DASHBOARD_ID)));
|
||||
|
||||
render(<PublicDashboardSettings dashboard={dashboard} />, undefined, {
|
||||
role: 'ADMIN',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('public-dashboard-publish')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import PublicDashboardHint from './PublicDashboardHint/PublicDashboardHint';
|
||||
import PublicDashboardSettingsForm from './PublicDashboardSettingsForm/PublicDashboardSettingsForm';
|
||||
import PublicDashboardStatus from './PublicDashboardStatus/PublicDashboardStatus';
|
||||
import PublicDashboardUrl from './PublicDashboardUrl/PublicDashboardUrl';
|
||||
|
||||
import { usePublicDashboard } from './usePublicDashboard';
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
@@ -17,7 +18,8 @@ function PublicDashboardSettings({
|
||||
}: PublicDashboardSettingsProps): JSX.Element {
|
||||
const {
|
||||
isPublic,
|
||||
isAdmin,
|
||||
canManage,
|
||||
publishChecks,
|
||||
isLoading,
|
||||
isPublishing,
|
||||
isUpdating,
|
||||
@@ -34,7 +36,7 @@ function PublicDashboardSettings({
|
||||
onOpenUrl,
|
||||
} = usePublicDashboard(dashboard.id);
|
||||
|
||||
const controlsDisabled = isLoading || !isAdmin;
|
||||
const controlsDisabled = isLoading || !canManage;
|
||||
|
||||
return (
|
||||
<div className={styles.publishTab}>
|
||||
@@ -61,7 +63,8 @@ function PublicDashboardSettings({
|
||||
|
||||
<PublicDashboardActions
|
||||
isPublic={isPublic}
|
||||
disabled={controlsDisabled}
|
||||
checks={publishChecks}
|
||||
isLoading={isLoading}
|
||||
isPublishing={isPublishing}
|
||||
isUpdating={isUpdating}
|
||||
isUnpublishing={isUnpublishing}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { useDashboardPermissions } from 'hooks/dashboards/useDashboardPermissions';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
@@ -11,10 +13,8 @@ import {
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -22,7 +22,10 @@ import { usePublicDashboardMeta } from './usePublicDashboardMeta';
|
||||
|
||||
export interface UsePublicDashboardReturn {
|
||||
isPublic: boolean;
|
||||
isAdmin: boolean;
|
||||
/** read + update on this dashboard — publishing is a dashboard update. */
|
||||
canManage: boolean;
|
||||
/** `[read, update]` — publishing changes the dashboard. */
|
||||
publishChecks: BrandedPermission[];
|
||||
isLoading: boolean;
|
||||
isPublishing: boolean;
|
||||
isUpdating: boolean;
|
||||
@@ -49,8 +52,10 @@ export function usePublicDashboard(
|
||||
): UsePublicDashboardReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { user } = useAppContext();
|
||||
const isAdmin = user?.role === USER_ROLES.ADMIN;
|
||||
// The backend gates the public-config writes on dashboard:update, not on the
|
||||
// admin role, so a licensed editor can publish.
|
||||
const { canEdit: canManage, editChecks: publishChecks } =
|
||||
useDashboardPermissions(dashboardId);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const [timeRangeEnabled, setTimeRangeEnabled] = useState<boolean>(true);
|
||||
@@ -196,7 +201,8 @@ export function usePublicDashboard(
|
||||
|
||||
return {
|
||||
isPublic,
|
||||
isAdmin,
|
||||
canManage,
|
||||
publishChecks,
|
||||
isLoading,
|
||||
isPublishing,
|
||||
isUpdating,
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
const AddVariableButton = ({
|
||||
isEditable,
|
||||
checks,
|
||||
disabledTooltip,
|
||||
setIsEditing,
|
||||
}: {
|
||||
isEditable: boolean;
|
||||
checks: BrandedPermission[];
|
||||
disabledTooltip?: string;
|
||||
setIsEditing: (state: { type: 'new' }) => void;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
size="md"
|
||||
onClick={(): void => setIsEditing({ type: 'new' })}
|
||||
testId="add-variable"
|
||||
disabled={!isEditable}
|
||||
>
|
||||
Add variable
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
}): JSX.Element => (
|
||||
<AuthZButton
|
||||
checks={checks}
|
||||
disabledTooltip={disabledTooltip}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
size="md"
|
||||
onClick={(): void => setIsEditing({ type: 'new' })}
|
||||
testId="add-variable"
|
||||
>
|
||||
Add variable
|
||||
</AuthZButton>
|
||||
);
|
||||
|
||||
export default AddVariableButton;
|
||||
|
||||
@@ -2,12 +2,15 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import AddVariableButton from '../AddVariableButton';
|
||||
import { EditingState } from '../../types';
|
||||
import styles from './NoVariables.module.scss';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
const NoVariablesCard = ({
|
||||
isEditable,
|
||||
checks,
|
||||
disabledTooltip,
|
||||
setIsEditing,
|
||||
}: {
|
||||
isEditable: boolean;
|
||||
checks: BrandedPermission[];
|
||||
disabledTooltip?: string;
|
||||
setIsEditing: React.Dispatch<React.SetStateAction<EditingState | null>>;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
@@ -20,7 +23,11 @@ const NoVariablesCard = ({
|
||||
Create a variable to parameterize your panel queries.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
<AddVariableButton
|
||||
checks={checks}
|
||||
disabledTooltip={disabledTooltip}
|
||||
setIsEditing={setIsEditing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,13 +20,15 @@ import styles from './Variables.module.scss';
|
||||
import AddVariableButton from './components/AddVariableButton';
|
||||
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
|
||||
import { EditingState } from './types';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
|
||||
interface VariablesSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
// The drawer destroys on close, so reading this once on mount is enough to
|
||||
// open the add-form when deep-linked (e.g. the bar's "Add variable" button).
|
||||
const openAddOnMount = useDashboardStore(
|
||||
@@ -127,7 +129,11 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.container, settingsStyles.settingsCard)}>
|
||||
{variables.length === 0 ? (
|
||||
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
<NoVariablesCard
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
setIsEditing={setIsEditing}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<VariablesList
|
||||
@@ -143,7 +149,11 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
appliedToAllNames={appliedToAllNames}
|
||||
/>
|
||||
<div className={styles.footer}>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
<AddVariableButton
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
setIsEditing={setIsEditing}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDenyAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import DashboardSettings from '../index';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
|
||||
const dashboard = {
|
||||
id: DASHBOARD_ID,
|
||||
spec: { display: { name: 'D' }, panels: {}, layouts: [], variables: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
let isCloudUser = true;
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: (): {
|
||||
isCloudUser: boolean;
|
||||
isEnterpriseSelfHostedUser: boolean;
|
||||
} => ({ isCloudUser, isEnterpriseSelfHostedUser: false }),
|
||||
}));
|
||||
|
||||
describe('DashboardSettings - AuthZ', () => {
|
||||
beforeEach(() => {
|
||||
isCloudUser = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders a trigger per tab', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(<DashboardSettings dashboard={dashboard} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: /Overview/ })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('tab', { name: /Variables/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /Publish/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The triggers used to be rendered from the TabKeys enum rather than the
|
||||
// items list, so Publish appeared on OSS and landed on an empty body.
|
||||
it('omits the Publish tab when public dashboards are unavailable', async () => {
|
||||
isCloudUser = false;
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(<DashboardSettings dashboard={dashboard} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: /Overview/ })).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: /Publish/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reading the publish config only needs read; the writes inside gate on update.
|
||||
it('keeps the Publish tab reachable for a non-admin', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
render(<DashboardSettings dashboard={dashboard} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: /Publish/ })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('tab', { name: /Publish/ })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,7 @@ import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/service
|
||||
import Overview from './Overview';
|
||||
import PublicDashboardSettings from './PublicDashboard';
|
||||
import VariablesSettings from './Variables';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import styles from './DashboardSettings.module.scss';
|
||||
@@ -37,7 +35,6 @@ const prefixIcons: Record<TabKeys, JSX.Element> = {
|
||||
};
|
||||
|
||||
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
// Opened once per drawer mount (the drawer destroys on close); a deep-link
|
||||
// request lands us on the right tab.
|
||||
@@ -58,27 +55,28 @@ function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
children: <VariablesSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Braces size={14} />,
|
||||
},
|
||||
// Readable by anyone who can open the dashboard; the controls inside
|
||||
// gate on update.
|
||||
...(enablePublicDashboard
|
||||
? [
|
||||
{
|
||||
key: TabKeys.PUBLISH,
|
||||
label: TabKeys.PUBLISH,
|
||||
children: <PublicDashboardSettings dashboard={dashboard} />,
|
||||
disabled: user?.role !== USER_ROLES.ADMIN,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[enablePublicDashboard, dashboard, user?.role],
|
||||
[enablePublicDashboard, dashboard],
|
||||
);
|
||||
|
||||
return (
|
||||
<TabsRoot defaultValue={settingsRequest?.tab ?? TabKeys.OVERVIEW}>
|
||||
<TabsList variant="primary">
|
||||
{Object.values(TabKeys).map((key) => (
|
||||
<TabsTrigger value={key} key={key}>
|
||||
{prefixIcons[key]}
|
||||
{key}
|
||||
{items.map((item) => (
|
||||
<TabsTrigger value={item.key} key={item.key} disabled={item.disabled}>
|
||||
{prefixIcons[item.key as TabKeys]}
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
@@ -8,10 +8,12 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import { useConfirmableAction } from 'hooks/useConfirmableAction';
|
||||
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
|
||||
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import styles from './Header.module.scss';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
interface HeaderProps {
|
||||
/** Unsaved edits exist — shows the "Unsaved Changes" badge and gates the discard confirmation on close (not the Save button). */
|
||||
@@ -20,7 +22,9 @@ interface HeaderProps {
|
||||
showSwitchToView?: boolean;
|
||||
/** Locked/no-permission dashboard — Save is disabled with a reason. */
|
||||
readOnly?: boolean;
|
||||
readOnlyReason?: string;
|
||||
/** Present when saving is unavailable — the Save button explains itself with it. */
|
||||
readOnlyChecks?: BrandedPermission[];
|
||||
readOnlyTooltip?: string;
|
||||
onSave: () => void;
|
||||
onSwitchToView?: () => void;
|
||||
onClose: () => void;
|
||||
@@ -31,7 +35,8 @@ function Header({
|
||||
isSaving,
|
||||
showSwitchToView = false,
|
||||
readOnly = false,
|
||||
readOnlyReason,
|
||||
readOnlyChecks = [],
|
||||
readOnlyTooltip,
|
||||
onSave,
|
||||
onSwitchToView,
|
||||
onClose,
|
||||
@@ -90,7 +95,10 @@ function Header({
|
||||
Switch to View Mode
|
||||
</Button>
|
||||
)}
|
||||
<DisabledControlTooltip reason={readOnlyReason ?? ''} disabled={readOnly}>
|
||||
<AuthZTooltip
|
||||
checks={readOnlyChecks}
|
||||
disabledTooltip={readOnly ? readOnlyTooltip : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
@@ -101,7 +109,7 @@ function Header({
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</DisabledControlTooltip>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
|
||||
<DialogWrapper
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
|
||||
@@ -82,7 +82,11 @@ describe('PanelEditor Header', () => {
|
||||
it('disables Save only while read-only or saving', () => {
|
||||
mockUseIsAIAssistantEnabled.mockReturnValue(false);
|
||||
|
||||
renderHeader({ isDirty: true, readOnly: true, readOnlyReason: 'Locked' });
|
||||
renderHeader({
|
||||
isDirty: true,
|
||||
readOnly: true,
|
||||
readOnlyTooltip: 'Locked',
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-save')).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,26 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelEditorContainer from '../index';
|
||||
|
||||
// The editor reads its edit context from the loaded dashboard subtree, which
|
||||
// these composition cases don't stand up; the derivation has its own suite.
|
||||
const mockEditContext = {
|
||||
isEditable: true,
|
||||
editChecks: [],
|
||||
areOtherPermissionsLoading: false,
|
||||
deleteChecks: [],
|
||||
isLocked: false,
|
||||
canEditDashboard: true,
|
||||
canDeleteDashboard: true,
|
||||
editDisabledTooltip: '',
|
||||
deleteDisabledTooltip: '',
|
||||
};
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): typeof mockEditContext => mockEditContext,
|
||||
}),
|
||||
);
|
||||
import { useScrollIntoViewStore } from '../../store/useScrollIntoViewStore';
|
||||
|
||||
/**
|
||||
@@ -172,8 +192,6 @@ function makePanel(
|
||||
const baseProps = {
|
||||
dashboardId: 'dash-1',
|
||||
panelId: 'panel-1',
|
||||
isEditable: true,
|
||||
editDisabledReason: '',
|
||||
onClose: jest.fn(),
|
||||
onSaved: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
|
||||
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
|
||||
@@ -63,10 +64,6 @@ interface PanelEditorContainerProps {
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
/** The dashboard can be edited (unlocked + permission); gates Save. */
|
||||
isEditable: boolean;
|
||||
/** Why Save is disabled (locked / no permission); '' when editable. */
|
||||
editDisabledReason: string;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
@@ -85,11 +82,14 @@ function PanelEditorContainer({
|
||||
savedPanel,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
isEditable,
|
||||
editDisabledReason,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
// Read here rather than taken as props: this renders inside a loaded dashboard
|
||||
// subtree, so it resolves the same context every other consumer does.
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
|
||||
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
|
||||
// panel always serializes its seed query and seeds the builder's default signal.
|
||||
const {
|
||||
@@ -280,7 +280,8 @@ function PanelEditorContainer({
|
||||
isSaving={isSaving}
|
||||
showSwitchToView={!isNew}
|
||||
readOnly={!isEditable}
|
||||
readOnlyReason={editDisabledReason}
|
||||
readOnlyChecks={editChecks}
|
||||
readOnlyTooltip={editDisabledTooltip}
|
||||
onSave={onSave}
|
||||
onSwitchToView={switchToViewMode}
|
||||
onClose={onCloseEditor}
|
||||
|
||||
@@ -6,9 +6,11 @@ import dashboardEmojiUrl from '@/assets/Icons/dashboard_emoji.svg';
|
||||
import landscapeUrl from '@/assets/Icons/landscape.svg';
|
||||
|
||||
import { useCreatePanel } from '../../hooks/useCreatePanel';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import PanelTypeSelectionModal from '../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import styles from './DashboardEmptyState.module.scss';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
|
||||
interface DashboardEmptyStateProps {
|
||||
canAddPanel: boolean;
|
||||
@@ -17,9 +19,10 @@ interface DashboardEmptyStateProps {
|
||||
function DashboardEmptyState({
|
||||
canAddPanel,
|
||||
}: DashboardEmptyStateProps): JSX.Element {
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const requestSettings = useDashboardStore((s) => s.requestSettings);
|
||||
|
||||
return (
|
||||
@@ -48,17 +51,18 @@ function DashboardEmptyState({
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
{isEditable && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
disabled={!isEditable}
|
||||
onClick={(): void => requestSettings({ tab: 'Overview' })}
|
||||
testId="empty-configure"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
|
||||
<div className={styles.step}>
|
||||
@@ -73,16 +77,17 @@ function DashboardEmptyState({
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
{canAddPanel && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
color="primary"
|
||||
prefix={<Plus size="md" />}
|
||||
disabled={!canAddPanel}
|
||||
onClick={(): void => openPicker()}
|
||||
testId="add-panel"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/quer
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import { usePanelActionItems } from './usePanelActionItems';
|
||||
import menuStyles from '../../../components/MenuActionItem/MenuActionItem.module.scss';
|
||||
import styles from './PanelActionsMenu.module.scss';
|
||||
|
||||
interface PanelActionsMenuProps {
|
||||
@@ -43,7 +44,11 @@ function PanelActionsMenu({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSimple menu={{ items }} align="end">
|
||||
<DropdownMenuSimple
|
||||
menu={{ items }}
|
||||
align="end"
|
||||
className={menuStyles.menuContent}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,13 +1,42 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import type { ROLES } from 'types/roles';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelActionItems } from '../usePanelActionItems';
|
||||
|
||||
/** Keys of the disabled items, in order. */
|
||||
// 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: [],
|
||||
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,
|
||||
}),
|
||||
);
|
||||
|
||||
function disabledKeys(
|
||||
result: ReturnType<typeof usePanelActionItems>,
|
||||
): unknown[] {
|
||||
@@ -64,15 +93,6 @@ jest.mock('../../hooks/useDownloadPanelImage', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Role is the only thing read off the app context; useComponentPermission runs
|
||||
// for real so the tests exercise the actual role → permission mapping.
|
||||
let mockRole: ROLES = 'ADMIN';
|
||||
jest.mock('providers/App/App', () => ({
|
||||
useAppContext: (): { user: { role: ROLES } } => ({
|
||||
user: { role: mockRole },
|
||||
}),
|
||||
}));
|
||||
|
||||
function section(
|
||||
layoutIndex: number,
|
||||
title: string | undefined,
|
||||
@@ -133,11 +153,10 @@ function itemKeys(result: ReturnType<typeof usePanelActionItems>): unknown[] {
|
||||
describe('usePanelActionItems', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRole = 'ADMIN';
|
||||
useDashboardStore.setState({ canEditDashboard: true, isLocked: false });
|
||||
setEditContextMock({});
|
||||
});
|
||||
|
||||
it('ADMIN on an editable dashboard with a known kind gets the full V1-parity set, divider-separated', () => {
|
||||
it('an editable dashboard with a known kind gets the full set, divider-separated', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
@@ -155,11 +174,20 @@ describe('usePanelActionItems', () => {
|
||||
// it's present for every renderable kind.
|
||||
});
|
||||
|
||||
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
|
||||
mockRole = 'AUTHOR';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
// These used to be dropped from the menu, leaving no trace of why.
|
||||
it('without edit rights keeps the edit actions visible but disabled', () => {
|
||||
setEditContextMock({
|
||||
isEditable: false,
|
||||
canEditDashboard: false,
|
||||
editDisabledTooltip: 'no permission',
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
|
||||
);
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
@@ -168,34 +196,20 @@ describe('usePanelActionItems', () => {
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('VIEWER keeps only the role-ungated actions (view, download, create-alert)', () => {
|
||||
mockRole = 'VIEWER';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
]);
|
||||
});
|
||||
|
||||
it('no edit permission (view mode) hides the edit actions entirely', () => {
|
||||
useDashboardStore.setState({ canEditDashboard: false });
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
|
||||
);
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
expect(disabledKeys(result.current)).toStrictEqual([
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'move',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('locked (edit mode) keeps the edit actions visible but disabled', () => {
|
||||
useDashboardStore.setState({ canEditDashboard: true, isLocked: true });
|
||||
setEditContextMock({
|
||||
isEditable: false,
|
||||
isLocked: true,
|
||||
editDisabledTooltip: 'locked',
|
||||
});
|
||||
// A locked dashboard mounts panels without layout context (no panelActions).
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { PanelActionCapabilities } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { ComponentTypes } from 'utils/permission';
|
||||
|
||||
/**
|
||||
* Every action the panel menu can offer: per-kind gated capabilities (minus
|
||||
@@ -14,12 +13,6 @@ export type PanelActionId =
|
||||
| 'delete';
|
||||
|
||||
export interface PanelActionMeta {
|
||||
/**
|
||||
* Role gate: componentPermission key checked against the current user.
|
||||
* Absent = available to every role (V1 parity: view, download and
|
||||
* create-alerts were never role-gated).
|
||||
*/
|
||||
permission?: ComponentTypes;
|
||||
/**
|
||||
* Kind gate: the PanelActionCapabilities flag this action requires.
|
||||
* Chrome actions (move/clone/delete) are layout concerns available for
|
||||
@@ -29,19 +22,19 @@ export interface PanelActionMeta {
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for how each panel action is gated, mirroring V1's
|
||||
* WidgetHeader rules. The third gate — context (editable, target sections) — is
|
||||
* runtime state resolved in `usePanelActionItems`, not declarable here.
|
||||
* Single source of truth for the kind gate on each panel action. Whether the
|
||||
* user may take it (dashboard edit rights) and whether the context allows it
|
||||
* (target sections present) are runtime state resolved in `usePanelActionItems`.
|
||||
*/
|
||||
export const PANEL_ACTION_META: Record<PanelActionId, PanelActionMeta> = {
|
||||
view: { capability: 'view' },
|
||||
edit: { permission: 'edit_widget', capability: 'edit' },
|
||||
clone: { permission: 'edit_widget' },
|
||||
// Single entry for every export format (CSV/PNG/SVG); like view it isn't
|
||||
// role-gated (V1 parity). The per-format options live in usePanelActionItems.
|
||||
edit: { capability: 'edit' },
|
||||
clone: {},
|
||||
// Single entry for every export format (CSV/PNG/SVG); the per-format options
|
||||
// live in usePanelActionItems.
|
||||
download: { capability: 'download' },
|
||||
createAlert: { capability: 'createAlert' },
|
||||
// Moving a panel between sections mutates the dashboard layout.
|
||||
move: { permission: 'edit_dashboard' },
|
||||
delete: { permission: 'delete_widget' },
|
||||
move: {},
|
||||
delete: {},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, useCallback, useMemo } from 'react';
|
||||
import { type ReactElement, type ReactNode, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
Copy,
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import {
|
||||
type ConfirmableAction,
|
||||
useConfirmableAction,
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
@@ -29,9 +26,9 @@ import { useDownloadPanelMenuItem } from '../hooks/useDownloadPanelMenuItem';
|
||||
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
import { buildMoveItems } from '../utils/buildMoveItems';
|
||||
import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../../../hooks/useDashboardEditGuard';
|
||||
import MenuActionItem from '../../../components/MenuActionItem/MenuActionItem';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
@@ -54,10 +51,10 @@ export interface PanelActionItems {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the panel actions menu items. Each action passes three gates before
|
||||
* it appears: kind (PanelDefinition.actions), role (useComponentPermission) and
|
||||
* context (dashboard editable + layout config present). View and Download stay
|
||||
* available on read-only dashboards, as in V1.
|
||||
* Resolves the panel actions menu items. Panels live in the dashboard spec and
|
||||
* have no authz kind, so every mutating action maps to the dashboard's edit
|
||||
* rights, while PanelDefinition.actions still decides which make sense at all.
|
||||
* View, Download and Create Alerts never mutate, so they are always available.
|
||||
*/
|
||||
export function usePanelActionItems({
|
||||
panelId,
|
||||
@@ -66,18 +63,8 @@ export function usePanelActionItems({
|
||||
panelActions,
|
||||
}: UsePanelActionItemsArgs): PanelActionItems {
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const { user } = useAppContext();
|
||||
const [canEditWidget, canMove, canDelete] = useComponentPermission(
|
||||
[
|
||||
// edit_widget gates both Edit and Clone, exactly as in V1.
|
||||
PANEL_ACTION_META.edit.permission ?? 'edit_widget',
|
||||
PANEL_ACTION_META.move.permission ?? 'edit_dashboard',
|
||||
PANEL_ACTION_META.delete.permission ?? 'delete_widget',
|
||||
],
|
||||
user.role,
|
||||
);
|
||||
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
|
||||
const isLocked = useDashboardStore((s) => s.isLocked);
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { openView } = useViewPanel();
|
||||
@@ -112,43 +99,43 @@ export function usePanelActionItems({
|
||||
const { request: requestDelete } = deleteConfirm;
|
||||
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
// Edit actions are shown only to edit-permitted users; the lock is their only
|
||||
// disabled state, surfaced as a hover tooltip on the row.
|
||||
const canEdit = canEditDashboard;
|
||||
const label = (text: string): ReactNode =>
|
||||
isLocked ? (
|
||||
<DisabledMenuItemLabel reason={DASHBOARD_LOCKED_REASON}>
|
||||
{text}
|
||||
</DisabledMenuItemLabel>
|
||||
) : (
|
||||
text
|
||||
);
|
||||
// The row is a button carrying its own icon and reason; the item hosts it.
|
||||
const row = (
|
||||
text: string,
|
||||
icon: ReactElement,
|
||||
opts: { checks?: BrandedPermission[]; destructive?: boolean } = {},
|
||||
): ReactNode => (
|
||||
<MenuActionItem
|
||||
label={text}
|
||||
icon={icon}
|
||||
checks={opts.checks ?? editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
destructive={opts.destructive}
|
||||
/>
|
||||
);
|
||||
|
||||
const panelGroup: MenuItem[] = [];
|
||||
if (panelCapabilities.view) {
|
||||
panelGroup.push({
|
||||
key: 'view-panel',
|
||||
label: 'View',
|
||||
icon: <Fullscreen size={14} />,
|
||||
label: row('View', <Fullscreen size={14} />, { checks: [] }),
|
||||
onClick: (): void => openView(panelId, panel),
|
||||
});
|
||||
}
|
||||
if (canEdit && canEditWidget && panelCapabilities.edit) {
|
||||
if (panelCapabilities.edit) {
|
||||
panelGroup.push({
|
||||
key: 'edit-panel',
|
||||
label: label('Edit panel'),
|
||||
icon: <PenLine size={14} />,
|
||||
disabled: isLocked,
|
||||
label: row('Edit panel', <PenLine size={14} />),
|
||||
disabled: !isEditable,
|
||||
onClick: (): void => openPanelEditor(panelId, { panel }),
|
||||
});
|
||||
}
|
||||
if (canEdit && canEditWidget && panelCapabilities.clone) {
|
||||
if (panelCapabilities.clone) {
|
||||
// Needs section context to place the copy; disabled without it.
|
||||
panelGroup.push({
|
||||
key: 'clone-panel',
|
||||
label: label('Clone'),
|
||||
icon: <Copy size={14} />,
|
||||
disabled: isLocked || !panelActions,
|
||||
label: row('Clone', <Copy size={14} />),
|
||||
disabled: !isEditable || !panelActions,
|
||||
onClick: (): void => {
|
||||
if (panelActions) {
|
||||
void clonePanel({
|
||||
@@ -170,45 +157,35 @@ export function usePanelActionItems({
|
||||
if (panelCapabilities.createAlert) {
|
||||
dataGroup.push({
|
||||
key: 'create-alert',
|
||||
label: 'Create Alerts',
|
||||
icon: <Bell size={14} />,
|
||||
label: row('Create Alerts', <Bell size={14} />, { checks: [] }),
|
||||
onClick: (): void => createAlert(panel, panelId),
|
||||
});
|
||||
}
|
||||
|
||||
let moveGroup: MenuItem[] = [];
|
||||
if (canEdit && canMove) {
|
||||
moveGroup =
|
||||
!isLocked && panelActions
|
||||
? buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex: panelActions.currentLayoutIndex,
|
||||
panelId,
|
||||
movePanel,
|
||||
})
|
||||
: [
|
||||
{
|
||||
key: 'move',
|
||||
label: label('Move to section'),
|
||||
icon: <FolderInput size={14} />,
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const deleteGroup: MenuItem[] =
|
||||
canEdit && canDelete
|
||||
? [
|
||||
const moveGroup: MenuItem[] =
|
||||
isEditable && panelActions
|
||||
? buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex: panelActions.currentLayoutIndex,
|
||||
panelId,
|
||||
movePanel,
|
||||
})
|
||||
: [
|
||||
{
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: label('Delete panel'),
|
||||
disabled: isLocked || !panelActions,
|
||||
onClick: (): void => requestDelete(),
|
||||
key: 'move',
|
||||
label: row('Move to section', <FolderInput size={14} />),
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
];
|
||||
|
||||
const deleteGroup: MenuItem[] = [
|
||||
{
|
||||
key: 'delete-panel',
|
||||
label: row('Delete panel', <Trash2 size={14} />, { destructive: true }),
|
||||
disabled: !isEditable || !panelActions,
|
||||
onClick: (): void => requestDelete(),
|
||||
},
|
||||
];
|
||||
|
||||
return [panelGroup, dataGroup, moveGroup, deleteGroup]
|
||||
.filter((group) => group.length > 0)
|
||||
@@ -216,11 +193,9 @@ export function usePanelActionItems({
|
||||
index === 0 ? group : [{ type: 'divider' as const }, ...group],
|
||||
);
|
||||
}, [
|
||||
canEditDashboard,
|
||||
isLocked,
|
||||
canEditWidget,
|
||||
canMove,
|
||||
canDelete,
|
||||
isEditable,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
panelCapabilities,
|
||||
panel,
|
||||
panelActions,
|
||||
|
||||
@@ -13,7 +13,8 @@ import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/ty
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
|
||||
|
||||
interface ViewPanelModalHeaderProps {
|
||||
selectedInterval: Time | CustomTimeType;
|
||||
@@ -62,13 +63,14 @@ function ViewPanelModalHeader({
|
||||
onChangePanelKind,
|
||||
onResetQuery,
|
||||
}: ViewPanelModalHeaderProps): JSX.Element {
|
||||
const {
|
||||
isEditable: canSwitchToEdit,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
} = useDashboardEditContext();
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
|
||||
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
|
||||
const isLocked = useDashboardStore((s) => s.isLocked);
|
||||
|
||||
const canSwitchToEdit = canEditDashboard && !isLocked;
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
@@ -80,17 +82,18 @@ function ViewPanelModalHeader({
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
</div>
|
||||
{canSwitchToEdit && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
disabled={!canSwitchToEdit}
|
||||
onClick={onSwitchToEdit}
|
||||
data-testid="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
)}
|
||||
</AuthZTooltip>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
|
||||
@@ -3,12 +3,10 @@ import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DisabledControlTooltip from '../../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../../../hooks/useDashboardEditGuard';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useCreatePanel } from '../../../hooks/useCreatePanel';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { useCloneSection } from '../hooks/useCloneSection';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
import { useRenameSection } from '../hooks/useRenameSection';
|
||||
@@ -20,6 +18,7 @@ import SectionHeader, {
|
||||
type SectionDragHandle,
|
||||
} from '../SectionHeader/SectionHeader';
|
||||
import styles from './Section.module.scss';
|
||||
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
|
||||
|
||||
interface SectionProps {
|
||||
section: DashboardSection;
|
||||
@@ -30,8 +29,8 @@ interface SectionProps {
|
||||
}
|
||||
|
||||
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
|
||||
const isLocked = useDashboardStore((s) => s.isLocked);
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
const {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
@@ -104,43 +103,34 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
onToggle={toggle}
|
||||
repeatVariable={section.repeatVariable}
|
||||
dragHandle={dragHandle}
|
||||
disabledReason={isLocked ? DASHBOARD_LOCKED_REASON : ''}
|
||||
actions={
|
||||
canEditDashboard
|
||||
? {
|
||||
onRename: (): void => setIsRenaming(true),
|
||||
onAddPanel: (): void => openPicker(section.layoutIndex),
|
||||
onCloneSection: (): void => void cloneSection(section),
|
||||
onDeleteSection: (): void => setIsDeleteOpen(true),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabledChecks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
actions={{
|
||||
onRename: (): void => setIsRenaming(true),
|
||||
onAddPanel: (): void => openPicker(section.layoutIndex),
|
||||
onCloneSection: (): void => void cloneSection(section),
|
||||
onDeleteSection: (): void => setIsDeleteOpen(true),
|
||||
}}
|
||||
/>
|
||||
{open &&
|
||||
(section.items.length > 0 ? (
|
||||
grid
|
||||
) : (
|
||||
<div className={styles.emptySection}>
|
||||
{canEditDashboard && (
|
||||
<DisabledControlTooltip
|
||||
reason={DASHBOARD_LOCKED_REASON}
|
||||
disabled={isLocked}
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={
|
||||
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
|
||||
}
|
||||
testId={`section-add-panel-${section.id}`}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size="md" />}
|
||||
disabled={isLocked}
|
||||
onClick={
|
||||
isLocked ? undefined : (): void => openPicker(section.layoutIndex)
|
||||
}
|
||||
testId={`section-add-panel-${section.id}`}
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</DisabledControlTooltip>
|
||||
)}
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
))}
|
||||
<SectionTitleModal
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { type ReactElement, type ReactNode, useMemo } from 'react';
|
||||
import { Copy, EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
import MenuActionItem from '../../../components/MenuActionItem/MenuActionItem';
|
||||
import menuStyles from '../../../components/MenuActionItem/MenuActionItem.module.scss';
|
||||
import styles from './SectionActionsMenu.module.scss';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
interface SectionActionsMenuProps {
|
||||
sectionId: string;
|
||||
/** Non-empty when edits are unavailable — items render disabled with this reason. */
|
||||
disabledReason?: string;
|
||||
/** Present when edits are unavailable — items render disabled with its reason. */
|
||||
disabledChecks?: BrandedPermission[];
|
||||
disabledTooltip?: string;
|
||||
disabled?: boolean;
|
||||
onAddPanel?: () => void;
|
||||
onRename?: () => void;
|
||||
onCloneSection?: () => void;
|
||||
@@ -19,28 +23,35 @@ interface SectionActionsMenuProps {
|
||||
|
||||
function SectionActionsMenu({
|
||||
sectionId,
|
||||
disabledReason = '',
|
||||
disabledChecks = [],
|
||||
disabledTooltip,
|
||||
disabled = false,
|
||||
onAddPanel,
|
||||
onRename,
|
||||
onCloneSection,
|
||||
onDeleteSection,
|
||||
}: SectionActionsMenuProps): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const disabled = !!disabledReason;
|
||||
const label = (text: string): ReactNode =>
|
||||
disabled ? (
|
||||
<DisabledMenuItemLabel reason={disabledReason}>
|
||||
{text}
|
||||
</DisabledMenuItemLabel>
|
||||
) : (
|
||||
text
|
||||
);
|
||||
// The row is a button, so it carries its own icon, disabled state and
|
||||
// reason — the dropdown item just hosts it.
|
||||
const row = (
|
||||
text: string,
|
||||
icon: ReactElement,
|
||||
opts: { destructive?: boolean } = {},
|
||||
): ReactNode => (
|
||||
<MenuActionItem
|
||||
label={text}
|
||||
icon={icon}
|
||||
checks={disabledChecks}
|
||||
disabledTooltip={disabledTooltip}
|
||||
destructive={opts.destructive}
|
||||
/>
|
||||
);
|
||||
const result: MenuItem[] = [];
|
||||
if (onAddPanel) {
|
||||
result.push({
|
||||
key: 'add-panel',
|
||||
icon: <Plus size={14} />,
|
||||
label: label('Add panel'),
|
||||
label: row('Add panel', <Plus size={14} />),
|
||||
disabled,
|
||||
onClick: onAddPanel,
|
||||
});
|
||||
@@ -48,8 +59,7 @@ function SectionActionsMenu({
|
||||
if (onRename) {
|
||||
result.push({
|
||||
key: 'rename',
|
||||
icon: <PenLine size={14} />,
|
||||
label: label('Rename section'),
|
||||
label: row('Rename section', <PenLine size={14} />),
|
||||
disabled,
|
||||
onClick: onRename,
|
||||
});
|
||||
@@ -57,8 +67,7 @@ function SectionActionsMenu({
|
||||
if (onCloneSection) {
|
||||
result.push({
|
||||
key: 'clone-section',
|
||||
icon: <Copy size={14} />,
|
||||
label: label('Clone section'),
|
||||
label: row('Clone section', <Copy size={14} />),
|
||||
disabled,
|
||||
onClick: onCloneSection,
|
||||
});
|
||||
@@ -68,19 +77,27 @@ function SectionActionsMenu({
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'delete-section',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: label('Delete section'),
|
||||
label: row('Delete section', <Trash2 size={14} />, {
|
||||
destructive: true,
|
||||
}),
|
||||
disabled,
|
||||
onClick: onDeleteSection,
|
||||
},
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [disabledReason, onAddPanel, onRename, onCloneSection, onDeleteSection]);
|
||||
}, [
|
||||
disabled,
|
||||
disabledChecks,
|
||||
disabledTooltip,
|
||||
onAddPanel,
|
||||
onRename,
|
||||
onCloneSection,
|
||||
onDeleteSection,
|
||||
]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<DropdownMenuSimple menu={{ items }} className={menuStyles.menuContent}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useMemo } from 'react';
|
||||
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { usePersistLayout } from '../hooks/usePersistLayout';
|
||||
import SectionGridItem from './SectionGridItem';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
|
||||
@@ -21,7 +21,7 @@ function SectionGrid({
|
||||
layoutIndex,
|
||||
sections,
|
||||
}: SectionGridProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { isEditable } = useDashboardEditContext();
|
||||
|
||||
const rglLayout = useMemo<Layout[]>(
|
||||
() =>
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import SectionActionsMenu from '../SectionActionsMenu/SectionActionsMenu';
|
||||
|
||||
import styles from './SectionHeader.module.scss';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
export interface SectionDragHandle {
|
||||
attributes: DraggableAttributes;
|
||||
@@ -32,8 +34,9 @@ interface SectionHeaderProps {
|
||||
dragHandle?: SectionDragHandle;
|
||||
/** Present for edit-permitted users; absent (no menu) in view mode. */
|
||||
actions?: SectionHeaderActions;
|
||||
/** Non-empty when locked — actions render disabled with this reason. */
|
||||
disabledReason?: string;
|
||||
/** Present when edits are unavailable — actions render disabled with its reason. */
|
||||
disabledChecks?: BrandedPermission[];
|
||||
disabledTooltip?: string;
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
@@ -44,7 +47,8 @@ function SectionHeader({
|
||||
repeatVariable,
|
||||
dragHandle,
|
||||
actions,
|
||||
disabledReason = '',
|
||||
disabledChecks = [],
|
||||
disabledTooltip,
|
||||
}: SectionHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
|
||||
@@ -83,7 +87,8 @@ function SectionHeader({
|
||||
{actions ? (
|
||||
<SectionActionsMenu
|
||||
sectionId={sectionId}
|
||||
disabledReason={disabledReason}
|
||||
disabledChecks={disabledChecks}
|
||||
disabledTooltip={disabledTooltip}
|
||||
onAddPanel={actions.onAddPanel}
|
||||
onRename={actions.onRename}
|
||||
onCloneSection={actions.onCloneSection}
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
|
||||
import Section from './Section/Section';
|
||||
import SectionDragPreview from './SectionDragPreview/SectionDragPreview';
|
||||
import SortableSection from './SortableSection';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
|
||||
interface SectionListProps {
|
||||
sections: DashboardSection[];
|
||||
@@ -23,7 +23,7 @@ interface SectionListProps {
|
||||
}
|
||||
|
||||
function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { isEditable } = useDashboardEditContext();
|
||||
|
||||
const {
|
||||
sensors,
|
||||
@@ -41,16 +41,9 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
[orderedSections],
|
||||
);
|
||||
|
||||
if (!isEditable) {
|
||||
return (
|
||||
<>
|
||||
{sections.map((section) => (
|
||||
<Section key={section.id} section={section} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The DnD tree stays mounted whether or not the user can reorder: permissions
|
||||
// resolve asynchronously, and swapping the subtree shape on that transition
|
||||
// would remount every section and its panels.
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -63,7 +56,12 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
{orderedSections.map((section) =>
|
||||
section.title ? (
|
||||
<SortableSection key={section.id} section={section} sections={sections} />
|
||||
<SortableSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
sections={sections}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
) : (
|
||||
<Section key={section.id} section={section} sections={sections} />
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
@@ -7,11 +8,14 @@ import Section from './Section/Section';
|
||||
interface SortableSectionProps {
|
||||
section: DashboardSection;
|
||||
sections: DashboardSection[];
|
||||
/** Reordering needs edit rights; the section still renders without them. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function SortableSection({
|
||||
section,
|
||||
sections,
|
||||
disabled = false,
|
||||
}: SortableSectionProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
@@ -21,7 +25,14 @@ function SortableSection({
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: section.id });
|
||||
} = useSortable({ id: section.id, disabled });
|
||||
|
||||
// dnd-kit re-renders this on every drag frame, so keep the handle identity
|
||||
// stable rather than handing Section a fresh object each time.
|
||||
const handle = useMemo(
|
||||
() => (disabled ? undefined : { attributes, listeners, setActivatorNodeRef }),
|
||||
[disabled, attributes, listeners, setActivatorNodeRef],
|
||||
);
|
||||
|
||||
// dnd-kit drives the drag transform per-frame, so this must be an inline
|
||||
// style — there is no static-stylesheet equivalent for a live transform.
|
||||
@@ -35,11 +46,7 @@ function SortableSection({
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<Section
|
||||
section={section}
|
||||
sections={sections}
|
||||
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
|
||||
/>
|
||||
<Section section={section} sections={sections} dragHandle={handle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { layoutsToSections } from '../utils';
|
||||
import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
|
||||
import { useViewPanel } from './Panel/hooks/useViewPanel';
|
||||
@@ -16,6 +15,7 @@ import styles from './PanelsAndSectionsLayout.module.scss';
|
||||
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
|
||||
|
||||
interface PanelsAndSectionsLayoutProps {
|
||||
layouts: DashboardtypesLayoutDTO[];
|
||||
@@ -26,7 +26,7 @@ function PanelsAndSectionsLayout({
|
||||
layouts,
|
||||
panels,
|
||||
}: PanelsAndSectionsLayoutProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { isEditable } = useDashboardEditContext();
|
||||
|
||||
// Single View-modal host for the whole dashboard, driven by the URL
|
||||
// (`expandedWidgetId`). One mounted modal beats one-per-panel: no N location
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useVariableSelection } from './hooks/useVariableSelection';
|
||||
import { resolveDefaultSelection } from './utils/resolveVariableSelection';
|
||||
import VariableSelector from './components/VariableSelector/VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -32,9 +33,10 @@ interface VariablesBarProps {
|
||||
*/
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
const { variables, selection, setSelection, autoSelect } =
|
||||
useVariableSelection(dashboard);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
// Persisted per dashboard so the full/collapsed view survives reloads.
|
||||
const expanded = useDashboardStore(selectVariablesExpanded(dashboardId));
|
||||
const setVariablesExpanded = useDashboardStore((s) => s.setVariablesExpanded);
|
||||
@@ -131,11 +133,13 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
{/* After the more/less trigger, in every state. Kept inline (not block)
|
||||
so the row still flows under the floated time selector, and always
|
||||
mounted so measuring never toggles it. */}
|
||||
{isEditable && (
|
||||
<span className={styles.addSlot}>
|
||||
<AddVariableIcon />
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.addSlot}>
|
||||
<AddVariableIcon
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,34 +1,70 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import styles from './AddVariable.module.scss';
|
||||
|
||||
interface AddVariableIconProps {
|
||||
/** Permissions the control needs; reported in the standard wording when denied. */
|
||||
checks: BrandedPermission[];
|
||||
/** A non-permission block, which outranks the checks. */
|
||||
disabledTooltip?: string;
|
||||
/** Whether editing is available at all, so the label is shown instead. */
|
||||
isEditable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "+" trigger (label on hover) shown after the variable pills once at
|
||||
* least one variable exists. Opens the Variables settings tab with the add form
|
||||
* primed.
|
||||
*/
|
||||
function AddVariableIcon(): JSX.Element {
|
||||
function AddVariableIcon({
|
||||
checks,
|
||||
disabledTooltip,
|
||||
isEditable,
|
||||
}: AddVariableIconProps): JSX.Element {
|
||||
const requestSettings = useDashboardStore((s) => s.requestSettings);
|
||||
|
||||
const onClick = (): void =>
|
||||
requestSettings({ tab: 'Variables', addVariable: true });
|
||||
|
||||
// An available trigger shows its label; an unavailable one is disabled and
|
||||
// explained by the authz button itself.
|
||||
if (isEditable) {
|
||||
return (
|
||||
<TooltipSimple side="top" title="Add variable">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.addVariableIcon}
|
||||
aria-label="Add variable"
|
||||
testId="dashboard-variables-add"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipSimple side="top" title="Add variable">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.addVariableIcon}
|
||||
aria-label="Add variable"
|
||||
testId="dashboard-variables-add"
|
||||
onClick={(): void =>
|
||||
requestSettings({ tab: 'Variables', addVariable: true })
|
||||
}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<AuthZButton
|
||||
checks={checks}
|
||||
disabledTooltip={disabledTooltip}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.addVariableIcon}
|
||||
aria-label="Add variable"
|
||||
testId="dashboard-variables-add"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</AuthZButton>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.aboveOverlay {
|
||||
// Lift the tooltip above the dropdown menu (z 50) and the antd Drawer (z 1000)
|
||||
// so it is never clipped behind them. The tooltip content reads this variable.
|
||||
--tooltip-z-index: 1100;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './DisabledControlTooltip.module.scss';
|
||||
|
||||
interface DisabledControlTooltipProps {
|
||||
reason: string;
|
||||
disabled: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// A disabled button swallows hover, so the wrapping span is the tooltip trigger.
|
||||
function DisabledControlTooltip({
|
||||
reason,
|
||||
disabled,
|
||||
children,
|
||||
}: DisabledControlTooltipProps): JSX.Element {
|
||||
if (!disabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return (
|
||||
<TooltipSimple
|
||||
title={reason}
|
||||
arrow
|
||||
disableHoverableContent
|
||||
tooltipContentProps={{ className: styles.aboveOverlay }}
|
||||
>
|
||||
<span className={styles.trigger}>{children}</span>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisabledControlTooltip;
|
||||
@@ -1,10 +0,0 @@
|
||||
.label {
|
||||
// Re-enable pointer events so the tooltip fires while the disabled row
|
||||
// (pointer-events: none) suppresses selection.
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.aboveOverlay {
|
||||
// Lift the tooltip above the dropdown menu (z 50) and the antd Drawer (z 1000).
|
||||
--tooltip-z-index: 1100;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './DisabledMenuItemLabel.module.scss';
|
||||
|
||||
interface DisabledMenuItemLabelProps {
|
||||
reason: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// A disabled row has pointer-events: none, so the label re-enables them to catch hover.
|
||||
function DisabledMenuItemLabel({
|
||||
reason,
|
||||
children,
|
||||
}: DisabledMenuItemLabelProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple
|
||||
title={reason}
|
||||
arrow
|
||||
disableHoverableContent
|
||||
tooltipContentProps={{ className: styles.aboveOverlay }}
|
||||
>
|
||||
<span className={styles.label}>{children}</span>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisabledMenuItemLabel;
|
||||
@@ -0,0 +1,28 @@
|
||||
.menuActionItem {
|
||||
/* The item owns the row's height, font, gap, hover and dimming, so anything
|
||||
the button would size, paint or dim for itself is handed back. Without this
|
||||
the two compound: a highlight inset in the row, 0.6 opacity on top of 0.5.
|
||||
Colour stays the button's, from `color`, as on the list rows. */
|
||||
--button-padding: var(--spacing-5, 10px) var(--spacing-6, 12px);
|
||||
--button-height: auto;
|
||||
--button-font-size: inherit;
|
||||
--button-line-height: inherit;
|
||||
--button-gap: var(--spacing-4, 8px);
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
--button-internal-hover-state-background-color: transparent;
|
||||
--button-disabled-opacity: 1;
|
||||
|
||||
/* Padding is the exception: it belongs to the button, which is what the
|
||||
tooltip anchors to. Left on the item, the anchor is inset from the menu's
|
||||
edge and the tooltip opens under the panel. */
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* The other half: the menu hands its padding to the rows, so a row's box reaches
|
||||
the panel edge, as the list popover's rows do. */
|
||||
.menuContent {
|
||||
--dropdown-menu-content-padding: 0;
|
||||
--dropdown-menu-item-padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
import styles from './MenuActionItem.module.scss';
|
||||
|
||||
interface MenuActionItemProps {
|
||||
label: ReactNode;
|
||||
icon: ReactElement;
|
||||
/** Permissions the row needs. Empty for a row that needs none. */
|
||||
checks: BrandedPermission[];
|
||||
/** A non-permission block, which outranks the checks (see AuthZTooltip). */
|
||||
disabledTooltip?: string;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row in a dashboard dropdown. The button fills the row, so the tooltip
|
||||
* anchors to the whole row and lands clear of the menu.
|
||||
*
|
||||
* Takes no `onClick` or `disabled`: the item keeps both, because Radix reads
|
||||
* `disabled` off it for keyboard navigation and only marks a row `clickable` —
|
||||
* the pointer cursor — when the item carries the handler.
|
||||
*/
|
||||
function MenuActionItem({
|
||||
label,
|
||||
icon,
|
||||
checks,
|
||||
disabledTooltip,
|
||||
destructive = false,
|
||||
}: MenuActionItemProps): JSX.Element {
|
||||
return (
|
||||
<AuthZButton
|
||||
checks={checks}
|
||||
disabledTooltip={disabledTooltip}
|
||||
side="left"
|
||||
variant="ghost"
|
||||
color={destructive ? 'destructive' : 'secondary'}
|
||||
className={styles.menuActionItem}
|
||||
prefix={icon}
|
||||
>
|
||||
{label}
|
||||
</AuthZButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default MenuActionItem;
|
||||
@@ -0,0 +1,133 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDenyAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import {
|
||||
buildDashboardDeletePermission,
|
||||
buildDashboardReadPermission,
|
||||
buildDashboardUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import { useDashboardEditContext } from '../useDashboardEditContext';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
// The copy lives in the dashboard i18n bundle, which the test env does not load,
|
||||
// so `t` yields the key — which is the part worth asserting anyway.
|
||||
const LOCKED_COPY = 'dashboard_locked';
|
||||
|
||||
let lockedDashboard = false;
|
||||
|
||||
// The hook reads the dashboard from the loaded subtree, which a bare renderHook
|
||||
// has no root page to establish — stand in for it so these cases stay about the
|
||||
// permissions and the derivation.
|
||||
jest.mock('../useDashboardFetchRequired', () => ({
|
||||
useDashboardFetchRequired: (): { dashboard: unknown } => ({
|
||||
dashboard: { id: DASHBOARD_ID, locked: lockedDashboard },
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderGuard(
|
||||
locked = false,
|
||||
): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useDashboardEditContext>, void>
|
||||
> {
|
||||
lockedDashboard = locked;
|
||||
return renderHook(() => useDashboardEditContext(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
}
|
||||
|
||||
describe('useDashboardEditContext - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
it('is editable when unlocked', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
const { result } = renderGuard();
|
||||
|
||||
await waitFor(() => expect(result.current.isEditable).toBe(true));
|
||||
expect(result.current.editDisabledTooltip).toBe('');
|
||||
expect(result.current.deleteDisabledTooltip).toBe('');
|
||||
});
|
||||
|
||||
// An edit-capable user gets the lock: it's the thing they can act on.
|
||||
it('reports the lock when locked', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
const { result } = renderGuard(true);
|
||||
|
||||
// Settle on the resolved grant: the in-flight state has canEdit false, so
|
||||
// a non-empty reason is not enough to know the check has landed.
|
||||
await waitFor(() => expect(result.current.canEditDashboard).toBe(true));
|
||||
expect(result.current.isEditable).toBe(false);
|
||||
expect(result.current.editDisabledTooltip).toBe(LOCKED_COPY);
|
||||
expect(result.current.deleteDisabledTooltip).toBe(LOCKED_COPY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission denied', () => {
|
||||
// Access before state: a lock would send them asking for the wrong thing.
|
||||
it('reports the permission, not the lock, when both apply', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
const { result } = renderGuard(true);
|
||||
|
||||
await waitFor(() => expect(result.current.editDisabledTooltip).toBe(''));
|
||||
expect(result.current.deleteDisabledTooltip).toBe('');
|
||||
expect(result.current.editDisabledTooltip).toBe('');
|
||||
});
|
||||
|
||||
it('reports the permission when unlocked', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
const { result } = renderGuard();
|
||||
|
||||
await waitFor(() => expect(result.current.canEditDashboard).toBe(false));
|
||||
expect(result.current.canEditDashboard).toBe(false);
|
||||
expect(result.current.editDisabledTooltip).toBe('');
|
||||
expect(result.current.deleteDisabledTooltip).toBe('');
|
||||
});
|
||||
|
||||
// Authz guide rule 2 — update alone is not enough to offer an edit affordance.
|
||||
it('is not editable with update but no read', async () => {
|
||||
server.use(setupAuthzAllow(buildDashboardUpdatePermission(DASHBOARD_ID)));
|
||||
|
||||
const { result } = renderGuard();
|
||||
|
||||
await waitFor(() => expect(result.current.canEditDashboard).toBe(false));
|
||||
expect(result.current.canEditDashboard).toBe(false);
|
||||
expect(result.current.editDisabledTooltip).toBe('');
|
||||
});
|
||||
|
||||
// Authz guide rule 3 — delete stands on its own.
|
||||
it('keeps delete available without read or update', async () => {
|
||||
server.use(setupAuthzAllow(buildDashboardDeletePermission(DASHBOARD_ID)));
|
||||
|
||||
const { result } = renderGuard();
|
||||
|
||||
await waitFor(() => expect(result.current.canDeleteDashboard).toBe(true));
|
||||
expect(result.current.deleteDisabledTooltip).toBe('');
|
||||
expect(result.current.canEditDashboard).toBe(false);
|
||||
});
|
||||
|
||||
it('is editable with read and update together', async () => {
|
||||
server.use(
|
||||
setupAuthzAllow(
|
||||
buildDashboardReadPermission(DASHBOARD_ID),
|
||||
buildDashboardUpdatePermission(DASHBOARD_ID),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderGuard();
|
||||
|
||||
await waitFor(() => expect(result.current.isEditable).toBe(true));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
/** Copy for the two non-permission blocks, resolved by the caller so this stays pure. */
|
||||
export interface EditContextReasons {
|
||||
locked: string;
|
||||
readOnly: string;
|
||||
}
|
||||
|
||||
export interface DashboardEditContext {
|
||||
isEditable: boolean;
|
||||
isLocked: boolean;
|
||||
canReadDashboard: boolean;
|
||||
canEditDashboard: boolean;
|
||||
canDeleteDashboard: boolean;
|
||||
/** `[read, update]`, for an authz component's `checks`. */
|
||||
editChecks: BrandedPermission[];
|
||||
deleteChecks: BrandedPermission[];
|
||||
/** Non-permission obstacle for `disabledTooltip`; empty when a permission is what's missing. */
|
||||
editDisabledTooltip: string;
|
||||
deleteDisabledTooltip: string;
|
||||
/** `update`/`delete` are still resolving; `read` gates the page itself. */
|
||||
areOtherPermissionsLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precedence is `update` → lock → `read`: the lock is only reported to a caller
|
||||
* who holds `update` and can act on it. A forced read-only mount outranks both,
|
||||
* since no check can lift it.
|
||||
*/
|
||||
export function deriveEditContext({
|
||||
isLocked,
|
||||
canRead,
|
||||
canEdit,
|
||||
canDelete,
|
||||
editChecks,
|
||||
deleteChecks,
|
||||
areOtherPermissionsLoading,
|
||||
reasons,
|
||||
readOnlyOverride = false,
|
||||
}: {
|
||||
isLocked: boolean;
|
||||
canRead: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
editChecks: BrandedPermission[];
|
||||
deleteChecks: BrandedPermission[];
|
||||
areOtherPermissionsLoading: boolean;
|
||||
reasons: EditContextReasons;
|
||||
/** Mount forced view-only regardless of permissions (see pulse-pod#283). */
|
||||
readOnlyOverride?: boolean;
|
||||
}): DashboardEditContext {
|
||||
if (readOnlyOverride) {
|
||||
return {
|
||||
isEditable: false,
|
||||
isLocked,
|
||||
canReadDashboard: canRead,
|
||||
canEditDashboard: false,
|
||||
canDeleteDashboard: false,
|
||||
editChecks,
|
||||
deleteChecks,
|
||||
editDisabledTooltip: reasons.readOnly,
|
||||
deleteDisabledTooltip: reasons.readOnly,
|
||||
areOtherPermissionsLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isEditable: canEdit && !isLocked,
|
||||
isLocked,
|
||||
canReadDashboard: canRead,
|
||||
canEditDashboard: canEdit,
|
||||
canDeleteDashboard: canDelete,
|
||||
editChecks,
|
||||
deleteChecks,
|
||||
editDisabledTooltip: canEdit && isLocked ? reasons.locked : '',
|
||||
deleteDisabledTooltip: canDelete && isLocked ? reasons.locked : '',
|
||||
areOtherPermissionsLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDashboardPermissions } from 'hooks/dashboards/useDashboardPermissions';
|
||||
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
import {
|
||||
type DashboardEditContext,
|
||||
deriveEditContext,
|
||||
} from './dashboardEditContext';
|
||||
import { useDashboardFetchRequired } from './useDashboardFetchRequired';
|
||||
|
||||
/**
|
||||
* Edit context for components inside a loaded dashboard subtree. The dashboard
|
||||
* and the permissions come from their shared caches, keyed off the store's
|
||||
* `dashboardId`; nothing derived is stored, to avoid a second source of truth.
|
||||
*/
|
||||
export function useDashboardEditContext(): DashboardEditContext {
|
||||
const { dashboard } = useDashboardFetchRequired();
|
||||
const {
|
||||
canRead,
|
||||
canEdit,
|
||||
canDelete,
|
||||
editChecks,
|
||||
deletePermission,
|
||||
areOtherPermissionsLoading,
|
||||
} = useDashboardPermissions(dashboard.id);
|
||||
const { t } = useTranslation('dashboard');
|
||||
const readOnlyOverride = useDashboardStore(
|
||||
(s) => s.canEditDashboardOverride === false,
|
||||
);
|
||||
|
||||
const deleteChecks = useMemo(() => [deletePermission], [deletePermission]);
|
||||
|
||||
return deriveEditContext({
|
||||
isLocked: !!dashboard.locked,
|
||||
canRead,
|
||||
canEdit,
|
||||
canDelete,
|
||||
editChecks,
|
||||
deleteChecks,
|
||||
areOtherPermissionsLoading,
|
||||
reasons: {
|
||||
locked: t('dashboard_locked'),
|
||||
readOnly: t('dashboard_read_only_here'),
|
||||
},
|
||||
readOnlyOverride,
|
||||
});
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import {
|
||||
DASHBOARD_LOCKED_REASON,
|
||||
DASHBOARD_NO_EDIT_PERMISSION_REASON,
|
||||
} from '../store/slices/editContextSlice';
|
||||
|
||||
// Re-exported from the (dependency-light) slice so leaf modules / tests can import
|
||||
// the reason strings without pulling this hook's provider chain.
|
||||
export {
|
||||
DASHBOARD_LOCKED_REASON,
|
||||
DASHBOARD_NO_EDIT_PERMISSION_REASON,
|
||||
} from '../store/slices/editContextSlice';
|
||||
|
||||
export interface DashboardEditGuard {
|
||||
isEditable: boolean;
|
||||
isLocked: boolean;
|
||||
canEditDashboard: boolean;
|
||||
editDisabledReason: string;
|
||||
}
|
||||
|
||||
// Editability + reason, derived from the dashboard (used where the store is cold,
|
||||
// e.g. the panel-editor route reached by direct URL).
|
||||
export function useDashboardEditGuard(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
|
||||
): DashboardEditGuard {
|
||||
const { user } = useAppContext();
|
||||
const [editDashboardPermission] = useComponentPermission(
|
||||
['edit_dashboard'],
|
||||
user.role,
|
||||
);
|
||||
const canEditDashboard = !!editDashboardPermission;
|
||||
const isLocked = !!dashboard?.locked;
|
||||
let editDisabledReason = '';
|
||||
if (isLocked) {
|
||||
editDisabledReason = DASHBOARD_LOCKED_REASON;
|
||||
} else if (!canEditDashboard) {
|
||||
editDisabledReason = DASHBOARD_NO_EDIT_PERMISSION_REASON;
|
||||
}
|
||||
return {
|
||||
isEditable: canEditDashboard && !isLocked,
|
||||
isLocked,
|
||||
canEditDashboard,
|
||||
editDisabledReason,
|
||||
};
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import type {
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { applyJsonPatch } from '../optimistic/applyJsonPatch';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../store/slices/editContextSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Cached dashboard snapshot, kept for rollback on error. */
|
||||
interface OptimisticPatchContext {
|
||||
@@ -37,8 +37,8 @@ export function useOptimisticPatch(
|
||||
dashboardIdOverride?: string,
|
||||
): UseOptimisticPatch {
|
||||
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const storeIsEditable = useDashboardStore((s) => s.isEditable);
|
||||
const dashboardId = dashboardIdOverride ?? storeDashboardId;
|
||||
const { t } = useTranslation('dashboard');
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
|
||||
@@ -78,12 +78,16 @@ export function useOptimisticPatch(
|
||||
const { mutateAsync } = mutation;
|
||||
const patchAsync = useCallback(
|
||||
(ops: DashboardtypesJSONPatchOperationDTO[]): Promise<unknown> => {
|
||||
if (storeDashboardId === dashboardId && !storeIsEditable) {
|
||||
return Promise.reject(new Error(DASHBOARD_LOCKED_REASON));
|
||||
// Defense-in-depth against a lock, read straight from the cache this hook
|
||||
// already owns. Permission is enforced by the UI and, definitively, by the
|
||||
// backend — checking it here would drag authz into every mutation.
|
||||
const cached = queryClient.getQueryData<GetDashboardV2200>(queryKey);
|
||||
if (cached?.data?.locked) {
|
||||
return Promise.reject(new Error(t('dashboard_locked')));
|
||||
}
|
||||
return mutateAsync(ops);
|
||||
},
|
||||
[storeDashboardId, dashboardId, storeIsEditable, mutateAsync],
|
||||
[queryClient, queryKey, mutateAsync],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,6 @@ import AutoRefreshTicker from 'container/TopNav/AutoRefreshV2/AutoRefreshTicker'
|
||||
|
||||
import DashboardPageToolbar from './DashboardPageToolbar';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import { useDashboardEditGuard } from './hooks/useDashboardEditGuard';
|
||||
import { useResolvedVariables } from './hooks/useResolvedVariables';
|
||||
import { useSyncVariablesForSuggestions } from './hooks/useSyncVariablesForSuggestions';
|
||||
import { useDashboardStore } from './store/useDashboardStore';
|
||||
@@ -50,17 +49,13 @@ function DashboardContainer({
|
||||
|
||||
const fullScreenHandle = useFullScreenHandle();
|
||||
|
||||
const { isLocked, canEditDashboard } = useDashboardEditGuard(dashboard);
|
||||
|
||||
// Seed during render (not an effect) so the first Panel render already sees the id —
|
||||
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
|
||||
const setEditContext = useDashboardStore((s) => s.setEditContext);
|
||||
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isLocked,
|
||||
canEditDashboard: canEditDashboardOverride ?? canEditDashboard,
|
||||
refetch,
|
||||
canEditDashboardOverride,
|
||||
});
|
||||
|
||||
// Resolve the variable selection into the V5 query payload and publish it to
|
||||
@@ -88,7 +83,7 @@ function DashboardContainer({
|
||||
</>
|
||||
)}
|
||||
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
|
||||
{isLocked && <LockedIndicator />}
|
||||
{!!dashboard.locked && <LockedIndicator />}
|
||||
<DashboardChangedDialog
|
||||
open={staleCheck.showPrompt}
|
||||
onReload={staleCheck.reload}
|
||||
|
||||
@@ -2,25 +2,23 @@ import type { StateCreator } from 'zustand';
|
||||
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
|
||||
export const DASHBOARD_LOCKED_REASON = 'This dashboard is locked';
|
||||
export const DASHBOARD_NO_EDIT_PERMISSION_REASON =
|
||||
'You don’t have permission to edit this dashboard';
|
||||
|
||||
// Edit context shared across the V2 dashboard tree, set once by DashboardContainer.
|
||||
/**
|
||||
* The one piece of page context the subtree can't derive for itself: which
|
||||
* dashboard is open, and how to refetch it. Editability is deliberately absent —
|
||||
* `useDashboardEditContext` derives it from the caches.
|
||||
*/
|
||||
export interface EditContextSlice {
|
||||
dashboardId: string;
|
||||
// canEditDashboard && !isLocked.
|
||||
isEditable: boolean;
|
||||
isLocked: boolean;
|
||||
canEditDashboard: boolean;
|
||||
// Locked / no-permission reason for tooltips; '' when editable.
|
||||
editDisabledReason: string;
|
||||
refetch: () => void;
|
||||
/**
|
||||
* @deprecated Forces a view-only mount regardless of permissions. Used only by
|
||||
* LLM Observability; see SigNoz/pulse-pod#283.
|
||||
*/
|
||||
canEditDashboardOverride?: boolean;
|
||||
setEditContext: (ctx: {
|
||||
dashboardId: string;
|
||||
isLocked: boolean;
|
||||
canEditDashboard: boolean;
|
||||
refetch: () => void;
|
||||
canEditDashboardOverride?: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -31,37 +29,22 @@ export const createEditContextSlice: StateCreator<
|
||||
EditContextSlice
|
||||
> = (set, get) => ({
|
||||
dashboardId: '',
|
||||
isEditable: false,
|
||||
isLocked: false,
|
||||
canEditDashboard: false,
|
||||
editDisabledReason: '',
|
||||
refetch: (): void => undefined,
|
||||
canEditDashboardOverride: undefined,
|
||||
// Idempotent (no-op when unchanged) so it's safe to call during render.
|
||||
setEditContext: (ctx): void => {
|
||||
const isEditable = ctx.canEditDashboard && !ctx.isLocked;
|
||||
let editDisabledReason = '';
|
||||
if (ctx.isLocked) {
|
||||
editDisabledReason = DASHBOARD_LOCKED_REASON;
|
||||
} else if (!ctx.canEditDashboard) {
|
||||
editDisabledReason = DASHBOARD_NO_EDIT_PERMISSION_REASON;
|
||||
}
|
||||
const prev = get();
|
||||
if (
|
||||
prev.dashboardId === ctx.dashboardId &&
|
||||
prev.isEditable === isEditable &&
|
||||
prev.isLocked === ctx.isLocked &&
|
||||
prev.canEditDashboard === ctx.canEditDashboard &&
|
||||
prev.refetch === ctx.refetch
|
||||
prev.refetch === ctx.refetch &&
|
||||
prev.canEditDashboardOverride === ctx.canEditDashboardOverride
|
||||
) {
|
||||
return;
|
||||
}
|
||||
set({
|
||||
dashboardId: ctx.dashboardId,
|
||||
isEditable,
|
||||
isLocked: ctx.isLocked,
|
||||
canEditDashboard: ctx.canEditDashboard,
|
||||
editDisabledReason,
|
||||
refetch: ctx.refetch,
|
||||
canEditDashboardOverride: ctx.canEditDashboardOverride,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,12 @@ import { useParams } from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
|
||||
import {
|
||||
buildDashboardDeletePermission,
|
||||
buildDashboardReadPermission,
|
||||
buildDashboardUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
|
||||
import DashboardContainer from './DashboardContainer';
|
||||
@@ -49,4 +55,15 @@ function DashboardPage(): JSX.Element {
|
||||
return <DashboardContainer dashboard={dashboard} refetch={refetch} />;
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
// Typed explicitly: the route lazy-loads this, and Loadable needs indexable props.
|
||||
export default withAuthZPage<Record<string, unknown>>(DashboardPage, {
|
||||
checks: (_props, router) => [
|
||||
buildDashboardReadPermission(router.params.dashboardId ?? ''),
|
||||
],
|
||||
// Same batch as `checks`, so the controls below resolve from cache.
|
||||
preloadChecks: (_props, router) => [
|
||||
buildDashboardUpdatePermission(router.params.dashboardId ?? ''),
|
||||
buildDashboardDeletePermission(router.params.dashboardId ?? ''),
|
||||
],
|
||||
fallbackOnLoading: <Spinner tip="Loading dashboard..." />,
|
||||
});
|
||||
|
||||
@@ -10,9 +10,14 @@ import Spinner from 'components/Spinner';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
|
||||
import {
|
||||
buildDashboardDeletePermission,
|
||||
buildDashboardReadPermission,
|
||||
buildDashboardUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
|
||||
import { useDashboardEditGuard } from '../DashboardContainer/hooks/useDashboardEditGuard';
|
||||
import { useResolvedVariables } from '../DashboardContainer/hooks/useResolvedVariables';
|
||||
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
|
||||
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
|
||||
@@ -47,22 +52,12 @@ function PanelEditorPage(): JSX.Element {
|
||||
|
||||
const { dashboard, isLoading, isError, error, refetch } =
|
||||
useDashboardFetch(dashboardId);
|
||||
// Derived here (not from the store) because the editor route doesn't mount
|
||||
// DashboardContainer, so the store's edit context may be cold on a direct URL.
|
||||
const { isEditable, isLocked, canEditDashboard, editDisabledReason } =
|
||||
useDashboardEditGuard(dashboard);
|
||||
|
||||
// On a refresh/direct URL this route is the only mount, so seed the edit
|
||||
// context the way DashboardContainer does — during render, so the subtree's
|
||||
// first render already sees the id (useDashboardFetchRequired throws without it).
|
||||
const setEditContext = useDashboardStore((s) => s.setEditContext);
|
||||
if (dashboard?.id) {
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isLocked,
|
||||
canEditDashboard,
|
||||
refetch,
|
||||
});
|
||||
setEditContext({ dashboardId: dashboard.id, refetch });
|
||||
}
|
||||
|
||||
// No variables bar on this route: seed the selection and publish the resolved
|
||||
@@ -143,12 +138,21 @@ function PanelEditorPage(): JSX.Element {
|
||||
savedPanel={existingPanel}
|
||||
isNew={!!newKind}
|
||||
layoutIndex={layoutIndex}
|
||||
isEditable={isEditable}
|
||||
editDisabledReason={editDisabledReason}
|
||||
onClose={backToDashboard}
|
||||
onSaved={backToDashboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorPage;
|
||||
// Typed explicitly: the route lazy-loads this, and Loadable needs indexable props.
|
||||
export default withAuthZPage<Record<string, unknown>>(PanelEditorPage, {
|
||||
checks: (_props, router) => [
|
||||
buildDashboardReadPermission(router.params.dashboardId ?? ''),
|
||||
],
|
||||
// Same batch as `checks`, so the controls below resolve from cache.
|
||||
preloadChecks: (_props, router) => [
|
||||
buildDashboardUpdatePermission(router.params.dashboardId ?? ''),
|
||||
buildDashboardDeletePermission(router.params.dashboardId ?? ''),
|
||||
],
|
||||
fallbackOnLoading: <Spinner tip="Loading dashboard..." />,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDenyAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import DashboardPage from '../DashboardPage';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
const DASHBOARD_URL = `http://localhost/api/v2/dashboards/${DASHBOARD_ID}`;
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: (): { dashboardId: string } => ({ dashboardId: DASHBOARD_ID }),
|
||||
}));
|
||||
|
||||
describe('DashboardPage - AuthZ', () => {
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// The list is collection-scoped, so an unreadable row is expected, not a failure.
|
||||
it('blocks the page when the read check is denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
await expect(
|
||||
screen.findByText('Uh-oh! You are not authorized'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`read:dashboard:${DASHBOARD_ID}`),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Failed to load dashboard'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The check is the only authority; a 403 from the GET is an API failure.
|
||||
it('shows the generic load error for a 403 from the dashboard request', async () => {
|
||||
server.use(
|
||||
rest.get(DASHBOARD_URL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(403),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: {
|
||||
type: 'forbidden',
|
||||
code: 'authz_forbidden',
|
||||
message: 'user/x is not authorized to perform dashboard:read',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
await expect(
|
||||
screen.findByText('Failed to load dashboard'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Uh-oh! You are not authorized'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The page must not paint controls and then disable them once the check lands.
|
||||
it('holds the page on the spinner until the permission check resolves', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
|
||||
rest.get(DASHBOARD_URL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
id: DASHBOARD_ID,
|
||||
spec: {
|
||||
display: { name: 'Checkout' },
|
||||
panels: {},
|
||||
layouts: [],
|
||||
variables: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
// Dashboard data has arrived, but the tree stays unmounted.
|
||||
await expect(screen.findByLabelText('loading')).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('show-drawer')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('add-panel-header')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// An authz outage must not make every dashboard look forbidden.
|
||||
it('renders the dashboard when the permission check itself fails', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.status(500))),
|
||||
rest.get(DASHBOARD_URL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
id: DASHBOARD_ID,
|
||||
spec: {
|
||||
display: { name: 'Checkout' },
|
||||
panels: {},
|
||||
layouts: [],
|
||||
variables: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText('Uh-oh! You are not authorized'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('still shows the generic error for a server failure', async () => {
|
||||
server.use(
|
||||
rest.get(DASHBOARD_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error', error: {} })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
await expect(
|
||||
screen.findByText('Failed to load dashboard'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Uh-oh! You are not authorized'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,17 @@
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
|
||||
import Spinner from 'components/Spinner';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import { useDashboardCollectionPermissions } from 'hooks/dashboards/useDashboardCollectionPermissions';
|
||||
import DashboardsList from './components/DashboardsList/DashboardsList';
|
||||
|
||||
import styles from './DashboardsListPage.module.scss';
|
||||
import { BreadcrumbLink } from '@signozhq/ui/breadcrumb';
|
||||
|
||||
function DashboardsListPage(): JSX.Element {
|
||||
// Resolved before the list mounts, so no control renders enabled-then-disabled.
|
||||
const { isLoading } = useDashboardCollectionPermissions();
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
@@ -19,7 +24,7 @@ function DashboardsListPage(): JSX.Element {
|
||||
enableFeedback
|
||||
/>
|
||||
</div>
|
||||
<DashboardsList />
|
||||
{isLoading ? <Spinner tip="Loading dashboards..." /> : <DashboardsList />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { MouseEvent, ReactElement, ReactNode } from 'react';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
interface Props {
|
||||
label: ReactNode;
|
||||
icon: ReactElement;
|
||||
testId: string;
|
||||
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
/** Permissions the row needs; the component words a refusal. */
|
||||
checks: BrandedPermission[];
|
||||
/** A non-permission block, which outranks the checks (see AuthZTooltip). */
|
||||
disabledTooltip?: string;
|
||||
loading?: boolean;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row in the actions menu. The button fills the row, so the tooltip anchors to
|
||||
* it and lands clear of the menu rather than over the row's icon.
|
||||
*/
|
||||
function ActionsMenuItem({
|
||||
label,
|
||||
icon,
|
||||
testId,
|
||||
onClick,
|
||||
checks,
|
||||
disabledTooltip,
|
||||
loading = false,
|
||||
destructive = false,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<AuthZButton
|
||||
checks={checks}
|
||||
disabledTooltip={disabledTooltip}
|
||||
side="left"
|
||||
variant="ghost"
|
||||
color={destructive ? 'destructive' : 'secondary'}
|
||||
className={styles.menuItem}
|
||||
prefix={icon}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
onClick={(e: MouseEvent<HTMLButtonElement>): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
testId={testId}
|
||||
>
|
||||
{label}
|
||||
</AuthZButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionsMenuItem;
|
||||
@@ -0,0 +1,180 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzAdmin,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import {
|
||||
buildDashboardDeletePermission,
|
||||
buildDashboardUpdatePermission,
|
||||
DashboardCreatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import ActionsPopover from './ActionsPopover';
|
||||
import { DashboardtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const DASHBOARD_ID = 'abc';
|
||||
|
||||
const baseProps = {
|
||||
link: '/dashboard/abc',
|
||||
dashboardId: DASHBOARD_ID,
|
||||
dashboardName: 'My Dashboard',
|
||||
createdBy: 'someone-else@signoz.io',
|
||||
source: DashboardtypesSourceDTO.user,
|
||||
isLocked: false,
|
||||
tags: [],
|
||||
onView: jest.fn(),
|
||||
};
|
||||
|
||||
async function openMenu(): Promise<void> {
|
||||
await userEvent.click(screen.getByTestId('dashboard-action-icon'));
|
||||
await screen.findByTestId('dashboard-action-rename');
|
||||
}
|
||||
|
||||
// The authz component puts the denied scopes on the control it disables.
|
||||
function deniedScopes(testId: string): string | null {
|
||||
return screen.getByTestId(testId).getAttribute('data-denied-permissions');
|
||||
}
|
||||
|
||||
describe('ActionsPopover - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('laziness', () => {
|
||||
it('fires no permission check until the menu is opened', 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' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ActionsPopover {...baseProps} />);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(onCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission denied', () => {
|
||||
it('disables the edit actions and explains why when update is denied', async () => {
|
||||
server.use(setupAuthzDeny(buildDashboardUpdatePermission(DASHBOARD_ID)));
|
||||
|
||||
render(<ActionsPopover {...baseProps} />);
|
||||
await openMenu();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deniedScopes('dashboard-action-rename')).toContain(
|
||||
buildDashboardUpdatePermission(DASHBOARD_ID),
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('dashboard-action-rename')).toBeDisabled();
|
||||
expect(screen.getByTestId('dashboard-action-edit-tags')).toBeDisabled();
|
||||
|
||||
// Read-only actions and delete are unaffected.
|
||||
expect(screen.getByTestId('dashboard-action-view')).toBeEnabled();
|
||||
expect(screen.getByTestId('dashboard-action-delete')).toBeEnabled();
|
||||
});
|
||||
|
||||
// Authz guide rule 3: delete does not depend on read.
|
||||
it('keeps delete usable for a user holding only delete', async () => {
|
||||
server.use(setupAuthzAllow(buildDashboardDeletePermission(DASHBOARD_ID)));
|
||||
|
||||
render(<ActionsPopover {...baseProps} />);
|
||||
await openMenu();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard-action-delete')).toBeEnabled();
|
||||
});
|
||||
expect(screen.getByTestId('dashboard-action-rename')).toBeDisabled();
|
||||
expect(screen.getByTestId('dashboard-action-duplicate')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables delete and explains why when delete is denied', async () => {
|
||||
server.use(setupAuthzDeny(buildDashboardDeletePermission(DASHBOARD_ID)));
|
||||
|
||||
render(<ActionsPopover {...baseProps} />);
|
||||
await openMenu();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deniedScopes('dashboard-action-delete')).toBe(
|
||||
buildDashboardDeletePermission(DASHBOARD_ID),
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('dashboard-action-delete')).toBeDisabled();
|
||||
expect(screen.getByTestId('dashboard-action-rename')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('disables duplicate without create, leaving rename usable', async () => {
|
||||
server.use(setupAuthzDeny(DashboardCreatePermission));
|
||||
|
||||
render(<ActionsPopover {...baseProps} />);
|
||||
await openMenu();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard-action-rename')).toBeEnabled();
|
||||
});
|
||||
expect(screen.getByTestId('dashboard-action-duplicate')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lock', () => {});
|
||||
|
||||
describe('locked dashboard', () => {
|
||||
// Access before state: without the permission, the lock is the wrong thing
|
||||
// to point at.
|
||||
it('reports the permission, not the lock, without edit rights', async () => {
|
||||
server.use(setupAuthzDeny(buildDashboardUpdatePermission(DASHBOARD_ID)));
|
||||
|
||||
render(<ActionsPopover {...baseProps} isLocked />);
|
||||
await openMenu();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deniedScopes('dashboard-action-rename')).toContain(
|
||||
buildDashboardUpdatePermission(DASHBOARD_ID),
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('dashboard-action-rename')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('reports the lock, not the permission, for an editor', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(<ActionsPopover {...baseProps} isLocked />);
|
||||
await openMenu();
|
||||
|
||||
// Duplicate is not lock-gated, so it resolving marks the checks as settled.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard-action-duplicate')).toBeEnabled();
|
||||
});
|
||||
expect(screen.getByTestId('dashboard-action-rename')).toBeDisabled();
|
||||
expect(deniedScopes('dashboard-action-rename')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
it('enables every action for an admin', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(<ActionsPopover {...baseProps} />);
|
||||
await openMenu();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard-action-rename')).toBeEnabled();
|
||||
});
|
||||
expect(screen.getByTestId('dashboard-action-edit-tags')).toBeEnabled();
|
||||
expect(screen.getByTestId('dashboard-action-duplicate')).toBeEnabled();
|
||||
expect(screen.getByTestId('dashboard-action-delete')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,11 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* With the button's pointer events off, the wrapper paints the cursor. */
|
||||
.menuItemWrap:has(button:disabled) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.deleteName {
|
||||
color: var(--danger-background);
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user