mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-23 21:20:30 +01:00
Compare commits
2 Commits
feat/story
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9997c3da9c | ||
|
|
485aed0e1a |
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: signoz-page-story
|
||||
description: Explore a SigNoz page, map the endpoints, states and query params it has, then write its Storybook page story with control-driven msw mocks that reach every state. Use when asked to create, extend or review a Storybook story for a page under frontend/src/pages, to add controls to an existing page story, or to write defineStoryMocks handlers and mock data for a page.
|
||||
---
|
||||
|
||||
# SigNoz page stories
|
||||
|
||||
A page story renders the real page inside the real app shell against msw, and its
|
||||
controls panel can reach every state the page has. The panel is the deliverable,
|
||||
not the story list.
|
||||
|
||||
`frontend/src/storybook/README.md` is the API surface (providers, parameters,
|
||||
control builders, module mocks, navigation). Read it first; this skill is the
|
||||
process on top of it.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Map the page**: [references/discovery.md](references/discovery.md). Produce
|
||||
the inventory (endpoints, states, params, permissions, caps) before writing
|
||||
code. No inventory, no story.
|
||||
2. **Skeleton first**: story + empty `defineStoryMocks`, then run it. The console
|
||||
names the endpoints step 1 missed.
|
||||
3. **Inventory to controls**: [references/controls.md](references/controls.md).
|
||||
4. **Mock data and handlers**: builders in `__mockdata__`, handlers in the page's
|
||||
mocks module.
|
||||
5. **Verify in the browser**: [references/verify.md](references/verify.md). Never
|
||||
report the story as done without it.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Default is the loaded page.** `export const Default: Story = {}` with no args,
|
||||
every widget carrying data. Empty, loading and failed are variants or control
|
||||
values, never the default.
|
||||
- **A control is a knob on a response**, resolved through `handlers`, `config` or
|
||||
`effect`. Never a component prop, never a module mock added for one story.
|
||||
- **Every branch in the inventory is reachable from the panel.** A state that
|
||||
needs a code edit to see is a missing control.
|
||||
- **Never re-declare what every story already has**: banner, side nav, data state
|
||||
(loaded/loading/error), access preset, permissions, check state.
|
||||
- **A variant earns a story only when it is worth linking to**: an empty
|
||||
workspace, a viewer, a page mid-load. Everything else stays a control.
|
||||
- **Endpoints the page owns go through `response.json`**, so the Data control
|
||||
covers loaded, loading and failed in one declaration. Endpoints the page cannot
|
||||
render without (ingestion detection, preferences, feature payloads) take a
|
||||
plain resolver so the shell survives the loading and error states.
|
||||
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param
|
||||
navigation works inside a story; a different pathname is blocked and reported
|
||||
by the overlay. A control for a param is worth it only when the param is a page
|
||||
mode someone would want to flip.
|
||||
- **File layout**: `src/pages/<Page>/<Page>.stories.tsx`,
|
||||
`src/pages/<Page>/<Page>.stories.mocks.tsx`, payload builders in
|
||||
`src/pages/<Page>/__mockdata__/<page>.ts`. Nothing page-specific in
|
||||
`src/storybook/controls/`.
|
||||
- **Reuse fixtures** from `src/mocks-server/` and `src/tests/fixtures/` where they
|
||||
exist. An endpoint jest needs too belongs in `src/mocks-server/handlers.ts`.
|
||||
- **Shared response builders live in `src/storybook/msw/__mockdata__/`**: typed
|
||||
helpers like `queryRangeV5ScalarResponse` that multiple pages need. Before
|
||||
writing a response shape inline, check if a builder exists; if not and the
|
||||
shape will repeat, add it there. Page-specific builders stay in the page's
|
||||
`__mockdata__/`.
|
||||
- **No comment is the default.** Write one only for what the code cannot show:
|
||||
a shape the backend dictates, an app bug the mock reproduces, an ordering or
|
||||
cap the page depends on, a workaround and the reason for it. Never restate a
|
||||
name, a type, or what a builder plainly builds; if the sentence reads as the
|
||||
signature in prose, delete it. Nothing addressed to a reviewer. The one
|
||||
comment a story always gets is its own doc comment: what it shows, in the
|
||||
page's own terms.
|
||||
|
||||
## Done means
|
||||
|
||||
- [ ] `Default` shows the page with data, checked in dark and light
|
||||
- [ ] every control flipped once, its effect seen on screen
|
||||
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled
|
||||
request, no React warning
|
||||
- [ ] no navigation overlay on mount
|
||||
- [ ] `pnpm tsgo --noEmit`, `pnpm exec oxlint <files>`,
|
||||
`pnpm exec oxfmt --check <files>` all clean. The repo has no
|
||||
prettier: `pnpm exec prettier` prints a pass while exiting 254
|
||||
@@ -1,163 +0,0 @@
|
||||
# Turning the inventory into controls
|
||||
|
||||
Every row of the inventory becomes a control, a global control that already
|
||||
exists, or a documented reason it cannot be one.
|
||||
|
||||
## Imports
|
||||
|
||||
Paths written as `src/storybook/...` in prose are repo paths, not import
|
||||
specifiers. Stories import through the `@/` alias (`@/*` → `./src/*`); modules
|
||||
inside `src/storybook/` import each other relatively.
|
||||
|
||||
| Import | From |
|
||||
| --- | --- |
|
||||
| `toggleControl`, `countControl`, `choiceControl`, `multiChoiceControl` | `../controls/controls` |
|
||||
| `defineStoryMocks`, `storyMocks` | `../controls/defineStoryMocks` |
|
||||
| `PageStoryArgs` | `../controls/resolveStoryMocks` |
|
||||
| `MockRequest`, `MockResponse` | `../controls/types` |
|
||||
| `withAppLayout` | `@/storybook/decorators/withAppLayout` |
|
||||
| the page's mocks, from the story | `./<Page>.stories.mocks` |
|
||||
| `queryRangeV5ScalarResponse`, `queryRangeV5RawResponse`, etc. | `@/storybook/msw/__mockdata__/queryRange` |
|
||||
|
||||
## Which builder
|
||||
|
||||
`src/storybook/controls/controls.ts`:
|
||||
|
||||
| The state is | Builder |
|
||||
| --- | --- |
|
||||
| on or off (a signal ingesting, a feature present) | `toggleControl` |
|
||||
| how many rows a list has | `countControl` |
|
||||
| one of several modes (tab, visibility, plan, severity filter) | `choiceControl` |
|
||||
| a subset (steps skipped, columns shown, signals selected) | `multiChoiceControl` |
|
||||
|
||||
Rules that come with them:
|
||||
|
||||
- `countControl` `max` goes past what the page renders, so a story can show the
|
||||
cap being hit. `0` is the empty state, which is why an empty list rarely needs
|
||||
its own story. When the cap is in the *request* (`?limit=5`) rather than the
|
||||
renderer, stop `max` at the limit: a longer response is a body the backend
|
||||
cannot send.
|
||||
- `choiceControl` options come from a `const` array typed with
|
||||
`(typeof X)[number]`, not from string literals scattered in the handlers.
|
||||
- Defaults describe the fully-populated page. The panel starts where `Default`
|
||||
starts.
|
||||
- `group` is `'<Page> · <facet>'`, such as `'Services · lists'` or
|
||||
`'Alerts · rules'`. Keep a page's knobs in two or three groups, not one per
|
||||
control.
|
||||
- `description` only when the name does not carry the effect (what dismissing
|
||||
does, what the cap is, which widget it feeds).
|
||||
|
||||
## Which hook
|
||||
|
||||
`defineStoryMocks` takes three, all optional:
|
||||
|
||||
- `handlers(values, response)`: the page's endpoints. Everything the page owns
|
||||
goes through `response.json`, so the global Data control turns the whole page
|
||||
into loading or failed without a second declaration. An endpoint the page
|
||||
cannot render at all without (ingestion detection, preferences, license
|
||||
payloads) takes a plain `rest.get(...)` resolver instead, so the shell stays
|
||||
visible while the rest hangs or fails.
|
||||
- `config(values)`: `SignozStoryConfig` for knobs no endpoint covers: `route`,
|
||||
`appContext`, `reduxState`, `queryBuilder`, `theme`.
|
||||
- `effect(values)`: module-level state no provider exposes.
|
||||
|
||||
One endpoint feeding several widgets stays one handler that reads the request.
|
||||
`response.json` hands the request to the builder and awaits it, so reading a
|
||||
query param, or a POST body, does not cost the Data control:
|
||||
|
||||
```ts
|
||||
rest.get(
|
||||
'http://localhost/api/v1/explorer/views',
|
||||
response.json((req) =>
|
||||
savedViews(values.savedViews, req.url.searchParams.get('sourcePage') ?? 'logs'),
|
||||
),
|
||||
),
|
||||
```
|
||||
|
||||
```ts
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
const signal = body.compositeQuery?.queries?.[0]?.spec?.signal;
|
||||
|
||||
return countResponse(values[`${signal}Ingestion`] ? 4213 : 0);
|
||||
}),
|
||||
),
|
||||
```
|
||||
|
||||
Reach for a plain `rest.post(url, async (req, res, ctx) => …)` only when the
|
||||
endpoint has to keep answering while the Data control is on `loading` or
|
||||
`error`: detection calls the page cannot render without.
|
||||
|
||||
## Mutations
|
||||
|
||||
A control drives the response, so a write the page makes against state a control
|
||||
owns does not stick: the refetch answers with the control's value and the button
|
||||
appears to do nothing. Two honest options: leave it declarative and say so in
|
||||
the PR, or move the state into `effect` so the handler can read what the page
|
||||
wrote. Never fake the write by mutating a builder's module state without saying
|
||||
where the state lives.
|
||||
|
||||
## Wiring it up
|
||||
|
||||
```ts
|
||||
// src/pages/Services/Services.stories.mocks.tsx
|
||||
export const servicesMocks = defineStoryMocks({
|
||||
controls: {
|
||||
services: countControl('Services', { group: LISTS, value: 8, max: 12 }),
|
||||
apdex: choiceControl<ApdexState>('Apdex', {
|
||||
group: HEALTH,
|
||||
options: APDEX_STATES,
|
||||
value: 'mixed',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.post(
|
||||
'http://localhost/api/v2/services',
|
||||
response.json(() => buildServices(values.services, values.apdex)),
|
||||
),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/pages/Services/Services.stories.tsx
|
||||
type ServicesArgs = PageStoryArgs<typeof servicesMocks>;
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Services',
|
||||
component: Services,
|
||||
decorators: [withAppLayout],
|
||||
...storyMocks(servicesMocks, { route: ROUTES.APPLICATION }),
|
||||
} satisfies Meta<ServicesArgs>;
|
||||
```
|
||||
|
||||
`PageStoryArgs` folds in the global controls, so a story's `args` can set
|
||||
`access`, `dataState` or `banner` next to the page's own knobs and stay typed.
|
||||
|
||||
## Not a control
|
||||
|
||||
- Anything the global controls already cover: banner, side nav, data state,
|
||||
access preset, permissions, check state.
|
||||
- A knob whose effect nobody can see on the page. Delete it or find the widget it
|
||||
was supposed to drive.
|
||||
- A raw payload as an object control. Controls carry intent (`5 dashboards`,
|
||||
`viewer`), and the builder turns intent into the payload.
|
||||
- Anything that needs a module mock or a component prop to work. If the state
|
||||
cannot be produced from a response, config or module state, say so in the PR
|
||||
instead of faking it.
|
||||
|
||||
## Control or story
|
||||
|
||||
Default to a control. Write a story when the state is worth a link:
|
||||
|
||||
- the fresh workspace, because that is what a new user sees
|
||||
- the restricted user, when permissions visibly change the page
|
||||
- a page-defining mode (a tab, a category) that has its own layout
|
||||
|
||||
Combinations of controls do not need stories, which is what the panel is for.
|
||||
|
||||
Each story gets one prose doc comment: what it shows, in the page's own terms.
|
||||
Everywhere else the comment rule in SKILL.md applies: write one only for what
|
||||
the code cannot show.
|
||||
@@ -1,64 +0,0 @@
|
||||
# Mapping a page
|
||||
|
||||
Two passes: read the code, then let the running story correct you. Write the
|
||||
inventory down: it is what the controls are derived from, and the only
|
||||
protection against a story that renders one state and calls it a page.
|
||||
|
||||
## Pass 1: read the page
|
||||
|
||||
Start at `src/pages/<Page>/` and follow it outward: the containers it mounts
|
||||
(`src/container/<Feature>/`), the hooks those use, the components with their own
|
||||
fetches. Stop at leaf components that take props only.
|
||||
|
||||
Grep recipes, run against the page's directories:
|
||||
|
||||
| Looking for | Grep |
|
||||
| --- | --- |
|
||||
| endpoints | `useQuery\|useMutation\|useInfiniteQuery`, then the `api/` module it calls |
|
||||
| endpoint URLs | the api module's `axios.get\|post` |
|
||||
| endpoint URLs behind a generated hook | the hook lives in `src/api/generated/services/<name>/index.ts` and the URL only appears in the fetcher body: `rg 'url: \`' src/api/generated/services/<name>/` |
|
||||
| url state | `useUrlQuery\|useUrlQueryData\|useUrlSearchState\|useQueryState\|QueryParams\.` |
|
||||
| navigation | `useSafeNavigate\|history.push\|<Link` |
|
||||
| permissions | `useAuthZ\|AuthZGuard\|AuthZButton\|hasEditPermission\|routePermission` |
|
||||
| flags and prefs | `useFeatureFlag\|FeatureKeys\.\|USER_PREFERENCES\.\|userPreferences` |
|
||||
| empty and error branches | `isLoading\|isError\|isFetching\|length === 0\|!data` |
|
||||
| render caps | `slice(0,\|PAGE_SIZE\|pageSize\|limit` |
|
||||
|
||||
`src/constants/routes.ts` has the route, `src/constants/query.ts` the param names,
|
||||
`src/lib/authz/README.md` how a permission check resolves.
|
||||
|
||||
## The inventory
|
||||
|
||||
One table, in the story's PR or scratch notes:
|
||||
|
||||
| Endpoint | Feeds | States it can be in |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/x` | the header count | populated, zero, error |
|
||||
|
||||
Plus four short lists:
|
||||
|
||||
- **Query params** the page reads, and what each one switches.
|
||||
- **Permission checks** the page makes, and what disappears when each is denied.
|
||||
- **Preferences and flags** that change layout (dismissed banners, onboarding
|
||||
checklists, opt-in views).
|
||||
- **Caps**: how many rows each list renders before it truncates or paginates.
|
||||
|
||||
A state that appears in this inventory and not in the controls panel is a bug in
|
||||
the story.
|
||||
|
||||
## Pass 2: let it run
|
||||
|
||||
Write the story and an empty `defineStoryMocks({ controls: {} })`, point it at the
|
||||
route, add `withAppLayout`, then open it (see verify.md). The console is the
|
||||
oracle:
|
||||
|
||||
- `[storybook] no msw handler` or a 501 from the catch-all: an endpoint pass 1
|
||||
missed. Add it to the inventory.
|
||||
- an msw unhandled-request warning: a request going to an origin the handlers do
|
||||
not answer on. handlers are declared against `http://localhost`.
|
||||
- a spinner that never resolves with the Data control on `loaded`: a handler
|
||||
whose URL does not match what the page calls.
|
||||
- the navigation overlay on mount: the page redirects, usually because `route`
|
||||
is wrong or a guard is failing on a permission the controls have not granted.
|
||||
|
||||
Repeat until the console is silent. Only then start declaring controls.
|
||||
@@ -1,110 +0,0 @@
|
||||
# Verifying a page story
|
||||
|
||||
A story is not done because it compiles. It is done when each control has been
|
||||
seen changing the page and the console is silent.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm storybook --ci --quiet # :6006, background it
|
||||
```
|
||||
|
||||
A newly added `.stories.tsx` takes a few seconds to appear in `index.json` on an
|
||||
already-running server; an empty first poll is not a broken `stories` glob.
|
||||
|
||||
Story ids come from the meta title: `Pages/Services` → `pages-services`, plus the
|
||||
story export in kebab-case. Render one story on its own:
|
||||
|
||||
```
|
||||
http://localhost:6006/iframe.html?id=pages-services--default&viewMode=story
|
||||
```
|
||||
|
||||
## Flip controls from the URL
|
||||
|
||||
Args are settable in the iframe URL, so a whole sweep runs headless without
|
||||
touching the panel. Booleans go as `!true` / `!false`, numbers bare, arrays
|
||||
indexed, several separated by `;`, and the theme through `globals`:
|
||||
|
||||
```
|
||||
&args=services:0;apdex:poor;access:viewer;dataState:loading
|
||||
&args=signals[0]:logs;signals[1]:traces
|
||||
&globals=theme:light
|
||||
```
|
||||
|
||||
That is the cheap way to check a control does something: load with and without
|
||||
it, diff the page text.
|
||||
|
||||
## Drive it
|
||||
|
||||
Playwright lives in the repo's e2e workspace, so a scratch script can use it
|
||||
directly:
|
||||
|
||||
```js
|
||||
import pw from '<repo>/tests/e2e/node_modules/playwright/index.js';
|
||||
const { chromium } = pw;
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const problems = [];
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error' || m.type() === 'warning') problems.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) => problems.push(e.message));
|
||||
|
||||
await page.goto(`${story}&args=services:0`, { waitUntil: 'networkidle' });
|
||||
await page.locator('body').waitFor();
|
||||
console.log((await page.locator('body').innerText()).slice(0, 1500), problems);
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
Screenshots are worth taking for `Default` in both themes
|
||||
(`&globals=theme:light`): text extraction does not catch an unstyled page.
|
||||
|
||||
## Gates
|
||||
|
||||
- **Console silent.** `[storybook] no msw handler`, a 501 from the catch-all, an
|
||||
msw unhandled-request warning, a React key or state warning: assume the story
|
||||
is wrong first. A warning that survives is sometimes the app's. Prove it by
|
||||
turning off the control that renders the widget and watching the warning go
|
||||
with it, and by finding the same component elsewhere doing it right. Then
|
||||
report the app bug in the PR. Never invent a field the API does not return to
|
||||
silence a warning.
|
||||
- **No navigation overlay on mount.** "Navigation blocked in Storybook" on load
|
||||
means the page is trying to leave: wrong `route`, or a guard denying on a
|
||||
permission the controls did not grant.
|
||||
- **Every control moves something.** Sweep them one at a time from the URL and
|
||||
diff the page text. A control with no diff is either wired to nothing or aimed
|
||||
at a widget that is not rendering. Some only show their effect after an
|
||||
interaction, such as a tab that has to be clicked or a select that has to be
|
||||
opened. Drive that interaction rather than calling the control unobservable.
|
||||
- **Both themes render styled.** An unstyled page means the story is not inside
|
||||
the provider decorator, or `<body data-theme>` was lost.
|
||||
- **Roles agree.** `<body data-signoz-story-role>` and
|
||||
`<body data-signoz-context-role>` disagreeing means the page reads a different
|
||||
`AppContext` than the story config fills.
|
||||
- **The page's own navigation works.** Tabs, filters and pagination that write
|
||||
query params should re-render the page in place; only leaving the page belongs
|
||||
in the overlay.
|
||||
|
||||
## Then the usual
|
||||
|
||||
```bash
|
||||
pnpm tsgo --noEmit
|
||||
pnpm exec oxlint <changed files>
|
||||
pnpm exec oxfmt --check <changed files>
|
||||
```
|
||||
|
||||
There is no prettier in this repo. `pnpm exec prettier --check` fetches something
|
||||
else, prints `Prettier: All files formatted correctly` and exits 254: a pass that is
|
||||
not one.
|
||||
|
||||
## Common failures
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| endless spinner with Data on `loaded` | handler URL does not match the call; handlers answer on `http://localhost` |
|
||||
| page renders but empty | response shape wrong; compare against the api module's type, not a guess |
|
||||
| 501 in the console | endpoint nobody mocked; the catch-all is answering |
|
||||
| new control missing from the panel | project-level control added; the tab needs a reload |
|
||||
| control flips but nothing changes | the widget is gated by something else: a permission, a flag, a preference |
|
||||
| shell disappears in `loading` | an endpoint the shell needs went through `response.json`; give it a plain resolver |
|
||||
6
frontend/.gitignore
vendored
6
frontend/.gitignore
vendored
@@ -28,8 +28,4 @@ e2e/test-plan/saved-views/
|
||||
e2e/test-plan/service-map/
|
||||
e2e/test-plan/services/
|
||||
e2e/test-plan/traces/
|
||||
e2e/test-plan/user-preferences/
|
||||
|
||||
# Storybook
|
||||
/storybook-static/
|
||||
debug-storybook.log
|
||||
e2e/test-plan/user-preferences/
|
||||
@@ -1,89 +0,0 @@
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
import type { Plugin, PluginOption } from 'vite';
|
||||
|
||||
const srcPath = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
|
||||
|
||||
/**
|
||||
* Modules replaced for every story. Same idea as `moduleNameMapper` in
|
||||
* `jest.config.ts`: the app keeps importing its own paths, Storybook resolves
|
||||
* them to a mock. Regexes so only exact specifiers match: `lib/history` must
|
||||
* not catch `lib/historyUtils`.
|
||||
*
|
||||
* Each replacement is typed as the module it stands in for, so drift is a
|
||||
* compile error rather than a story that fails at render. The `jest` note on
|
||||
* each entry is where the same import lands under the other runner. The two
|
||||
* only diverge where the runner needs them to.
|
||||
*/
|
||||
const mockAliases = [
|
||||
{
|
||||
// jest: not replaced, jsdom drives a real browser history.
|
||||
find: /^(?:src\/)?lib\/history$/,
|
||||
replacement: `${srcPath}/storybook/navigation/history.alias.ts`,
|
||||
},
|
||||
{
|
||||
// jest: src/__tests__/logEventMock.ts
|
||||
find: /^(?:src\/)?api\/common\/logEvent$/,
|
||||
replacement: `${srcPath}/storybook/mocks/logEvent.mock.ts`,
|
||||
},
|
||||
{
|
||||
// jest: __mocks__/env.ts, which leaves `baseURL` empty because jsdom already
|
||||
// resolves a relative `/api/...` against `http://localhost`.
|
||||
find: /^(?:src\/)?constants\/env$/,
|
||||
replacement: `${srcPath}/storybook/mocks/env.mock.ts`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Plugins from `vite.config.ts` that either target the app's `index.html` or
|
||||
* only pay off in a production build.
|
||||
*/
|
||||
const EXCLUDED_PLUGINS = [
|
||||
'vite-plugin-checker',
|
||||
'dev-base-path',
|
||||
'dev-boot-data',
|
||||
'vite-plugin-image-optimizer',
|
||||
'vite-plugin-compression',
|
||||
];
|
||||
|
||||
const isExcluded = (plugin: PluginOption): boolean =>
|
||||
!!plugin &&
|
||||
typeof plugin === 'object' &&
|
||||
'name' in plugin &&
|
||||
EXCLUDED_PLUGINS.includes((plugin as Plugin).name);
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: '@storybook/react-vite',
|
||||
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
||||
// `../public` carries the fonts, icons and i18n bundles the app expects at
|
||||
// the root; `./public` carries the msw worker, which must not ship in a
|
||||
// production build.
|
||||
staticDirs: ['../public', './public'],
|
||||
addons: [],
|
||||
core: { disableTelemetry: true },
|
||||
viteFinal: async (viteConfig) => {
|
||||
const plugins = (viteConfig.plugins ?? [])
|
||||
.flat(Infinity as 1)
|
||||
.filter((plugin) => !isExcluded(plugin as PluginOption));
|
||||
|
||||
const existingAlias = viteConfig.resolve?.alias;
|
||||
const normalizedAlias = Array.isArray(existingAlias)
|
||||
? existingAlias
|
||||
: Object.entries(existingAlias ?? {}).map(([find, replacement]) => ({
|
||||
find,
|
||||
replacement: replacement as string,
|
||||
}));
|
||||
|
||||
return {
|
||||
...viteConfig,
|
||||
plugins,
|
||||
resolve: {
|
||||
...viteConfig.resolve,
|
||||
alias: [...mockAliases, ...normalizedAlias],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,24 +0,0 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="css/uPlot.min.css" />
|
||||
|
||||
<script>
|
||||
// i18n's language detector would pick the browser locale (e.g. `en-US`), which
|
||||
// /public/locales does not ship; pin it to the bundled language instead.
|
||||
window.localStorage.setItem('i18nextLng', 'en');
|
||||
|
||||
// The Go backend injects this at boot; every integration it enables is off in
|
||||
// Storybook so no third-party script loads inside the iframe.
|
||||
window.signozBootData = {
|
||||
settings: {
|
||||
posthog: { enabled: false, apiHost: '', key: '', uiHost: '' },
|
||||
appcues: { enabled: false, appId: '' },
|
||||
sentry: { enabled: false, dsn: '', tunnel: '' },
|
||||
pylon: { enabled: false, appId: '', identitySecret: '' },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,110 +0,0 @@
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
import type { SetupWorker } from 'msw';
|
||||
import { setupWorker } from 'msw';
|
||||
|
||||
import { withProviders } from '../src/storybook/decorators/withProviders';
|
||||
import { globalMocks } from '../src/storybook/globals';
|
||||
import { resetStoryHistory } from '../src/storybook/navigation/containment';
|
||||
import { clearBlockedNavigations } from '../src/storybook/navigation/blockedNavigationStore';
|
||||
import {
|
||||
resolveStory,
|
||||
type StoryRuntimeContext,
|
||||
} from '../src/storybook/runtime/resolveStory';
|
||||
|
||||
import '../src/ReactI18';
|
||||
|
||||
import '../src/styles.scss';
|
||||
|
||||
import '../src/storybook/storybook-root.scss';
|
||||
|
||||
interface StorybookWorkerHolder {
|
||||
__signozStorybookWorker?: StorybookWorker;
|
||||
}
|
||||
|
||||
const holder = window as unknown as StorybookWorkerHolder;
|
||||
|
||||
/**
|
||||
* One worker per page, even if this module is re-executed by HMR. Two live
|
||||
* workers both answer the service worker and the story gets whichever replies
|
||||
* first.
|
||||
*/
|
||||
interface StorybookWorker {
|
||||
worker: SetupWorker;
|
||||
ready: Promise<unknown>;
|
||||
}
|
||||
|
||||
const { worker, ready } = (holder.__signozStorybookWorker ??=
|
||||
((): StorybookWorker => {
|
||||
const instance = setupWorker();
|
||||
|
||||
return {
|
||||
worker: instance,
|
||||
ready: instance.start({
|
||||
serviceWorker: { url: './mockServiceWorker.js' },
|
||||
// Storybook's own traffic (index.json, HMR, telemetry) goes unhandled by
|
||||
// design; only flag the app's API calls so a missing handler is obvious.
|
||||
onUnhandledRequest: (request, print): void => {
|
||||
const url = new URL(request.url.href);
|
||||
const isStaticAsset =
|
||||
/\.(?:woff2?|ttf|otf|css|js|map|png|jpe?g|svg|webp|ico)$/.test(
|
||||
url.pathname,
|
||||
);
|
||||
const isAppRequest =
|
||||
!isStaticAsset &&
|
||||
(url.pathname.startsWith('/api/') || url.host !== window.location.host);
|
||||
|
||||
if (isAppRequest) {
|
||||
print.warning();
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
})());
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
controls: { expanded: true },
|
||||
},
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: 'SigNoz color scheme',
|
||||
toolbar: {
|
||||
title: 'Theme',
|
||||
icon: 'paintbrush',
|
||||
items: [
|
||||
{ value: 'dark', title: 'Dark' },
|
||||
{ value: 'light', title: 'Light' },
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: { theme: 'dark' },
|
||||
// Controls every story carries: permissions, banners, and whether the page's
|
||||
// own endpoints answer, hang or fail.
|
||||
args: globalMocks.args,
|
||||
argTypes: globalMocks.argTypes,
|
||||
decorators: [withProviders],
|
||||
loaders: [
|
||||
// Runs on every render, args changes included, and ahead of the decorators:
|
||||
// the whole story world is put in place here, so the provider tree only has
|
||||
// to read it. Re-registering the handlers per render also means an edit to a
|
||||
// handler module takes effect on the next render instead of leaving the
|
||||
// worker on the set it was created with.
|
||||
async (context): Promise<void> => {
|
||||
const world = resolveStory(context as unknown as StoryRuntimeContext);
|
||||
|
||||
world.apply();
|
||||
world.install(worker);
|
||||
|
||||
await ready;
|
||||
},
|
||||
],
|
||||
beforeEach: () => {
|
||||
clearBlockedNavigations();
|
||||
resetStoryHistory();
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -1,303 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker (1.3.2).
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
|
||||
const activeClientIds = new Set()
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: INTEGRITY_CHECKSUM,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
const accept = request.headers.get('accept') || ''
|
||||
|
||||
// Bypass server-sent events.
|
||||
if (accept.includes('text/event-stream')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = Math.random().toString(16).slice(2)
|
||||
|
||||
event.respondWith(
|
||||
handleRequest(event, requestId).catch((error) => {
|
||||
if (error.name === 'NetworkError') {
|
||||
console.warn(
|
||||
'[MSW] Successfully emulated a network error for the "%s %s" request.',
|
||||
request.method,
|
||||
request.url,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// At this point, any exception indicates an issue with the original request/response.
|
||||
console.error(
|
||||
`\
|
||||
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
|
||||
request.method,
|
||||
request.url,
|
||||
`${error.name}: ${error.message}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
;(async function () {
|
||||
const clonedResponse = response.clone()
|
||||
sendToClient(client, {
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
type: clonedResponse.type,
|
||||
ok: clonedResponse.ok,
|
||||
status: clonedResponse.status,
|
||||
statusText: clonedResponse.statusText,
|
||||
body:
|
||||
clonedResponse.body === null ? null : await clonedResponse.text(),
|
||||
headers: Object.fromEntries(clonedResponse.headers.entries()),
|
||||
redirected: clonedResponse.redirected,
|
||||
},
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event
|
||||
const clonedRequest = request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const headers = Object.fromEntries(clonedRequest.headers.entries())
|
||||
|
||||
// Remove MSW-specific request headers so the bypassed requests
|
||||
// comply with the server's CORS preflight check.
|
||||
// Operate with the headers as an object because request "Headers"
|
||||
// are immutable.
|
||||
delete headers['x-msw-bypass']
|
||||
|
||||
return fetch(clonedRequest, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass requests with the explicit bypass header.
|
||||
// Such requests can be issued by "ctx.fetch()".
|
||||
if (request.headers.get('x-msw-bypass') === 'true') {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const clientMessage = await sendToClient(client, {
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
mode: request.mode,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.text(),
|
||||
bodyUsed: request.bodyUsed,
|
||||
keepalive: request.keepalive,
|
||||
},
|
||||
})
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'MOCK_NOT_FOUND': {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
case 'NETWORK_ERROR': {
|
||||
const { name, message } = clientMessage.data
|
||||
const networkError = new Error(message)
|
||||
networkError.name = name
|
||||
|
||||
// Rejecting a "respondWith" promise emulates a network error.
|
||||
throw networkError
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
function sendToClient(client, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [channel.port2])
|
||||
})
|
||||
}
|
||||
|
||||
function sleep(timeMs) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, timeMs)
|
||||
})
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
await sleep(response.delay)
|
||||
return new Response(response.body, response)
|
||||
}
|
||||
@@ -88,17 +88,6 @@ pnpm test
|
||||
pnpm tsgo --noEmit
|
||||
```
|
||||
|
||||
## Storybook
|
||||
|
||||
```bash
|
||||
pnpm storybook
|
||||
```
|
||||
|
||||
Opens [http://localhost:6006](http://localhost:6006). Pages run against msw
|
||||
mocks with no backend; query-param navigation works inside a story, leaving the
|
||||
page is blocked. See [`src/storybook/README.md`](src/storybook/README.md) for the
|
||||
override surface.
|
||||
|
||||
## Linting
|
||||
|
||||
```bash
|
||||
|
||||
@@ -62,6 +62,40 @@ if (typeof window.ResizeObserver === 'undefined') {
|
||||
(window as any).ResizeObserver = ResizeObserverMock;
|
||||
}
|
||||
|
||||
if (typeof globalThis.DOMRect === 'undefined') {
|
||||
(globalThis as any).DOMRect = class DOMRect {
|
||||
x = 0;
|
||||
y = 0;
|
||||
width = 0;
|
||||
height = 0;
|
||||
top = 0;
|
||||
right = 0;
|
||||
bottom = 0;
|
||||
left = 0;
|
||||
constructor(x = 0, y = 0, width = 0, height = 0) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.top = y;
|
||||
this.right = x + width;
|
||||
this.bottom = y + height;
|
||||
this.left = x;
|
||||
}
|
||||
toJSON(): any {
|
||||
return { x: this.x, y: this.y, width: this.width, height: this.height };
|
||||
}
|
||||
static fromRect(rect?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}): DOMRect {
|
||||
return new DOMRect(rect?.x, rect?.y, rect?.width, rect?.height);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
|
||||
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
|
||||
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"i18n:generate-hash": "node ./i18-generate-hash.cjs",
|
||||
"dev": "vite",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build -o storybook-static",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prettify": "oxfmt",
|
||||
@@ -50,9 +48,9 @@
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sentry/react": "10.57.0",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/design-tokens": "2.1.6",
|
||||
"@signozhq/icons": "0.4.0",
|
||||
"@signozhq/ui": "0.0.23",
|
||||
"@signozhq/ui": "0.1.0",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -160,7 +158,6 @@
|
||||
"@commitlint/config-conventional": "20.4.4",
|
||||
"@jest/globals": "30.4.1",
|
||||
"@jest/types": "30.2.0",
|
||||
"@storybook/react-vite": "10.5.9",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "13.4.0",
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
@@ -206,7 +203,6 @@
|
||||
"redux-mock-store": "1.5.4",
|
||||
"sass": "1.97.3",
|
||||
"sharp": "0.35.0",
|
||||
"storybook": "10.5.9",
|
||||
"stylelint": "17.7.0",
|
||||
"svgo": "4.0.2",
|
||||
"ts-jest": "29.4.9",
|
||||
@@ -242,4 +238,4 @@
|
||||
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1787
frontend/pnpm-lock.yaml
generated
1787
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -664,6 +664,7 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
value={limit?.toString()}
|
||||
defaultValue="10"
|
||||
onChange={(value): void => {
|
||||
value ??= '10';
|
||||
setLimit(+value);
|
||||
pagination.onLimitChange?.(+value);
|
||||
if (page !== 1) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import BarChart from 'container/DashboardContainer/visualization/charts/BarChart
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
@@ -131,9 +132,9 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
|
||||
<div ref={graphRef} className={styles.graphContainer}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
stack={StackMode.Normal}
|
||||
config={config}
|
||||
data={chartData}
|
||||
isStackedBarChart
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
customTooltip={renderBillingTooltip}
|
||||
width={containerDimensions.width}
|
||||
|
||||
@@ -58,26 +58,17 @@ describe('prepareBillingBarConfig', () => {
|
||||
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
|
||||
});
|
||||
|
||||
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
|
||||
it('sets padding and focus alpha for behavioral parity', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
|
||||
// Stacking bands come from the chart now — see useChartStacking.
|
||||
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
|
||||
expect(config.focus).toStrictEqual({ alpha: 0.3 });
|
||||
});
|
||||
|
||||
it('sets no bands when result is empty', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse([]),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses queryName as label when legend is undefined', () => {
|
||||
const apiResponse: MetricRangePayloadProps = {
|
||||
data: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
@@ -63,7 +62,6 @@ export function prepareBillingBarConfig({
|
||||
});
|
||||
});
|
||||
|
||||
builder.setBands(getInitialStackedBands(results.length));
|
||||
builder.setPadding([32, 32, 16, 16]);
|
||||
builder.setFocus({ alpha: 0.3 });
|
||||
|
||||
|
||||
@@ -6,25 +6,24 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { useBarChartStacking } from '../../hooks/useBarChartStacking';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { BarChartProps } from '../types';
|
||||
|
||||
export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
const {
|
||||
children,
|
||||
isStackedBarChart,
|
||||
customTooltip,
|
||||
config,
|
||||
data,
|
||||
stack = StackMode.None,
|
||||
pinnedTooltipElement,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const chartData = useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart,
|
||||
config,
|
||||
});
|
||||
// Written during render so it lands before UPlotChart's effect reads the config,
|
||||
// which derives the fill bands, percent axis unit and percent range from it.
|
||||
config.setStackMode(stack);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
@@ -37,7 +36,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
isStackedBarChart: isStackedBarChart,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
@@ -48,7 +46,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
rest.timezone,
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
isStackedBarChart,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
@@ -58,7 +55,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
<ChartWrapper
|
||||
{...rest}
|
||||
config={config}
|
||||
data={chartData}
|
||||
data={data}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import { prepareAlignedData } from 'lib/uPlotV2/components/UPlotChart/utils';
|
||||
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
|
||||
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
|
||||
import noop from 'lodash-es/noop';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ChartProps } from '../types';
|
||||
import { ChartWrapperProps } from '../types';
|
||||
import { useChartStacking } from './useChartStacking';
|
||||
|
||||
const TOOLTIP_WIDTH_PADDING = 120;
|
||||
const TOOLTIP_MIN_WIDTH = 300;
|
||||
@@ -39,9 +42,20 @@ export default function ChartWrapper({
|
||||
pinnedTooltipElement,
|
||||
tooltipPortalRoot,
|
||||
'data-testid': testId,
|
||||
}: ChartProps): JSX.Element {
|
||||
}: ChartWrapperProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
|
||||
const stack = config.getStackMode();
|
||||
const chartData = useChartStacking({ data, config });
|
||||
|
||||
// Tooltips need pre-stack values, gap-processed exactly as UPlotChart processes the
|
||||
// plot data — otherwise the cursor's index addresses a shorter array.
|
||||
const unstackedData = useMemo(
|
||||
() =>
|
||||
stack === StackMode.None ? undefined : prepareAlignedData({ data, config }),
|
||||
[data, config, stack],
|
||||
);
|
||||
|
||||
const legendComponent = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => {
|
||||
if (!showLegend) {
|
||||
@@ -61,11 +75,11 @@ export default function ChartWrapper({
|
||||
const renderTooltipCallback = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => {
|
||||
if (customTooltip) {
|
||||
return customTooltip(args);
|
||||
return customTooltip({ ...args, unstackedData });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[customTooltip],
|
||||
[customTooltip, unstackedData],
|
||||
);
|
||||
|
||||
const syncMetadata = useMemo(
|
||||
@@ -91,7 +105,7 @@ export default function ChartWrapper({
|
||||
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
|
||||
<UPlotChart
|
||||
config={config}
|
||||
data={data}
|
||||
data={chartData}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
plotRef={(plot): void => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { useChartStacking } from '../useChartStacking';
|
||||
|
||||
type Hooks = Record<string, (...args: unknown[]) => void>;
|
||||
|
||||
function createConfig(stack: StackMode): {
|
||||
config: UPlotConfigBuilder;
|
||||
hooks: Hooks;
|
||||
} {
|
||||
const hooks: Hooks = {};
|
||||
const config = {
|
||||
getStackMode: (): StackMode => stack,
|
||||
addHook: jest.fn((type: string, hook: (...args: unknown[]) => void) => {
|
||||
hooks[type] = hook;
|
||||
return jest.fn();
|
||||
}),
|
||||
} as unknown as UPlotConfigBuilder;
|
||||
return { config, hooks };
|
||||
}
|
||||
|
||||
const data = [[1], [30], [10]] as unknown as uPlot.AlignedData;
|
||||
|
||||
describe('useChartStacking', () => {
|
||||
it('returns the data untouched and registers nothing when the config says `none`', () => {
|
||||
const { config } = createConfig(StackMode.None);
|
||||
const { result } = renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
expect(result.current).toBe(data);
|
||||
expect(config.addHook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats a missing config as unstacked', () => {
|
||||
const { result } = renderHook(() => useChartStacking({ data, config: null }));
|
||||
|
||||
expect(result.current).toBe(data);
|
||||
});
|
||||
|
||||
it('accumulates raw values when the config declares `normal`', () => {
|
||||
const { config } = createConfig(StackMode.Normal);
|
||||
const { result } = renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
expect(result.current).toStrictEqual([[1], [40], [10]]);
|
||||
});
|
||||
|
||||
it('rescales each column to its total when the config declares `percent`', () => {
|
||||
const { config } = createConfig(StackMode.Percent);
|
||||
const { result } = renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
expect(result.current).toStrictEqual([[1], [100], [25]]);
|
||||
});
|
||||
|
||||
it('registers the uPlot hooks that re-stack on data and visibility changes', () => {
|
||||
const { config } = createConfig(StackMode.Normal);
|
||||
renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
expect(
|
||||
(config.addHook as jest.Mock).mock.calls.map(([type]) => type),
|
||||
).toStrictEqual(['setData', 'setSeries']);
|
||||
});
|
||||
|
||||
it('re-stacks from the raw values when the legend hides a series', () => {
|
||||
const { config, hooks } = createConfig(StackMode.Normal);
|
||||
renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
const plot = {
|
||||
data: [[1]],
|
||||
series: [{}, { show: true }, { show: false }],
|
||||
delBand: jest.fn(),
|
||||
addBand: jest.fn(),
|
||||
setData: jest.fn(),
|
||||
};
|
||||
hooks.setSeries(plot, 2, { show: false });
|
||||
|
||||
// The hidden series keeps its raw value and stops contributing to the total.
|
||||
expect(plot.setData).toHaveBeenCalledWith([[1], [30], [10]]);
|
||||
expect(plot.delBand).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('ignores a focus-only setSeries so hovering does not re-stack', () => {
|
||||
const { config, hooks } = createConfig(StackMode.Normal);
|
||||
renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
const plot = {
|
||||
data: [[1]],
|
||||
series: [{}, { show: true }, { show: true }],
|
||||
delBand: jest.fn(),
|
||||
addBand: jest.fn(),
|
||||
setData: jest.fn(),
|
||||
};
|
||||
hooks.setSeries(plot, 1, { focus: true });
|
||||
|
||||
expect(plot.setData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import { has } from 'lodash-es';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { stackSeries } from '../utils/stackSeriesUtils';
|
||||
|
||||
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
|
||||
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
|
||||
return !plot.series[seriesIndex]?.show;
|
||||
}
|
||||
|
||||
function canApplyStacking(
|
||||
unstackedData: uPlot.AlignedData | null,
|
||||
plot: uPlot,
|
||||
isUpdating: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
!isUpdating &&
|
||||
!!unstackedData &&
|
||||
!!plot.data &&
|
||||
unstackedData[0]?.length === plot.data[0]?.length
|
||||
);
|
||||
}
|
||||
|
||||
function setupStackingHooks(
|
||||
config: UPlotConfigBuilder,
|
||||
updateStacksInChart: (plot: uPlot) => void,
|
||||
isUpdatingRef: MutableRefObject<boolean>,
|
||||
): () => void {
|
||||
const onDataChange = (plot: uPlot): void => {
|
||||
if (!isUpdatingRef.current) {
|
||||
updateStacksInChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const onSeriesVisibilityChange = (
|
||||
plot: uPlot,
|
||||
_seriesIdx: number | null,
|
||||
opts: uPlot.Series,
|
||||
): void => {
|
||||
// uPlot fires setSeries for hover focus too; only visibility changes restack.
|
||||
if (!has(opts, 'focus')) {
|
||||
updateStacksInChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSetDataHook = config.addHook('setData', onDataChange);
|
||||
const removeSetSeriesHook = config.addHook(
|
||||
'setSeries',
|
||||
onSeriesVisibilityChange,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
removeSetDataHook?.();
|
||||
removeSetSeriesHook?.();
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseChartStackingParams {
|
||||
data: uPlot.AlignedData;
|
||||
config: UPlotConfigBuilder | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacks a chart's data for the mode declared on its config, and re-stacks on data or
|
||||
* visibility changes. The pre-stack values live in a ref because the uPlot hooks that
|
||||
* read them run outside React's render cycle.
|
||||
*/
|
||||
export function useChartStacking({
|
||||
data,
|
||||
config,
|
||||
}: UseChartStackingParams): uPlot.AlignedData {
|
||||
const stack = config?.getStackMode() ?? StackMode.None;
|
||||
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
|
||||
unstackedDataRef.current = stack === 'none' ? null : data;
|
||||
|
||||
// Guards the re-entrant setData below, which would otherwise re-trigger our own hook.
|
||||
const isUpdatingChartRef = useRef(false);
|
||||
|
||||
const chartData = useMemo((): uPlot.AlignedData => {
|
||||
if (stack === StackMode.None || !data || data.length < 2) {
|
||||
return data;
|
||||
}
|
||||
const noSeriesHidden = (): boolean => false; // include all series in initial stack
|
||||
return stackSeries(data, noSeriesHidden, stack).data;
|
||||
}, [data, stack]);
|
||||
|
||||
const updateStacksInChart = useCallback(
|
||||
(plot: uPlot): void => {
|
||||
const unstacked = unstackedDataRef.current;
|
||||
if (
|
||||
!unstacked ||
|
||||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldExcludeSeries = (idx: number): boolean =>
|
||||
isSeriesHidden(plot, idx);
|
||||
const { data: stacked, bands } = stackSeries(
|
||||
unstacked,
|
||||
shouldExcludeSeries,
|
||||
stack,
|
||||
);
|
||||
|
||||
plot.delBand(null);
|
||||
bands.forEach((band: uPlot.Band) => plot.addBand(band));
|
||||
|
||||
isUpdatingChartRef.current = true;
|
||||
plot.setData(stacked);
|
||||
isUpdatingChartRef.current = false;
|
||||
},
|
||||
[stack],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (stack === StackMode.None || !config) {
|
||||
return undefined;
|
||||
}
|
||||
return setupStackingHooks(config, updateStacksInChart, isUpdatingChartRef);
|
||||
}, [stack, config, updateStacksInChart]);
|
||||
|
||||
return chartData;
|
||||
}
|
||||
@@ -6,10 +6,16 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { TimeSeriesChartProps } from '../types';
|
||||
|
||||
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
const { children, customTooltip, ...rest } = props;
|
||||
const { children, customTooltip, stack = StackMode.None, ...rest } = props;
|
||||
|
||||
// Written during render so it lands before UPlotChart's effect reads the config,
|
||||
// which derives the fill bands, percent axis unit and percent range from it.
|
||||
rest.config.setStackMode(stack);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ChartClickData,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
interface BaseChartProps {
|
||||
width: number;
|
||||
@@ -52,27 +53,26 @@ interface UPlotChartDataProps {
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
|
||||
/** Everything the shared uPlot shell consumes; each chart's props narrow it. */
|
||||
export interface ChartWrapperProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {}
|
||||
|
||||
export interface TimeSeriesChartProps extends ChartWrapperProps {
|
||||
timezone?: Timezone;
|
||||
/** How series compose. Defaults to `none`, which draws them independently. */
|
||||
stack?: StackMode;
|
||||
}
|
||||
|
||||
export interface HistogramChartProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
|
||||
export interface BarChartProps extends ChartWrapperProps {
|
||||
timezone?: Timezone;
|
||||
/** How series compose. Defaults to `none`, which draws them independently. */
|
||||
stack?: StackMode;
|
||||
}
|
||||
|
||||
export interface HistogramChartProps extends ChartWrapperProps {
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
export interface BarChartProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
|
||||
isStackedBarChart?: boolean;
|
||||
timezone?: Timezone;
|
||||
}
|
||||
|
||||
export type ChartProps =
|
||||
| TimeSeriesChartProps
|
||||
| BarChartProps
|
||||
| HistogramChartProps;
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { stackSeries } from '../stackSeriesUtils';
|
||||
|
||||
const includeAll = (): boolean => false;
|
||||
|
||||
// Stacking is top-down: the first series carries the column total, the last its own
|
||||
// raw value. Every expectation below reads in that order.
|
||||
describe('stackSeries', () => {
|
||||
it('is a no-op under `none`, returning the data and no bands', () => {
|
||||
const data: AlignedData = [[1], [30], [10]];
|
||||
|
||||
const { data: result, bands } = stackSeries(data, includeAll, StackMode.None);
|
||||
|
||||
expect(result).toBe(data);
|
||||
expect(bands).toStrictEqual([]);
|
||||
});
|
||||
|
||||
describe('normal', () => {
|
||||
it('accumulates raw values from the bottom series upward', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[10, 20],
|
||||
[1, 2],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[11, 22],
|
||||
[1, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats nulls as 0 without breaking the running total', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[10, null],
|
||||
[1, 2],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[11, 2],
|
||||
[1, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits one band per adjacent pair of participating series', () => {
|
||||
const data: AlignedData = [[1], [10], [5], [1]];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Normal).bands).toStrictEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies omitted series through unstacked and skips their bands', () => {
|
||||
const data: AlignedData = [[1], [10], [5], [1]];
|
||||
const omitMiddle = (seriesIndex: number): boolean => seriesIndex === 2;
|
||||
|
||||
const { data: stacked, bands } = stackSeries(
|
||||
data,
|
||||
omitMiddle,
|
||||
StackMode.Normal,
|
||||
);
|
||||
|
||||
expect(stacked).toStrictEqual([[1], [11], [5], [1]]);
|
||||
expect(bands).toStrictEqual([{ series: [1, 3] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('percent', () => {
|
||||
it('rescales each column to its total so the top series reads 100', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[30, 10],
|
||||
[10, 10],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[100, 100],
|
||||
[25, 50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalises per column, so an identical series differs across x', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[1, 3],
|
||||
[1, 1],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[100, 100],
|
||||
[50, 25],
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes omitted series from the total, so the visible ones still reach 100', () => {
|
||||
const data: AlignedData = [[1], [30], [10], [60]];
|
||||
const omitLast = (seriesIndex: number): boolean => seriesIndex === 3;
|
||||
|
||||
expect(stackSeries(data, omitLast, StackMode.Percent).data).toStrictEqual([
|
||||
[1],
|
||||
[100],
|
||||
[25],
|
||||
[60],
|
||||
]);
|
||||
});
|
||||
|
||||
it('yields 0 for a column whose participating series sum to zero', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[0, 5],
|
||||
[0, 5],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[0, 100],
|
||||
[0, 50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('divides by the signed total when a column mixes signs', () => {
|
||||
// 30 + (-10) = 20, so the shares are 150% and -50% and still sum to 100.
|
||||
const data: AlignedData = [[1], [30], [-10]];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1],
|
||||
[100],
|
||||
[-50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('yields 0 across a column whose signed total cancels to zero', () => {
|
||||
const data: AlignedData = [[1], [10], [-10]];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1],
|
||||
[0],
|
||||
[0],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to normal when no mode is given', () => {
|
||||
const data: AlignedData = [[1], [30], [10]];
|
||||
|
||||
expect(stackSeries(data, includeAll).data).toStrictEqual(
|
||||
stackSeries(data, includeAll, StackMode.Normal).data,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
import { getInitialStackedBands, stack } from '../stackUtils';
|
||||
|
||||
describe('stackUtils', () => {
|
||||
describe('stack', () => {
|
||||
const neverOmit = (): boolean => false;
|
||||
|
||||
it('preserves time axis as first row', () => {
|
||||
const data: AlignedData = [
|
||||
[100, 200, 300],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
];
|
||||
const { data: result } = stack(data, neverOmit);
|
||||
expect(result[0]).toStrictEqual([100, 200, 300]);
|
||||
});
|
||||
|
||||
it('stacks value series cumulatively (last = raw, first = total)', () => {
|
||||
// Time, then 3 value series. Stack order: last series stays raw, then we add upward.
|
||||
const data: AlignedData = [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3], // series 1
|
||||
[4, 5, 6], // series 2
|
||||
[7, 8, 9], // series 3
|
||||
];
|
||||
const { data: result } = stack(data, neverOmit);
|
||||
// result[1] = s1+s2+s3, result[2] = s2+s3, result[3] = s3
|
||||
expect(result[1]).toStrictEqual([12, 15, 18]); // 1+4+7, 2+5+8, 3+6+9
|
||||
expect(result[2]).toStrictEqual([11, 13, 15]); // 4+7, 5+8, 6+9
|
||||
expect(result[3]).toStrictEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
it('treats null values as 0 when stacking', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[1, null],
|
||||
[null, 10],
|
||||
];
|
||||
const { data: result } = stack(data, neverOmit);
|
||||
expect(result[1]).toStrictEqual([1, 10]); // total
|
||||
expect(result[2]).toStrictEqual([0, 10]); // last series with null→0
|
||||
});
|
||||
|
||||
it('copies omitted series as-is without accumulating', () => {
|
||||
// Omit series 2 (index 2); series 1 and 3 are stacked.
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[10, 20], // series 1
|
||||
[100, 200], // series 2 - omitted
|
||||
[1, 2], // series 3
|
||||
];
|
||||
const omitSeries2 = (i: number): boolean => i === 2;
|
||||
const { data: result } = stack(data, omitSeries2);
|
||||
// series 3 raw: [1, 2]; series 2 omitted: [100, 200] as-is; series 1 stacked with s3: [11, 22]
|
||||
expect(result[1]).toStrictEqual([11, 22]); // 10+1, 20+2
|
||||
expect(result[2]).toStrictEqual([100, 200]); // copied, not stacked
|
||||
expect(result[3]).toStrictEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('returns bands between consecutive visible series when none omitted', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
[5, 6],
|
||||
];
|
||||
const { bands } = stack(data, neverOmit);
|
||||
expect(bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
|
||||
});
|
||||
|
||||
it('returns bands only between visible series when some are omitted', () => {
|
||||
// 4 value series; omit index 2. Visible: 1, 3, 4. Bands: [1,3], [3,4]
|
||||
const data: AlignedData = [[0], [1], [2], [3], [4]];
|
||||
const omitSeries2 = (i: number): boolean => i === 2;
|
||||
const { bands } = stack(data, omitSeries2);
|
||||
expect(bands).toStrictEqual([{ series: [1, 3] }, { series: [3, 4] }]);
|
||||
});
|
||||
|
||||
it('returns empty bands when only one value series', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
];
|
||||
const { bands } = stack(data, neverOmit);
|
||||
expect(bands).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInitialStackedBands', () => {
|
||||
it('returns one band between each consecutive pair for seriesCount 3', () => {
|
||||
expect(getInitialStackedBands(3)).toStrictEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array for seriesCount 0 or 1', () => {
|
||||
expect(getInitialStackedBands(0)).toStrictEqual([]);
|
||||
expect(getInitialStackedBands(1)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns single band for seriesCount 2', () => {
|
||||
expect(getInitialStackedBands(2)).toStrictEqual([{ series: [1, 2] }]);
|
||||
});
|
||||
|
||||
it('returns bands [1,2], [2,3], ..., [n-1, n] for seriesCount n', () => {
|
||||
const bands = getInitialStackedBands(5);
|
||||
expect(bands).toStrictEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
{ series: [3, 4] },
|
||||
{ series: [4, 5] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,20 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import uPlot, { AlignedData } from 'uplot';
|
||||
|
||||
/**
|
||||
* Stack data cumulatively (top-down: first series = top, last = bottom).
|
||||
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
|
||||
* When `omit(seriesIndex)` returns true, that series keeps its raw values and
|
||||
* contributes nothing to the total. `None` is a no-op.
|
||||
*/
|
||||
export function stackSeries(
|
||||
data: AlignedData,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
mode: StackMode = StackMode.Normal,
|
||||
): { data: AlignedData; bands: uPlot.Band[] } {
|
||||
if (mode === StackMode.None) {
|
||||
return { data, bands: [] };
|
||||
}
|
||||
|
||||
const timeAxis = data[0];
|
||||
const pointCount = timeAxis.length;
|
||||
const valueSeriesCount = data.length - 1; // exclude time axis
|
||||
@@ -17,6 +24,7 @@ export function stackSeries(
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
mode,
|
||||
});
|
||||
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
|
||||
|
||||
@@ -31,6 +39,46 @@ interface BuildStackedSeriesParams {
|
||||
valueSeriesCount: number;
|
||||
pointCount: number;
|
||||
omit: (seriesIndex: number) => boolean;
|
||||
mode: StackMode;
|
||||
}
|
||||
|
||||
/** Per-point total. Mixed-sign columns sum signed, as "share of total" implies. */
|
||||
function columnTotals({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
}: Omit<BuildStackedSeriesParams, 'mode'>): number[] {
|
||||
const totals = Array(pointCount).fill(0) as number[];
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex <= valueSeriesCount; seriesIndex++) {
|
||||
if (omit(seriesIndex)) {
|
||||
continue;
|
||||
}
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
rawValues.forEach((rawValue, pointIndex) => {
|
||||
totals[pointIndex] += rawValue == null ? 0 : Number(rawValue);
|
||||
});
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** A column whose participating series sum to 0 has no share to divide, so every slice is 0. */
|
||||
function toPercent(value: number, total: number): number {
|
||||
return total === 0 ? 0 : (value / total) * 100;
|
||||
}
|
||||
|
||||
/** What a raw value adds to the stack at a given point. */
|
||||
type Contribution = (value: number, pointIndex: number) => number;
|
||||
|
||||
function contributionForMode(params: BuildStackedSeriesParams): Contribution {
|
||||
if (params.mode !== StackMode.Percent) {
|
||||
return (value): number => value;
|
||||
}
|
||||
// Resolved up front: totals span series the accumulation below has not reached yet.
|
||||
const totals = columnTotals(params);
|
||||
return (value, pointIndex): number => toPercent(value, totals[pointIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,9 +90,17 @@ function buildStackedSeries({
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
mode,
|
||||
}: BuildStackedSeriesParams): (number | null)[][] {
|
||||
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
|
||||
const cumulativeSums = Array(pointCount).fill(0) as number[];
|
||||
const contributionOf = contributionForMode({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
mode,
|
||||
});
|
||||
|
||||
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
@@ -54,7 +110,10 @@ function buildStackedSeries({
|
||||
} else {
|
||||
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
|
||||
const numericValue = rawValue == null ? 0 : Number(rawValue);
|
||||
return (cumulativeSums[pointIndex] += numericValue);
|
||||
return (cumulativeSums[pointIndex] += contributionOf(
|
||||
numericValue,
|
||||
pointIndex,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -101,16 +160,3 @@ function findNextVisibleSeriesIndex(
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns band indices for initial stacked state (no series omitted).
|
||||
* Top-down: first series at top, band fills between consecutive series.
|
||||
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
|
||||
*/
|
||||
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
|
||||
const bands: uPlot.Band[] = [];
|
||||
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
|
||||
bands.push({ series: [seriesIndex, seriesIndex + 1] });
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import uPlot, { AlignedData } from 'uplot';
|
||||
|
||||
/**
|
||||
* Stack data cumulatively (top-down: first series = top, last = bottom).
|
||||
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
|
||||
*/
|
||||
export function stack(
|
||||
data: AlignedData,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): { data: AlignedData; bands: uPlot.Band[] } {
|
||||
const timeAxis = data[0];
|
||||
const pointCount = timeAxis.length;
|
||||
const valueSeriesCount = data.length - 1; // exclude time axis
|
||||
|
||||
const stackedSeries = buildStackedSeries({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
});
|
||||
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
|
||||
|
||||
return {
|
||||
data: [timeAxis, ...stackedSeries] as AlignedData,
|
||||
bands,
|
||||
};
|
||||
}
|
||||
|
||||
interface BuildStackedSeriesParams {
|
||||
data: AlignedData;
|
||||
valueSeriesCount: number;
|
||||
pointCount: number;
|
||||
omit: (seriesIndex: number) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate from last series upward: last series = raw values, first = total.
|
||||
* Omitted series are copied as-is (no accumulation).
|
||||
*/
|
||||
function buildStackedSeries({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
}: BuildStackedSeriesParams): (number | null)[][] {
|
||||
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
|
||||
const cumulativeSums = Array(pointCount).fill(0) as number[];
|
||||
|
||||
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
|
||||
if (omit(seriesIndex)) {
|
||||
stackedSeries[seriesIndex - 1] = rawValues;
|
||||
} else {
|
||||
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
|
||||
const numericValue = rawValue == null ? 0 : Number(rawValue);
|
||||
return (cumulativeSums[pointIndex] += numericValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return stackedSeries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bands define fill between consecutive visible series for stacked appearance.
|
||||
* uPlot format: [upperSeriesIdx, lowerSeriesIdx].
|
||||
*/
|
||||
function buildFillBands(
|
||||
seriesLength: number,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): uPlot.Band[] {
|
||||
const bands: uPlot.Band[] = [];
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex < seriesLength; seriesIndex++) {
|
||||
if (omit(seriesIndex)) {
|
||||
continue;
|
||||
}
|
||||
const nextVisibleSeriesIndex = findNextVisibleSeriesIndex(
|
||||
seriesLength,
|
||||
seriesIndex,
|
||||
omit,
|
||||
);
|
||||
if (nextVisibleSeriesIndex !== -1) {
|
||||
bands.push({ series: [seriesIndex, nextVisibleSeriesIndex] });
|
||||
}
|
||||
}
|
||||
|
||||
return bands;
|
||||
}
|
||||
|
||||
function findNextVisibleSeriesIndex(
|
||||
seriesLength: number,
|
||||
afterIndex: number,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): number {
|
||||
for (let i = afterIndex + 1; i < seriesLength; i++) {
|
||||
if (!omit(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns band indices for initial stacked state (no series omitted).
|
||||
* Top-down: first series at top, band fills between consecutive series.
|
||||
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
|
||||
*/
|
||||
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
|
||||
const bands: uPlot.Band[] = [];
|
||||
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
|
||||
bands.push({ series: [seriesIndex, seriesIndex + 1] });
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { UseBarChartStackingParams } from '../useBarChartStacking';
|
||||
import { useBarChartStacking } from '../useBarChartStacking';
|
||||
|
||||
type MockConfig = { addHook: jest.Mock };
|
||||
|
||||
function asConfig(c: MockConfig): UseBarChartStackingParams['config'] {
|
||||
return c as unknown as UseBarChartStackingParams['config'];
|
||||
}
|
||||
|
||||
function createMockConfig(): {
|
||||
config: MockConfig;
|
||||
invokeSetData: (plot: uPlot) => void;
|
||||
invokeSetSeries: (
|
||||
plot: uPlot,
|
||||
seriesIndex: number | null,
|
||||
opts: Partial<uPlot.Series> & { focus?: boolean },
|
||||
) => void;
|
||||
removeSetData: jest.Mock;
|
||||
removeSetSeries: jest.Mock;
|
||||
} {
|
||||
let setDataHandler: ((plot: uPlot) => void) | null = null;
|
||||
let setSeriesHandler:
|
||||
| ((plot: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void)
|
||||
| null = null;
|
||||
|
||||
const removeSetData = jest.fn();
|
||||
const removeSetSeries = jest.fn();
|
||||
|
||||
const addHook = jest.fn(
|
||||
(
|
||||
hookName: string,
|
||||
handler: (plot: uPlot, ...args: unknown[]) => void,
|
||||
): (() => void) => {
|
||||
if (hookName === 'setData') {
|
||||
setDataHandler = handler as (plot: uPlot) => void;
|
||||
return removeSetData;
|
||||
}
|
||||
if (hookName === 'setSeries') {
|
||||
setSeriesHandler = handler as (
|
||||
plot: uPlot,
|
||||
seriesIndex: number | null,
|
||||
opts: uPlot.Series,
|
||||
) => void;
|
||||
return removeSetSeries;
|
||||
}
|
||||
return jest.fn();
|
||||
},
|
||||
);
|
||||
|
||||
const config: MockConfig = { addHook };
|
||||
|
||||
const invokeSetData = (plot: uPlot): void => {
|
||||
setDataHandler?.(plot);
|
||||
};
|
||||
|
||||
const invokeSetSeries = (
|
||||
plot: uPlot,
|
||||
seriesIndex: number | null,
|
||||
opts: Partial<uPlot.Series> & { focus?: boolean },
|
||||
): void => {
|
||||
setSeriesHandler?.(plot, seriesIndex, opts as uPlot.Series);
|
||||
};
|
||||
|
||||
return {
|
||||
config,
|
||||
invokeSetData,
|
||||
invokeSetSeries,
|
||||
removeSetData,
|
||||
removeSetSeries,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockPlot(overrides: Partial<uPlot> = {}): uPlot {
|
||||
return {
|
||||
data: [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
],
|
||||
series: [{ show: true }, { show: true }, { show: true }],
|
||||
delBand: jest.fn(),
|
||||
addBand: jest.fn(),
|
||||
setData: jest.fn(),
|
||||
...overrides,
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
describe('useBarChartStacking', () => {
|
||||
it('returns data as-is when isStackedBarChart is false', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[100, 200],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: false,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current).toBe(data);
|
||||
});
|
||||
|
||||
it('returns data as-is when config is null and isStackedBarChart is true', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[4, 5],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
// Still returns stacked data (computed in useMemo); no hooks registered
|
||||
expect(result.current[0]).toStrictEqual([0, 1]);
|
||||
expect(result.current[1]).toStrictEqual([5, 7]); // stacked
|
||||
expect(result.current[2]).toStrictEqual([4, 5]);
|
||||
});
|
||||
|
||||
it('returns stacked data when isStackedBarChart is true and multiple value series', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
[7, 8, 9],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current[0]).toStrictEqual([0, 1, 2]);
|
||||
expect(result.current[1]).toStrictEqual([12, 15, 18]); // s1+s2+s3
|
||||
expect(result.current[2]).toStrictEqual([11, 13, 15]); // s2+s3
|
||||
expect(result.current[3]).toStrictEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
it('returns data as-is when only one value series (no stacking needed)', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current).toStrictEqual(data);
|
||||
});
|
||||
|
||||
it('registers setData and setSeries hooks when isStackedBarChart and config provided', () => {
|
||||
const { config } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.addHook).toHaveBeenCalledWith('setData', expect.any(Function));
|
||||
expect(config.addHook).toHaveBeenCalledWith(
|
||||
'setSeries',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not register hooks when isStackedBarChart is false', () => {
|
||||
const { config } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: false,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.addHook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls cleanup when unmounted', () => {
|
||||
const { config, removeSetData, removeSetSeries } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeSetData).toHaveBeenCalled();
|
||||
expect(removeSetSeries).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-stacks and updates plot when setData hook is invoked', () => {
|
||||
const { config, invokeSetData } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
];
|
||||
const plot = createMockPlot({
|
||||
data: [
|
||||
[0, 1, 2],
|
||||
[5, 7, 9],
|
||||
[4, 5, 6],
|
||||
],
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
invokeSetData(plot);
|
||||
|
||||
expect(plot.delBand).toHaveBeenCalledWith(null);
|
||||
expect(plot.addBand).toHaveBeenCalled();
|
||||
expect(plot.setData).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
[0, 1, 2],
|
||||
expect.any(Array), // stacked row 1
|
||||
expect.any(Array), // stacked row 2
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-stacks when setSeries hook is invoked (e.g. legend toggle)', () => {
|
||||
const { config, invokeSetSeries } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[10, 20],
|
||||
[5, 10],
|
||||
];
|
||||
// Plot data must match unstacked length so canApplyStacking passes
|
||||
const plot = createMockPlot({
|
||||
data: [
|
||||
[0, 1],
|
||||
[15, 30],
|
||||
[5, 10],
|
||||
],
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
invokeSetSeries(plot, 1, { show: false });
|
||||
|
||||
expect(plot.setData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-stack when setSeries is called with focus option', () => {
|
||||
const { config, invokeSetSeries } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
const plot = createMockPlot();
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
(plot.setData as jest.Mock).mockClear();
|
||||
invokeSetSeries(plot, 1, { focus: true } as uPlot.Series);
|
||||
|
||||
expect(plot.setData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { has } from 'lodash-es';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { stackSeries } from '../charts/utils/stackSeriesUtils';
|
||||
|
||||
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
|
||||
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
|
||||
return !plot.series[seriesIndex]?.show;
|
||||
}
|
||||
|
||||
function canApplyStacking(
|
||||
unstackedData: uPlot.AlignedData | null,
|
||||
plot: uPlot,
|
||||
isUpdating: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
!isUpdating &&
|
||||
!!unstackedData &&
|
||||
!!plot.data &&
|
||||
unstackedData[0]?.length === plot.data[0]?.length
|
||||
);
|
||||
}
|
||||
|
||||
function setupStackingHooks(
|
||||
config: UPlotConfigBuilder,
|
||||
applyStackingToChart: (plot: uPlot) => void,
|
||||
isUpdatingRef: MutableRefObject<boolean>,
|
||||
): () => void {
|
||||
const onDataChange = (plot: uPlot): void => {
|
||||
if (!isUpdatingRef.current) {
|
||||
applyStackingToChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const onSeriesVisibilityChange = (
|
||||
plot: uPlot,
|
||||
_seriesIdx: number | null,
|
||||
opts: uPlot.Series,
|
||||
): void => {
|
||||
if (!has(opts, 'focus')) {
|
||||
applyStackingToChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSetDataHook = config.addHook('setData', onDataChange);
|
||||
const removeSetSeriesHook = config.addHook(
|
||||
'setSeries',
|
||||
onSeriesVisibilityChange,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
removeSetDataHook?.();
|
||||
removeSetSeriesHook?.();
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseBarChartStackingParams {
|
||||
data: uPlot.AlignedData;
|
||||
isStackedBarChart?: boolean;
|
||||
config: UPlotConfigBuilder | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles stacking for bar charts: computes initial stacked data and re-stacks
|
||||
* when data or series visibility changes (e.g. legend toggles).
|
||||
*/
|
||||
export function useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart = false,
|
||||
config,
|
||||
}: UseBarChartStackingParams): uPlot.AlignedData {
|
||||
// Store unstacked source data so uPlot hooks can access it (hooks run outside React's render cycle)
|
||||
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
|
||||
unstackedDataRef.current = isStackedBarChart ? data : null;
|
||||
|
||||
// Prevents re-entrant calls when we update chart data (avoids infinite loop in setData hook)
|
||||
const isUpdatingChartRef = useRef(false);
|
||||
|
||||
const chartData = useMemo((): uPlot.AlignedData => {
|
||||
if (!isStackedBarChart || !data || data.length < 2) {
|
||||
return data;
|
||||
}
|
||||
const noSeriesHidden = (): boolean => false; // include all series in initial stack
|
||||
const { data: stacked } = stackSeries(data, noSeriesHidden);
|
||||
return stacked;
|
||||
}, [data, isStackedBarChart]);
|
||||
|
||||
const applyStackingToChart = useCallback((plot: uPlot): void => {
|
||||
const unstacked = unstackedDataRef.current;
|
||||
if (
|
||||
!unstacked ||
|
||||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldExcludeSeries = (idx: number): boolean =>
|
||||
isSeriesHidden(plot, idx);
|
||||
const { data: stacked, bands } = stackSeries(unstacked, shouldExcludeSeries);
|
||||
|
||||
plot.delBand(null);
|
||||
bands.forEach((band: uPlot.Band) => plot.addBand(band));
|
||||
|
||||
isUpdatingChartRef.current = true;
|
||||
plot.setData(stacked);
|
||||
isUpdatingChartRef.current = false;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isStackedBarChart || !config) {
|
||||
return undefined;
|
||||
}
|
||||
return setupStackingHooks(config, applyStackingToChart, isUpdatingChartRef);
|
||||
}, [isStackedBarChart, config, applyStackingToChart]);
|
||||
|
||||
return chartData;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { prepareBarPanelConfig } from './utils';
|
||||
import '../Panel.styles.scss';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -147,6 +148,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
key={`${syncMode}-${syncFilterMode}`}
|
||||
stack={widget.stackedBarChart ? StackMode.Normal : StackMode.None}
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
@@ -159,7 +161,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
isStackedBarChart={widget.stackedBarChart ?? false}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone}
|
||||
|
||||
@@ -35,20 +35,10 @@ jest.mock('lib/getLabelName', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
|
||||
() => ({
|
||||
getInitialStackedBands: jest.fn().mockReturnValue([]),
|
||||
}),
|
||||
);
|
||||
|
||||
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
|
||||
.getLegend as jest.Mock;
|
||||
const getLabelNameMock = jest.requireMock('lib/getLabelName')
|
||||
.default as jest.Mock;
|
||||
const getInitialStackedBandsMock = jest.requireMock(
|
||||
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
|
||||
).getInitialStackedBands as jest.Mock;
|
||||
|
||||
const createApiResponse = (
|
||||
result: MetricRangePayloadProps['data']['result'] = [],
|
||||
@@ -247,36 +237,5 @@ describe('BarPanel utils', () => {
|
||||
}).getConfig();
|
||||
expect(config.series?.[1]).toMatchObject({ stroke: '#ff0000' });
|
||||
});
|
||||
|
||||
it('calls getInitialStackedBands when widget is stackedBarChart', () => {
|
||||
const widget = createWidget({ stackedBarChart: true });
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q2',
|
||||
values: [[1000, '2']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
prepareBarPanelConfig({ ...baseParams, widget, apiResponse });
|
||||
// seriesCount = result.length + 1 = 3
|
||||
expect(getInitialStackedBandsMock).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('does not call getInitialStackedBands for non-stacked chart', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
prepareBarPanelConfig({ ...baseParams, apiResponse });
|
||||
expect(getInitialStackedBandsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ExecStats } from 'api/v5/v5';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
@@ -69,11 +68,6 @@ export function prepareBarPanelConfig({
|
||||
return builder;
|
||||
}
|
||||
|
||||
if (widget.stackedBarChart) {
|
||||
const seriesCount = (apiResponse.data.result.length ?? 0) + 1; // +1 for 1-based uPlot series indices
|
||||
builder.setBands(getInitialStackedBands(seriesCount));
|
||||
}
|
||||
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-2);
|
||||
|
||||
--tab-content-padding: 0;
|
||||
--tab-text-color: var(--l1-foreground);
|
||||
--tab-active-text-color: var(--l1-foreground);
|
||||
--tabs-content-padding: 0;
|
||||
--tabs-text-color: var(--l1-foreground);
|
||||
--tabs-active-text-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.pageError {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
height: 100%;
|
||||
margin-top: var(--spacing-2);
|
||||
margin-left: var(--spacing-2);
|
||||
--tab-text-color: var(--l1-foreground);
|
||||
--tab-active-text-color: var(--l1-foreground);
|
||||
--tabs-text-color: var(--l1-foreground);
|
||||
--tabs-active-text-color: var(--l1-foreground);
|
||||
[role='tabpanel'] {
|
||||
margin: 0;
|
||||
padding: var(--spacing-0) var(--spacing-4);
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
--tab-content-padding: 0;
|
||||
--tabs-content-padding: 0;
|
||||
margin-top: var(--spacing-3);
|
||||
--tab-text-color: var(--l1-foreground);
|
||||
--tab-active-text-color: var(--l1-foreground);
|
||||
--tabs-text-color: var(--l1-foreground);
|
||||
--tabs-active-text-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.tabLabel {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
}
|
||||
|
||||
// Remove default tab content padding/margin — the card provides spacing.
|
||||
--tab-content-padding: 0;
|
||||
--tab-content-margin: var(--spacing-4) 0 0;
|
||||
--tabs-content-padding: 0;
|
||||
--tabs-content-margin: var(--spacing-4) 0 0;
|
||||
}
|
||||
|
||||
.mcp-client-tabs {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -137,6 +138,7 @@ function TimeSeries({
|
||||
key={`${WIDGET_ID}-${index}`}
|
||||
>
|
||||
<BarChart
|
||||
stack={StackMode.Normal}
|
||||
config={chart.config}
|
||||
legendConfig={{
|
||||
position: LegendPosition.BOTTOM,
|
||||
@@ -144,7 +146,6 @@ function TimeSeries({
|
||||
data={chart.chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
isStackedBarChart
|
||||
yAxisUnit={yAxisUnit || 'short'}
|
||||
timezone={timezone}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import {
|
||||
@@ -89,9 +88,6 @@ export function buildMeterChartConfig({
|
||||
return builder;
|
||||
}
|
||||
|
||||
const seriesCount = (apiResponse.data.result.length ?? 0) + 1;
|
||||
builder.setBands(getInitialStackedBands(seriesCount));
|
||||
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
|
||||
@@ -35,7 +35,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
|
||||
class="c0"
|
||||
>
|
||||
<p
|
||||
class="_typography_ulrzs_1"
|
||||
class="_typography_j4pmm_1"
|
||||
data-slot="typography"
|
||||
data-variant="text"
|
||||
/>
|
||||
@@ -50,7 +50,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
|
||||
class="value-text-container"
|
||||
>
|
||||
<p
|
||||
class="_typography_ulrzs_1 value-graph-text"
|
||||
class="_typography_j4pmm_1 value-graph-text"
|
||||
data-slot="typography"
|
||||
data-testid="value-graph-text"
|
||||
data-variant="text"
|
||||
@@ -59,7 +59,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
|
||||
295.43
|
||||
</p>
|
||||
<p
|
||||
class="_typography_ulrzs_1 value-graph-unit"
|
||||
class="_typography_j4pmm_1 value-graph-unit"
|
||||
data-slot="typography"
|
||||
data-testid="value-graph-suffix-unit"
|
||||
data-variant="text"
|
||||
|
||||
@@ -22,11 +22,11 @@ exports[`PipelinePage container test should render DragAction section 1`] = `
|
||||
class="c0"
|
||||
>
|
||||
<div
|
||||
class="_switch-wrapper_jbsv7_1"
|
||||
class="_switch-wrapper_1a8sn_6"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
class="_switch_jbsv7_1"
|
||||
class="_switch_1a8sn_6"
|
||||
data-color="robin"
|
||||
data-state="checked"
|
||||
id=":r0:"
|
||||
@@ -35,7 +35,7 @@ exports[`PipelinePage container test should render DragAction section 1`] = `
|
||||
value="on"
|
||||
>
|
||||
<span
|
||||
class="_switch__thumb_jbsv7_59"
|
||||
class="_switch__thumb_1a8sn_71"
|
||||
data-state="checked"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -74,7 +74,7 @@ exports[`PipelinePage container test should render PipelinePageLayout section 1`
|
||||
/>
|
||||
<div>
|
||||
<p
|
||||
class="_typography_ulrzs_1"
|
||||
class="_typography_j4pmm_1"
|
||||
data-slot="typography"
|
||||
data-variant="text"
|
||||
>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
--tab-content-padding: 0px;
|
||||
--tabs-content-padding: 0px;
|
||||
|
||||
[role='tabpanel'] {
|
||||
display: flex;
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 300px;
|
||||
min-width: 400px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -57,8 +57,6 @@
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-left-override: 5px;
|
||||
--tanstack-cell-padding-right-override: 5px;
|
||||
|
||||
--tanstack-cell-padding-left-override: 16px;
|
||||
--tanstack-cell-padding-right-override: 16px;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { useThemeConfig } from 'hooks/useDarkMode';
|
||||
|
||||
/**
|
||||
* `useThemeConfig` has to run under `ThemeProvider`, so the antd
|
||||
* `ConfigProvider` lives in its own component the way `AppRoutes` does it.
|
||||
*/
|
||||
function AntdThemeBridge({ children }: { children: ReactNode }): JSX.Element {
|
||||
const themeConfig = useThemeConfig();
|
||||
|
||||
return <ConfigProvider theme={themeConfig}>{children}</ConfigProvider>;
|
||||
}
|
||||
|
||||
export default AntdThemeBridge;
|
||||
@@ -1,123 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Store } from 'redux';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
|
||||
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { ThemeProvider } from 'hooks/useDarkMode';
|
||||
import { NotificationProvider } from 'hooks/useNotifications';
|
||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { CmdKProvider } from 'providers/cmdKProvider';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import {
|
||||
QueryBuilderContext,
|
||||
QueryBuilderProvider,
|
||||
} from 'providers/QueryBuilder';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
import AntdThemeBridge from './AntdThemeBridge';
|
||||
|
||||
/**
|
||||
* A layer the runner supplies. A render function rather than a component, so an
|
||||
* inline one does not change identity between renders and remount the tree.
|
||||
*/
|
||||
export type HarnessWrapper = (children: ReactNode) => ReactNode;
|
||||
|
||||
export interface AppHarnessProps {
|
||||
children: ReactNode;
|
||||
/** Stands in for `AppProvider`, whose fetches no harness can make. */
|
||||
appContext: IAppContext;
|
||||
store: Store;
|
||||
queryClient: QueryClient;
|
||||
/** When set, replaces `QueryBuilderProvider` with a fixed context value. */
|
||||
queryBuilder?: Partial<QueryBuilderContextType>;
|
||||
/**
|
||||
* The router the runner drives: jest a `MemoryRouter`, Storybook a `Router` on
|
||||
* the contained history. Everything above it in the tree is router-free, so
|
||||
* the choice stays here.
|
||||
*/
|
||||
router: HarnessWrapper;
|
||||
/** The nuqs adapter: the react one under jest, the testing one in Storybook. */
|
||||
searchParams: HarnessWrapper;
|
||||
/** Rendered beside the subject: Storybook's palette, overlay and probe. */
|
||||
overlays?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's provider tree, as `src/index.tsx` and `src/AppRoutes/index.tsx`
|
||||
* mount it, minus Sentry, posthog and `AppProvider`, with the pieces a test
|
||||
* runner has to choose left as props. Both harnesses that render pages outside
|
||||
* the app (jest's `test-utils`, Storybook's `StorybookProviders`) go through
|
||||
* here, so a provider added to the app is added once and both see it.
|
||||
*/
|
||||
function AppHarness({
|
||||
children,
|
||||
appContext,
|
||||
store,
|
||||
queryClient,
|
||||
queryBuilder,
|
||||
router,
|
||||
searchParams,
|
||||
overlays,
|
||||
}: AppHarnessProps): JSX.Element {
|
||||
const subject = queryBuilder ? (
|
||||
<QueryBuilderContext.Provider value={queryBuilder as QueryBuilderContextType}>
|
||||
{children}
|
||||
</QueryBuilderContext.Provider>
|
||||
) : (
|
||||
<QueryBuilderProvider>{children}</QueryBuilderProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<HelmetProvider>
|
||||
{searchParams(
|
||||
<ThemeProvider>
|
||||
<TimezoneProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
<AppContext.Provider value={appContext}>
|
||||
<AntdThemeBridge>
|
||||
{router(
|
||||
<TooltipProvider>
|
||||
<CmdKProvider>
|
||||
<NotificationProvider>
|
||||
<ErrorModalProvider>
|
||||
<ResourceProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<PreferenceContextProvider>
|
||||
{subject}
|
||||
{overlays}
|
||||
</PreferenceContextProvider>
|
||||
</KeyboardHotkeysProvider>
|
||||
</ResourceProvider>
|
||||
</ErrorModalProvider>
|
||||
</NotificationProvider>
|
||||
</CmdKProvider>
|
||||
</TooltipProvider>,
|
||||
)}
|
||||
</AntdThemeBridge>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</TimezoneProvider>
|
||||
</ThemeProvider>,
|
||||
)}
|
||||
</HelmetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
AppHarness.defaultProps = {
|
||||
queryBuilder: undefined,
|
||||
overlays: undefined,
|
||||
};
|
||||
|
||||
export default AppHarness;
|
||||
@@ -135,5 +135,3 @@ export const closeAuthZDevModal = (): void =>
|
||||
useAuthZDevStore.getState().closeModal();
|
||||
export const toggleAuthZDevModal = (): void =>
|
||||
useAuthZDevStore.getState().toggleModal();
|
||||
export const clearAllAuthZDevOverrides = (): void =>
|
||||
useAuthZDevStore.getState().clearAllOverrides();
|
||||
|
||||
@@ -9,6 +9,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
(): TooltipContentItem[] =>
|
||||
buildTooltipContent({
|
||||
data: props.uPlotInstance.data,
|
||||
unstackedData: props.unstackedData,
|
||||
series: props.uPlotInstance.series,
|
||||
dataIndexes: props.dataIndexes,
|
||||
activeSeriesIndex: props.seriesIndex,
|
||||
@@ -21,6 +22,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
props.unstackedData,
|
||||
props.seriesIndex,
|
||||
props.dataIndexes,
|
||||
props.yAxisUnit,
|
||||
|
||||
@@ -11,6 +11,7 @@ export default function TimeSeriesTooltip(
|
||||
(): TooltipContentItem[] =>
|
||||
buildTooltipContent({
|
||||
data: props.uPlotInstance.data,
|
||||
unstackedData: props.unstackedData,
|
||||
series: props.uPlotInstance.series,
|
||||
dataIndexes: props.dataIndexes,
|
||||
activeSeriesIndex: props.seriesIndex,
|
||||
@@ -22,6 +23,7 @@ export default function TimeSeriesTooltip(
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
props.unstackedData,
|
||||
props.seriesIndex,
|
||||
props.dataIndexes,
|
||||
props.yAxisUnit,
|
||||
|
||||
@@ -72,6 +72,35 @@ describe('Tooltip utils', () => {
|
||||
expect(result).toBe(20);
|
||||
});
|
||||
|
||||
it('reports the pre-stack value, identically for normal and percent', () => {
|
||||
const unstackedData: AlignedData = [[0], [30], [10]];
|
||||
const series = [{}, { show: true }, { show: true }] as Series[];
|
||||
const read = (data: AlignedData): number | null =>
|
||||
getTooltipBaseValue({
|
||||
data,
|
||||
unstackedData,
|
||||
index: 1,
|
||||
dataIndex: 0,
|
||||
isStackedBarChart: true,
|
||||
series,
|
||||
});
|
||||
|
||||
expect(read([[0], [40], [10]])).toBe(30);
|
||||
expect(read([[0], [100], [25]])).toBe(30);
|
||||
});
|
||||
|
||||
it('falls back to subtraction when no pre-stack data is given', () => {
|
||||
const result = getTooltipBaseValue({
|
||||
data: [[0], [40], [10]],
|
||||
index: 1,
|
||||
dataIndex: 0,
|
||||
isStackedBarChart: true,
|
||||
series: [{}, { show: true }, { show: true }] as Series[],
|
||||
});
|
||||
|
||||
expect(result).toBe(30);
|
||||
});
|
||||
|
||||
it('returns null when value is missing', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
|
||||
@@ -23,17 +23,25 @@ export function resolveSeriesColor(
|
||||
|
||||
export function getTooltipBaseValue({
|
||||
data,
|
||||
unstackedData,
|
||||
index,
|
||||
dataIndex,
|
||||
isStackedBarChart,
|
||||
series,
|
||||
}: {
|
||||
data: AlignedData;
|
||||
unstackedData?: AlignedData;
|
||||
index: number;
|
||||
dataIndex: number;
|
||||
isStackedBarChart?: boolean;
|
||||
series?: Series[];
|
||||
}): number | null {
|
||||
// The subtraction below only recovers the raw value under `normal` stacking.
|
||||
const unstackedSeries = unstackedData?.[index];
|
||||
if (unstackedSeries) {
|
||||
return unstackedSeries[dataIndex] ?? null;
|
||||
}
|
||||
|
||||
let baseValue = data[index][dataIndex] ?? null;
|
||||
// Top-down stacking (first series at top): raw = stacked[i] - stacked[nextVisible].
|
||||
// When series are hidden, we must use the next *visible* series, not index+1,
|
||||
@@ -56,6 +64,7 @@ export function getTooltipBaseValue({
|
||||
|
||||
export function buildTooltipContent({
|
||||
data,
|
||||
unstackedData,
|
||||
series,
|
||||
dataIndexes,
|
||||
activeSeriesIndex,
|
||||
@@ -67,6 +76,7 @@ export function buildTooltipContent({
|
||||
syncFilterMode,
|
||||
}: {
|
||||
data: AlignedData;
|
||||
unstackedData?: AlignedData;
|
||||
series: Series[];
|
||||
dataIndexes: Array<number | null>;
|
||||
activeSeriesIndex: number | null;
|
||||
@@ -115,6 +125,7 @@ export function buildTooltipContent({
|
||||
|
||||
const baseValue = getTooltipBaseValue({
|
||||
data,
|
||||
unstackedData,
|
||||
index: seriesIndex,
|
||||
dataIndex,
|
||||
isStackedBarChart,
|
||||
|
||||
@@ -69,6 +69,11 @@ export interface TooltipRenderArgs {
|
||||
syncedSeriesIndexes?: number[] | null;
|
||||
/** Receiver-side filter mode for the synced tooltip. Defaults to Filtered. */
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
/**
|
||||
* Pre-stack values, injected by `ChartWrapper`. `Percent` discards the column total,
|
||||
* so the raw value cannot be recovered from the plot's own cumulative data.
|
||||
*/
|
||||
unstackedData?: uPlot.AlignedData;
|
||||
}
|
||||
|
||||
export interface IRenderTooltipFooterArgs {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ConfigBuilderProps,
|
||||
LegendItem,
|
||||
SelectionPreferencesSource,
|
||||
StackMode,
|
||||
} from './types';
|
||||
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
|
||||
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
|
||||
@@ -28,6 +29,11 @@ import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
|
||||
/**
|
||||
* Type definitions for uPlot option objects
|
||||
*/
|
||||
/** Renders a 0–100 number as `50%`, unlike the 0–1 `percentunit`. */
|
||||
const PERCENT_AXIS_UNIT = 'percent';
|
||||
|
||||
const PERCENT_AXIS_MAX = 100;
|
||||
|
||||
type LegendConfig = {
|
||||
show?: boolean;
|
||||
live?: boolean;
|
||||
@@ -57,6 +63,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private bands: uPlot.Band[] = [];
|
||||
|
||||
private stackMode: StackMode = StackMode.None;
|
||||
|
||||
private cursor: Cursor | undefined;
|
||||
|
||||
private hooks: Hooks.Arrays = {};
|
||||
@@ -143,6 +151,15 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
this.axes[scaleKey] = new UPlotAxisBuilder(props);
|
||||
}
|
||||
|
||||
/** Drives the fill bands, the percent axis unit and the percent range below. */
|
||||
setStackMode(stackMode: StackMode): void {
|
||||
this.stackMode = stackMode;
|
||||
}
|
||||
|
||||
getStackMode(): StackMode {
|
||||
return this.stackMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or merge a scale configuration
|
||||
*/
|
||||
@@ -211,6 +228,41 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
this.bands = bands;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's own limits are in the source unit, which means nothing once values are
|
||||
* normalised. Soft rather than hard, so mixed-sign shares outside 0–100 stay visible.
|
||||
*/
|
||||
private resolveScale(scale: UPlotScaleBuilder): UPlotScaleBuilder {
|
||||
if (this.stackMode !== StackMode.Percent || scale.props.scaleKey !== 'y') {
|
||||
return scale;
|
||||
}
|
||||
return new UPlotScaleBuilder({
|
||||
...scale.props,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
softMin: 0,
|
||||
softMax: PERCENT_AXIS_MAX,
|
||||
// Thresholds still draw, but a 500ms one must not stretch the axis to 0–500.
|
||||
thresholds: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Explicit bands win; otherwise a stack fills between consecutive series. */
|
||||
private resolveBands(): uPlot.Band[] | undefined {
|
||||
if (this.bands.length > 0) {
|
||||
return this.bands;
|
||||
}
|
||||
if (this.stackMode === StackMode.None || this.series.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
this.series
|
||||
.slice(0, -1)
|
||||
// uPlot series are 1-based (index 0 is the timestamp axis).
|
||||
.map((_, index) => ({ series: [index + 1, index + 2] as [number, number] }))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cursor configuration
|
||||
*/
|
||||
@@ -444,9 +496,19 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
};
|
||||
}),
|
||||
];
|
||||
config.axes = Object.values(this.axes).map((a) => a.getConfig());
|
||||
config.axes = Object.entries(this.axes).map(([scaleKey, axis]) => {
|
||||
if (scaleKey !== 'y' || this.stackMode !== StackMode.Percent) {
|
||||
return axis.getConfig();
|
||||
}
|
||||
// Ticks read as percentages; the panel unit still applies to tooltips and
|
||||
// thresholds, so build from a copy rather than touching the axis props.
|
||||
return new UPlotAxisBuilder({
|
||||
...axis.props,
|
||||
yAxisUnit: PERCENT_AXIS_UNIT,
|
||||
}).getConfig();
|
||||
});
|
||||
config.scales = this.scales.reduce(
|
||||
(acc, s) => ({ ...acc, ...s.getConfig() }),
|
||||
(acc, s) => ({ ...acc, ...this.resolveScale(s).getConfig() }),
|
||||
{} as Record<string, uPlot.Scale>,
|
||||
);
|
||||
|
||||
@@ -456,7 +518,7 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
config.cursor = this.getCursorConfig();
|
||||
config.tzDate = this.tzDate;
|
||||
config.plugins = this.plugins.length > 0 ? this.plugins : undefined;
|
||||
config.bands = this.bands.length > 0 ? this.bands : undefined;
|
||||
config.bands = this.resolveBands();
|
||||
|
||||
if (Array.isArray(this.padding)) {
|
||||
config.padding = this.padding;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
STEP_INTERVAL_MULTIPLIER,
|
||||
} from '../../constants';
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
|
||||
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
|
||||
|
||||
// Mock only the real boundary that hits localStorage
|
||||
@@ -496,3 +496,161 @@ describe('UPlotConfigBuilder', () => {
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotConfigBuilder stacking', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Soft limits end up captured in the scale's range closure, so the only way to read
|
||||
* them back is to run it and inspect the range config it hands uPlot.
|
||||
*/
|
||||
function scaleSoftLimits(
|
||||
builder: UPlotConfigBuilder,
|
||||
scaleKey: string,
|
||||
): { min: number; max: number } {
|
||||
const rangeNum = jest.fn().mockReturnValue([0, 0]);
|
||||
(uPlot as unknown as { rangeNum: unknown }).rangeNum = rangeNum;
|
||||
|
||||
const range = builder.getConfig().scales?.[scaleKey]?.range as (
|
||||
u: unknown,
|
||||
min: number,
|
||||
max: number,
|
||||
key: string,
|
||||
) => void;
|
||||
range({ scales: { [scaleKey]: { distr: 1 } } }, 40, 60, scaleKey);
|
||||
|
||||
const [, , rangeConfig] = rangeNum.mock.calls[0] as [
|
||||
number,
|
||||
number,
|
||||
{ min: { soft: number }; max: { soft: number } },
|
||||
];
|
||||
return { min: rangeConfig.min.soft, max: rangeConfig.max.soft };
|
||||
}
|
||||
|
||||
/** Renders y-axis ticks the way uPlot would, so unit formatting is observable. */
|
||||
function yAxisTicks(builder: UPlotConfigBuilder, ticks: number[]): string[] {
|
||||
const yAxis = builder.getConfig().axes?.find((a) => a.scale === 'y');
|
||||
const values = yAxis?.values as (
|
||||
u: unknown,
|
||||
splits: number[],
|
||||
) => (string | null)[];
|
||||
return values(null, ticks).map((v) => String(v));
|
||||
}
|
||||
|
||||
function builderFor(stack?: StackMode, seriesCount = 3): UPlotConfigBuilder {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-test' });
|
||||
if (stack) {
|
||||
builder.setStackMode(stack);
|
||||
}
|
||||
builder.addAxis({ scaleKey: 'y', show: true, side: 3, yAxisUnit: 'ms' });
|
||||
for (let i = 0; i < seriesCount; i++) {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label: `S${i}`,
|
||||
drawStyle: DrawStyle.Bar,
|
||||
colorMapping: {},
|
||||
isDarkMode: false,
|
||||
} as SeriesProps);
|
||||
}
|
||||
return builder;
|
||||
}
|
||||
|
||||
it('defaults to no stacking, so no bands and the panel unit on the axis', () => {
|
||||
const builder = builderFor();
|
||||
|
||||
expect(builder.getStackMode()).toBe('none');
|
||||
expect(builder.getConfig().bands).toBeUndefined();
|
||||
expect(yAxisTicks(builder, [1000])).toStrictEqual(['1 s']);
|
||||
});
|
||||
|
||||
it('derives one band per adjacent series pair once a stack is declared', () => {
|
||||
expect(builderFor(StackMode.Normal).getConfig().bands).toStrictEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits no bands for a single series', () => {
|
||||
expect(builderFor(StackMode.Normal, 1).getConfig().bands).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the panel unit on the axis for a normal stack', () => {
|
||||
expect(yAxisTicks(builderFor(StackMode.Normal), [1000])).toStrictEqual([
|
||||
'1 s',
|
||||
]);
|
||||
});
|
||||
|
||||
it('formats the axis as percentages for a percent stack', () => {
|
||||
expect(yAxisTicks(builderFor(StackMode.Percent), [0, 50, 100])).toStrictEqual(
|
||||
['0%', '50%', '100%'],
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves other axes on their own unit under a percent stack', () => {
|
||||
const builder = builderFor(StackMode.Percent);
|
||||
builder.addAxis({ scaleKey: 'x', show: true, side: 2 });
|
||||
|
||||
expect(builder.getConfig().axes?.map((a) => a.scale)).toStrictEqual([
|
||||
'y',
|
||||
'x',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins the y scale to the 0–100 band under a percent stack, dropping panel limits', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-scale' });
|
||||
builder.setStackMode(StackMode.Percent);
|
||||
builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 });
|
||||
|
||||
// Soft, not hard: mixed-sign shares fall outside 0–100 and must stay visible.
|
||||
expect(builder.getConfig().scales?.y).toMatchObject({ auto: true });
|
||||
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 });
|
||||
});
|
||||
|
||||
it('leaves the panel limits alone when the stack is not percent', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-scale' });
|
||||
builder.setStackMode(StackMode.Normal);
|
||||
builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 });
|
||||
|
||||
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 5, max: 500 });
|
||||
});
|
||||
|
||||
it.each([StackMode.Normal, StackMode.Percent])(
|
||||
'draws thresholds under a %s stack',
|
||||
(stack) => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-thr' });
|
||||
builder.setStackMode(stack);
|
||||
builder.addThresholds({
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }],
|
||||
yAxisUnit: 'ms',
|
||||
});
|
||||
|
||||
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a source-unit threshold from stretching the percent band', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-thr' });
|
||||
builder.setStackMode(StackMode.Percent);
|
||||
const thresholds = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }],
|
||||
yAxisUnit: 'ms',
|
||||
};
|
||||
builder.addThresholds(thresholds);
|
||||
builder.addScale({ scaleKey: 'y', thresholds });
|
||||
|
||||
// Without this the 500ms threshold would widen a percentage axis to 0–500.
|
||||
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 });
|
||||
});
|
||||
|
||||
it('lets explicit bands win over the derived ones', () => {
|
||||
const builder = builderFor(StackMode.Normal);
|
||||
builder.setBands([{ series: [1, 3] }]);
|
||||
|
||||
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,13 @@ export enum SelectionPreferencesSource {
|
||||
/**
|
||||
* Props for configuring the uPlot config builder
|
||||
*/
|
||||
/** `Percent` rescales each x-slice to its column total, so every column fills to 100. */
|
||||
export enum StackMode {
|
||||
None = 'none',
|
||||
Normal = 'normal',
|
||||
Percent = 'percent',
|
||||
}
|
||||
|
||||
export interface ConfigBuilderProps {
|
||||
id: string;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
|
||||
@@ -281,3 +281,20 @@ describe('dataUtils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertLargeGapNullsIntoAlignedData index alignment', () => {
|
||||
// ChartWrapper gap-processes the pre-stack series to keep tooltip indices aligned;
|
||||
// that only holds because insertions are decided from the x axis, never from y.
|
||||
it('inserts at the same positions regardless of the y values', () => {
|
||||
const x = [0, 100, 200];
|
||||
const options = [{ spanGaps: 50 }];
|
||||
const raw = [x, [1, 2, 3]] as uPlot.AlignedData;
|
||||
const stacked = [x, [10, 20, 30]] as uPlot.AlignedData;
|
||||
|
||||
const fromRaw = insertLargeGapNullsIntoAlignedData(raw, options);
|
||||
const fromStacked = insertLargeGapNullsIntoAlignedData(stacked, options);
|
||||
|
||||
expect(fromRaw[0]).toStrictEqual(fromStacked[0]);
|
||||
expect(fromRaw[1]).toHaveLength((fromStacked[1] as unknown[]).length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
is hidden — the row stays a single crisp line and scrolls only when narrow. */
|
||||
.typeTabsScroll {
|
||||
justify-self: flex-end;
|
||||
--tab-list-wrapper-secondary-padding-left: 0;
|
||||
--tabs-list-wrapper-secondary-padding-left: 0;
|
||||
}
|
||||
|
||||
/* Connected segmented control, mirroring Overview's SegmentedControl: no outer
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getExecStats,
|
||||
@@ -219,7 +220,9 @@ function BarPanelRenderer({
|
||||
height={containerDimensions.height}
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
|
||||
stack={
|
||||
spec.visualization?.stackedBarChart ? StackMode.Normal : StackMode.None
|
||||
}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
@@ -101,12 +100,6 @@ function addSeries({
|
||||
}: AddSeriesArgs): void {
|
||||
const colorMapping = spec.legend?.customColors ?? {};
|
||||
|
||||
if (spec.visualization?.stackedBarChart) {
|
||||
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
|
||||
// `+1` keeps the band targets aligned with the series we're about to add.
|
||||
builder.setBands(getInitialStackedBands(series.length + 1));
|
||||
}
|
||||
|
||||
series.forEach((s) => {
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
import {
|
||||
countControl,
|
||||
choiceControl,
|
||||
multiChoiceControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
buildAlertRules,
|
||||
buildServices,
|
||||
homeFeatureFlags,
|
||||
homeUserPreferences,
|
||||
HOME_CHECKLIST_STEPS,
|
||||
isSavedViewSignal,
|
||||
metricsOnboardingResponse,
|
||||
queryRangeV5ScalarResponse,
|
||||
recentDashboardsResponse,
|
||||
SAVED_VIEW_SIGNALS,
|
||||
type SavedViewSignal,
|
||||
savedViewsResponse,
|
||||
SERVICES_SOURCES,
|
||||
type ServicesSource,
|
||||
spanMetricsResponse,
|
||||
topLevelOperationsResponse,
|
||||
} from './__mockdata__/home';
|
||||
|
||||
const SIGNALS = 'Home · signals';
|
||||
const ONBOARDING = 'Home · onboarding';
|
||||
const LISTS = 'Home · lists';
|
||||
|
||||
const INGESTED_COUNT = 4213;
|
||||
|
||||
/** Home caps every list at five rows, so the control has to go past that. */
|
||||
const LIST_MAX = 8;
|
||||
|
||||
const CHECKLIST_VISIBILITY = ['visible', 'dismissed'] as const;
|
||||
|
||||
type ChecklistVisibility = (typeof CHECKLIST_VISIBILITY)[number];
|
||||
|
||||
interface QueryRangeV5Body {
|
||||
compositeQuery?: { queries?: { spec?: { signal?: string } }[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Home detects logs and traces with one `query_range` call each, told apart by
|
||||
* the signal on the query spec.
|
||||
*/
|
||||
const signalOf = (body: QueryRangeV5Body): string | undefined =>
|
||||
body.compositeQuery?.queries?.[0]?.spec?.signal;
|
||||
|
||||
export const homeMocks = defineStoryMocks({
|
||||
controls: {
|
||||
logsIngestion: toggleControl('Logs ingestion', {
|
||||
group: SIGNALS,
|
||||
value: true,
|
||||
}),
|
||||
tracesIngestion: toggleControl('Traces ingestion', {
|
||||
group: SIGNALS,
|
||||
value: true,
|
||||
}),
|
||||
metricsIngestion: toggleControl('Metrics ingestion', {
|
||||
group: SIGNALS,
|
||||
value: true,
|
||||
}),
|
||||
welcomeChecklist: choiceControl<ChecklistVisibility>('Welcome checklist', {
|
||||
group: ONBOARDING,
|
||||
description:
|
||||
'Dismissing it moves the checklist behind the header button, as "I\'ll do this later" does.',
|
||||
options: CHECKLIST_VISIBILITY,
|
||||
value: 'visible',
|
||||
}),
|
||||
skippedSteps: multiChoiceControl('Skipped steps', {
|
||||
group: ONBOARDING,
|
||||
description: 'Steps the user chose to skip. Completion follows the data.',
|
||||
options: HOME_CHECKLIST_STEPS,
|
||||
value: [],
|
||||
}),
|
||||
alertRules: countControl('Alert rules', {
|
||||
group: LISTS,
|
||||
value: 5,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
dashboards: countControl('Recent dashboards', {
|
||||
group: LISTS,
|
||||
value: 5,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
savedViews: countControl('Saved views per signal', {
|
||||
group: LISTS,
|
||||
value: 5,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
savedViewSignals: multiChoiceControl<SavedViewSignal>('Signals with views', {
|
||||
group: LISTS,
|
||||
description:
|
||||
'Explorer tabs that have views; the rest fall back to their empty state.',
|
||||
options: SAVED_VIEW_SIGNALS,
|
||||
value: SAVED_VIEW_SIGNALS,
|
||||
}),
|
||||
services: countControl('Services', {
|
||||
group: LISTS,
|
||||
value: 6,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
servicesSource: choiceControl<ServicesSource>('Services source', {
|
||||
group: LISTS,
|
||||
description:
|
||||
'`span-metrics` turns on the feature flag that swaps the services card for the span-metrics one.',
|
||||
options: SERVICES_SOURCES,
|
||||
value: 'traces',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get('http://localhost/api/v2/metrics/onboarding', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(metricsOnboardingResponse(values.metricsIngestion)),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v5/query_range', async (req, res, ctx) => {
|
||||
const signal = signalOf((await req.json()) as QueryRangeV5Body);
|
||||
|
||||
const isActive =
|
||||
signal === 'traces' ? values.tracesIngestion : values.logsIngestion;
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(queryRangeV5ScalarResponse(isActive ? INGESTED_COUNT : 0)),
|
||||
);
|
||||
}),
|
||||
|
||||
rest.get('http://localhost/api/v1/user/preferences', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: homeUserPreferences({
|
||||
checklistDismissed: values.welcomeChecklist === 'dismissed',
|
||||
skippedSteps: values.skippedSteps,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/users/me/dashboards',
|
||||
response.json(() => recentDashboardsResponse(values.dashboards)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: buildAlertRules(values.alertRules),
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/explorer/views',
|
||||
response.json((req) => {
|
||||
const sourcePage = req.url.searchParams.get('sourcePage') ?? 'logs';
|
||||
const signal = isSavedViewSignal(sourcePage) ? sourcePage : 'logs';
|
||||
|
||||
return savedViewsResponse(
|
||||
values.savedViewSignals.includes(signal) ? values.savedViews : 0,
|
||||
signal,
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/services',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: buildServices(values.services),
|
||||
})),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/service/top_level_operations',
|
||||
response.json(() => topLevelOperationsResponse(values.services)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v4/query_range',
|
||||
response.json(() => spanMetricsResponse()),
|
||||
),
|
||||
],
|
||||
config: ({ servicesSource }) => ({
|
||||
appContext: { featureFlags: homeFeatureFlags(servicesSource) },
|
||||
}),
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import { homeMocks } from './HomePage.stories.mocks';
|
||||
|
||||
import HomePage from './HomePage';
|
||||
|
||||
type HomeArgs = PageStoryArgs<typeof homeMocks>;
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
component: HomePage,
|
||||
decorators: [withAppLayout],
|
||||
...storyMocks(homeMocks, { route: ROUTES.HOME }),
|
||||
} satisfies Meta<HomeArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<HomeArgs>;
|
||||
|
||||
/**
|
||||
* Every widget carrying data: all three signals ingesting, alert rules across
|
||||
* severities, recent dashboards, saved views on each explorer tab and a
|
||||
* services table with failing services.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Fresh workspace: nothing ingested yet, so the welcome checklist takes over. */
|
||||
export const NoIngestion: Story = {
|
||||
args: {
|
||||
logsIngestion: false,
|
||||
tracesIngestion: false,
|
||||
metricsIngestion: false,
|
||||
alertRules: 0,
|
||||
dashboards: 0,
|
||||
savedViews: 0,
|
||||
services: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Telemetry reads only: no permission to manage anything, so the create actions
|
||||
* and the legacy editor role are both gone.
|
||||
*/
|
||||
export const ViewerAccess: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/** Widgets stuck in their loading state, shell included. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
@@ -1,311 +0,0 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { checkListStepToPreferenceKeyMap } from 'container/Home/constants';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ServiceDataProps } from 'api/metrics/getTopLevelOperations';
|
||||
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { explorerView } from 'mocks-server/__mockdata__/explorer_views';
|
||||
import { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
|
||||
import type { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
|
||||
import type { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
import type { ServicesList } from 'types/api/metrics/getService';
|
||||
import type { UserPreference } from 'types/api/preferences/preference';
|
||||
|
||||
import { baseUserPreferences } from '@/storybook/msw/__mockdata__/appShell';
|
||||
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__mockdata__/queryRange';
|
||||
|
||||
export { queryRangeV5ScalarResponse };
|
||||
|
||||
/** Shapes follow the fields the components read, not the full generated DTOs. */
|
||||
|
||||
export const metricsOnboardingResponse = (
|
||||
hasMetrics: boolean,
|
||||
): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: { hasMetrics },
|
||||
});
|
||||
|
||||
export const HOME_CHECKLIST_STEPS = [
|
||||
'SEND_LOGS',
|
||||
'SEND_TRACES',
|
||||
'SEND_METRICS',
|
||||
'SETUP_ALERTS',
|
||||
'SETUP_SAVED_VIEWS',
|
||||
'SETUP_DASHBOARDS',
|
||||
] as const;
|
||||
|
||||
export type HomeChecklistStep = (typeof HOME_CHECKLIST_STEPS)[number];
|
||||
|
||||
const skippedPreference = (name: string): UserPreference => ({
|
||||
name,
|
||||
description: 'Welcome checklist step skipped',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['org'],
|
||||
value: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* The welcome checklist reads its dismissed and skipped flags off the user
|
||||
* preferences list, one entry per step.
|
||||
*/
|
||||
export const homeUserPreferences = ({
|
||||
checklistDismissed,
|
||||
skippedSteps,
|
||||
}: {
|
||||
checklistDismissed: boolean;
|
||||
skippedSteps: readonly HomeChecklistStep[];
|
||||
}): UserPreference[] => [
|
||||
...baseUserPreferences,
|
||||
...(checklistDismissed
|
||||
? [skippedPreference(ORG_PREFERENCES.WELCOME_CHECKLIST_DO_LATER)]
|
||||
: []),
|
||||
...skippedSteps.map((step) =>
|
||||
skippedPreference(checkListStepToPreferenceKeyMap[step]),
|
||||
),
|
||||
];
|
||||
|
||||
const DASHBOARDS = [
|
||||
{
|
||||
name: 'Kubernetes cluster health',
|
||||
tags: [{ key: 'team', value: 'platform' }, { key: 'k8s' }],
|
||||
},
|
||||
{ name: 'API latency overview', tags: [{ key: 'sre' }] },
|
||||
{
|
||||
name: 'Checkout funnel',
|
||||
tags: [{ key: 'team', value: 'payments' }, { key: 'business' }],
|
||||
},
|
||||
{ name: 'Postgres slow queries', tags: [{ key: 'database' }] },
|
||||
{
|
||||
name: 'Kafka consumer lag',
|
||||
tags: [{ key: 'team', value: 'data' }, { key: 'streaming' }],
|
||||
},
|
||||
{ name: 'Ingress error budget', tags: [{ key: 'sre' }, { key: 'slo' }] },
|
||||
{ name: 'Cost per service', tags: [{ key: 'finops' }] },
|
||||
{
|
||||
name: 'Redis cache hit rate',
|
||||
tags: [{ key: 'database' }, { key: 'cache' }],
|
||||
},
|
||||
];
|
||||
|
||||
export const recentDashboardsResponse = (
|
||||
count: number,
|
||||
): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
dashboards: DASHBOARDS.slice(0, count).map((dashboard, index) => ({
|
||||
id: `storybook-dashboard-${index + 1}`,
|
||||
name: dashboard.name,
|
||||
spec: { display: { name: dashboard.name } },
|
||||
tags: dashboard.tags,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const ALERT_NAMES = [
|
||||
'Checkout p99 above 2s',
|
||||
'Payment failure rate',
|
||||
'Log volume spike',
|
||||
'Kafka consumer lag',
|
||||
'Pod restart storm',
|
||||
'Disk usage above 85%',
|
||||
'Frontend error rate',
|
||||
'Postgres connections saturated',
|
||||
];
|
||||
|
||||
/**
|
||||
* Cycles the jest fixtures so the list keeps their severity and firing spread.
|
||||
* `updatedAt` descends because that is the order the page sorts on.
|
||||
*/
|
||||
export const buildAlertRules = (count: number): RuletypesRuleDTO[] =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
...alertRulesFixture[index % alertRulesFixture.length],
|
||||
id: `storybook-rule-${index + 1}`,
|
||||
alert: ALERT_NAMES[index % ALERT_NAMES.length],
|
||||
updatedAt: new Date(Date.UTC(2026, 7, 20 - index, 9)).toISOString(),
|
||||
}));
|
||||
|
||||
export const SAVED_VIEW_SIGNALS = ['logs', 'traces', 'metrics'] as const;
|
||||
|
||||
export type SavedViewSignal = (typeof SAVED_VIEW_SIGNALS)[number];
|
||||
|
||||
const VIEW_NAMES: Record<SavedViewSignal, string[]> = {
|
||||
logs: [
|
||||
'Checkout errors',
|
||||
'Auth service warnings',
|
||||
'Slow SQL statements',
|
||||
'Payment webhooks',
|
||||
'Rate limited requests',
|
||||
'Cron job failures',
|
||||
],
|
||||
traces: [
|
||||
'Slowest checkout spans',
|
||||
'Failed payment traces',
|
||||
'Cart to order funnel',
|
||||
'External API calls',
|
||||
'Cold start requests',
|
||||
'Retried gRPC calls',
|
||||
],
|
||||
metrics: [
|
||||
'Pod memory by namespace',
|
||||
'Queue depth by topic',
|
||||
'HTTP throughput',
|
||||
'Container CPU throttling',
|
||||
'JVM heap usage',
|
||||
'Cache hit ratio',
|
||||
],
|
||||
};
|
||||
|
||||
export const isSavedViewSignal = (value: string): value is SavedViewSignal =>
|
||||
SAVED_VIEW_SIGNALS.includes(value as SavedViewSignal);
|
||||
|
||||
export const savedViewsResponse = (
|
||||
count: number,
|
||||
sourcePage: SavedViewSignal,
|
||||
): Record<string, unknown> => {
|
||||
const names = VIEW_NAMES[sourcePage];
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: Array.from({ length: Math.min(count, names.length) }, (_, index) => ({
|
||||
...explorerView.data[0],
|
||||
id: `storybook-${sourcePage}-view-${index + 1}`,
|
||||
name: names[index],
|
||||
sourcePage,
|
||||
tags: [sourcePage],
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
/** Ordered by p99 with errors at both ends, so any slice keeps the spread. */
|
||||
const SERVICES: ServicesList[] = [
|
||||
{
|
||||
serviceName: 'payments',
|
||||
p99: 2_940_100_000,
|
||||
avgDuration: 921_440_000,
|
||||
numCalls: 46_080,
|
||||
callRate: 25.6,
|
||||
numErrors: 5990,
|
||||
errorRate: 13,
|
||||
},
|
||||
{
|
||||
serviceName: 'checkout',
|
||||
p99: 1_248_900_000,
|
||||
avgDuration: 486_310_000,
|
||||
numCalls: 92_160,
|
||||
callRate: 51.2,
|
||||
numErrors: 4608,
|
||||
errorRate: 5,
|
||||
},
|
||||
{
|
||||
serviceName: 'frontend',
|
||||
p99: 812_450_000,
|
||||
avgDuration: 274_120_000,
|
||||
numCalls: 184_320,
|
||||
callRate: 102.4,
|
||||
numErrors: 1843,
|
||||
errorRate: 1,
|
||||
},
|
||||
{
|
||||
serviceName: 'shipping',
|
||||
p99: 486_200_000,
|
||||
avgDuration: 192_800_000,
|
||||
numCalls: 23_040,
|
||||
callRate: 12.8,
|
||||
numErrors: 691,
|
||||
errorRate: 3,
|
||||
},
|
||||
{
|
||||
serviceName: 'cart',
|
||||
p99: 214_800_000,
|
||||
avgDuration: 88_640_000,
|
||||
numCalls: 138_240,
|
||||
callRate: 76.8,
|
||||
numErrors: 0,
|
||||
errorRate: 0,
|
||||
},
|
||||
{
|
||||
serviceName: 'catalogue',
|
||||
p99: 96_300_000,
|
||||
avgDuration: 41_220_000,
|
||||
numCalls: 276_480,
|
||||
callRate: 153.6,
|
||||
numErrors: 276,
|
||||
errorRate: 0.1,
|
||||
},
|
||||
{
|
||||
serviceName: 'recommendations',
|
||||
p99: 64_100_000,
|
||||
avgDuration: 28_400_000,
|
||||
numCalls: 61_440,
|
||||
callRate: 34.1,
|
||||
numErrors: 61,
|
||||
errorRate: 0.1,
|
||||
},
|
||||
{
|
||||
serviceName: 'notifications',
|
||||
p99: 38_700_000,
|
||||
avgDuration: 15_900_000,
|
||||
numCalls: 12_288,
|
||||
callRate: 6.8,
|
||||
numErrors: 0,
|
||||
errorRate: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export const buildServices = (count: number): ServicesList[] =>
|
||||
SERVICES.slice(0, count);
|
||||
|
||||
export const SERVICES_SOURCES = ['traces', 'span-metrics'] as const;
|
||||
|
||||
export type ServicesSource = (typeof SERVICES_SOURCES)[number];
|
||||
|
||||
/** `USE_SPAN_METRICS` swaps the services card for the span-metrics one. */
|
||||
export const homeFeatureFlags = (source: ServicesSource): FeatureFlagProps[] =>
|
||||
defaultFeatureFlags.map((flag) =>
|
||||
flag.name === FeatureKeys.USE_SPAN_METRICS
|
||||
? { ...flag, active: source === 'span-metrics' }
|
||||
: flag,
|
||||
);
|
||||
|
||||
/** The span-metrics card takes its row set from the top level operations. */
|
||||
export const topLevelOperationsResponse = (count: number): ServiceDataProps =>
|
||||
Object.fromEntries(
|
||||
buildServices(count).map((service) => [
|
||||
service.serviceName,
|
||||
['HTTP GET /', 'HTTP POST /checkout'],
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Span-metrics latency, error rate and ops per second come off a table panel.
|
||||
* The page reads them from `newResult`, which the table branch of
|
||||
* `GetMetricQueryRange` never adds, so the columns render as zero however
|
||||
* complete this body is. That is the app's gap, not the mock's.
|
||||
*/
|
||||
export const spanMetricsResponse = (): {
|
||||
status: string;
|
||||
data: MetricRangePayloadV3['data'];
|
||||
} => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
resultType: 'table',
|
||||
result: [
|
||||
{
|
||||
queryName: '',
|
||||
legend: '',
|
||||
series: null,
|
||||
list: null,
|
||||
table: {
|
||||
columns: [
|
||||
{ name: 'A', queryName: 'A', isValueColumn: true },
|
||||
{ name: 'D', queryName: 'D', isValueColumn: true },
|
||||
{ name: 'F1', queryName: 'F1', isValueColumn: true },
|
||||
],
|
||||
rows: [{ data: { A: 148_000_000, D: 12.4, F1: 1.8 } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,305 +0,0 @@
|
||||
# Storybook
|
||||
|
||||
Runs SigNoz pages and components with no backend: every request is answered by
|
||||
msw, and the providers the app mounts at boot are replaced by story-controlled
|
||||
values.
|
||||
|
||||
```bash
|
||||
pnpm storybook # dev server on :6006
|
||||
pnpm storybook:build # static build into storybook-static/
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What lives there |
|
||||
| ----------------- | ---------------------------------------------------------------------- |
|
||||
| `runtime/` | `resolveStory`: story context in, the world the story renders in out |
|
||||
| `controls/` | Declaring controls (`defineStoryMocks`) and composing mock modules |
|
||||
| `globals/` | The mock modules every story carries: app shell and access |
|
||||
| `access/` | What a permission grant allows, and the legacy role it derives |
|
||||
| `providers/` | The Storybook adapter over `src/harness/AppHarness` |
|
||||
| `navigation/` | Keeping a story on its page, and reporting what it tried to leave for |
|
||||
| `msw/` | The default handler set and the shell's endpoints |
|
||||
| `mocks/` | Modules aliased in place of the app's own |
|
||||
| `decorators/` | `withProviders` (global) and `withAppLayout` (opt-in per page) |
|
||||
|
||||
A page's own mocks live with the page, not here. See [Adding a page
|
||||
story](#adding-a-page-story).
|
||||
|
||||
## What a story gets for free
|
||||
|
||||
`withProviders` (global decorator, `.storybook/preview.tsx`) wraps every story in
|
||||
`StorybookProviders`, the Storybook adapter over `src/harness/AppHarness`.
|
||||
`AppHarness` is the app's provider tree from `src/index.tsx` +
|
||||
`src/AppRoutes/index.tsx`, minus Sentry, posthog and `AppProvider`, with the
|
||||
pieces a runner has to choose left as props: the router, the nuqs adapter, the
|
||||
store, the query client and the mocked `AppContext`.
|
||||
|
||||
`tests/test-utils` mounts its own, smaller tree for jest and does not go through
|
||||
`AppHarness`: the suite has ~20 files that mock `hooks/useDarkMode`,
|
||||
`hooks/useNotifications` or `providers/cmdKProvider` down to a single export, so
|
||||
the providers those modules also carry would come back `undefined`. A provider
|
||||
added to the app therefore still needs adding in both places.
|
||||
|
||||
Storybook fills the seams with:
|
||||
|
||||
- `AppContext` from `tests/fixtures/appContextMock`, the same fixture the jest
|
||||
suite uses, so a story and a test see the same user, license and flags.
|
||||
- A fresh react-query client and redux store per story: no cache or state bleed.
|
||||
- `nuqs` on its testing adapter, so query-param state lives in memory and never
|
||||
touches the iframe URL.
|
||||
- Theme from the toolbar (dark/light). `applyThemeBodyClass` puts `<body>` in the
|
||||
state the app gets from `index.html` plus `AppLayout`: `data-theme="default"`
|
||||
(every `@signozhq/design-tokens` semantic token is scoped to it, and without it
|
||||
`--l1-background` and friends resolve to nothing and the page renders
|
||||
unstyled) and the `darkMode`/`dark`/`lightMode` classes.
|
||||
|
||||
## The story runtime
|
||||
|
||||
`runtime/resolveStory.ts` is the one place that turns a story's parameters and
|
||||
the controls panel's current values into everything the story runs on: the msw
|
||||
handlers in resolution order, the provider config, the theme, the remount key,
|
||||
and the module-level state to seed. The preview loader applies it before the
|
||||
decorators run; the decorator reads the same result, memoised on the story and
|
||||
its args.
|
||||
|
||||
Handlers resolve first-match-wins, in this order:
|
||||
|
||||
1. the story's own `parameters.msw.handlers`;
|
||||
2. the page's control-driven handlers;
|
||||
3. the global mocks' handlers (access);
|
||||
4. `msw/appShellHandlers.ts`, the endpoints the shell hits on every route, and
|
||||
the ones whose jest fixture is too thin to show it doing its job;
|
||||
5. `src/mocks-server/handlers.ts`, the jest handlers verbatim. An endpoint both
|
||||
runners need belongs here so jest gets it too;
|
||||
6. a catch-all for `http://localhost/api/*` that logs and answers 501, so an
|
||||
endpoint nobody mocked fails loudly instead of hanging on a refused
|
||||
connection.
|
||||
|
||||
The whole set is re-registered on every story render rather than handed to
|
||||
`setupWorker` once. Editing a handler module then takes effect on the next
|
||||
render; with the handlers baked in at worker creation, a long-lived dev server
|
||||
kept answering with the set it started with, and endpoints added later showed up
|
||||
as failed requests.
|
||||
|
||||
The handlers are declared against `http://localhost`, which is why
|
||||
`constants/env` is mocked to that origin. msw intercepts before the request
|
||||
leaves the page, so nothing reaches the network.
|
||||
|
||||
## Overrides
|
||||
|
||||
Per-story, through `parameters`:
|
||||
|
||||
```tsx
|
||||
export const Elsewhere: Story = {
|
||||
parameters: {
|
||||
signoz: {
|
||||
route: '/home?relativeTime=1h',
|
||||
appContext: { featureFlags: [] },
|
||||
reduxState: { globalTime: { ... } },
|
||||
theme: 'light',
|
||||
},
|
||||
msw: {
|
||||
handlers: [
|
||||
rest.get('http://localhost/api/v2/rules', handleInternalServerError),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
`parameters.signoz` is typed by `SignozStoryConfig` in `src/storybook/types.ts`.
|
||||
Who the story runs as is not in there. See [Access](#access).
|
||||
|
||||
Anything a page declares as a control belongs in `args`, not in `parameters`.
|
||||
|
||||
## The app shell
|
||||
|
||||
A page story always runs inside the real `AppLayout` (side nav, top nav,
|
||||
banners). A page without its shell is not the page anyone sees. Declare it once
|
||||
on the meta so every story of that page inherits it:
|
||||
|
||||
```tsx
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
component: HomePage,
|
||||
decorators: [withAppLayout],
|
||||
} satisfies Meta<typeof HomePage>;
|
||||
```
|
||||
|
||||
## Controls
|
||||
|
||||
A page declares what about its mocks is adjustable, and the controls panel drives
|
||||
it. Every control is a knob on the response, not a prop on the component: turning
|
||||
one re-registers the msw handlers and remounts the story with an empty query
|
||||
cache, so the page fetches again and renders the new data.
|
||||
|
||||
```tsx
|
||||
// src/pages/HomePage/HomePage.stories.mocks.ts
|
||||
export const homeMocks = defineStoryMocks({
|
||||
controls: {
|
||||
logsIngestion: toggleControl('Logs ingestion', { group: SIGNALS, value: true }),
|
||||
dashboards: countControl('Recent dashboards', { group: LISTS, value: 5, max: 8 }),
|
||||
welcomeChecklist: choiceControl<ChecklistVisibility>('Welcome checklist', {
|
||||
group: ONBOARDING,
|
||||
options: CHECKLIST_VISIBILITY,
|
||||
value: 'visible',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/users/me/dashboards',
|
||||
response.json(() => recentDashboardsResponse(values.dashboards)),
|
||||
),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/pages/HomePage/HomePage.stories.tsx
|
||||
type HomeArgs = PageStoryArgs<typeof homeMocks>;
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
component: HomePage,
|
||||
decorators: [withAppLayout],
|
||||
...storyMocks(homeMocks, { route: ROUTES.HOME }),
|
||||
} satisfies Meta<HomeArgs>;
|
||||
|
||||
export const NoIngestion: StoryObj<HomeArgs> = {
|
||||
args: { logsIngestion: false, tracesIngestion: false, metricsIngestion: false },
|
||||
};
|
||||
```
|
||||
|
||||
`toggleControl`, `countControl`, `choiceControl` and `multiChoiceControl` build
|
||||
the panel row and carry the value's type, so `values` inside `handlers` is typed
|
||||
and a story's `args` are checked.
|
||||
|
||||
The hooks a mock module can answer, all optional. `handlers` answers the page's
|
||||
endpoints; `config` returns the provider-level knobs no endpoint covers;
|
||||
`responseState` says how the endpoints declared through `response` answer;
|
||||
`effect` seeds module-level state no provider exposes, such as no-auth mode; and
|
||||
`role` derives the legacy role, which only `authzMocks` does.
|
||||
|
||||
Endpoints declared through `response.json` follow the response state (`loaded`,
|
||||
`loading` or `error`) which the `Data` control drives, so one declaration covers
|
||||
all three. Endpoints the page cannot render without, such as ingestion detection
|
||||
and preferences, take a plain msw resolver so they keep answering while the rest
|
||||
of the page hangs or fails.
|
||||
|
||||
The mock modules every story carries are registered in `globals/index.ts`:
|
||||
`appShellMocks` (app-wide banners, side nav state, `Data`) and `authzMocks`
|
||||
(below). Adding one there publishes its controls and widens `PageStoryArgs` in
|
||||
the same edit.
|
||||
|
||||
## Access
|
||||
|
||||
Permissions are the knob, not roles. `POST /api/v1/authz/check` is the single
|
||||
gate the app reads: route guards, `AuthZGuard`, `AuthZButton` and `user.role` all
|
||||
resolve through it, so the controls answer that endpoint and everything
|
||||
downstream follows. `access/access.ts` is what decides:
|
||||
`accessFor(preset, extra)` returns the permission set, whether a given check is
|
||||
allowed, and the legacy role it derives.
|
||||
|
||||
- **Access**: `admin`, `editor`, `viewer`, `anonymous`, `grant-all`, `deny-all`,
|
||||
`custom`, `dev-tools`.
|
||||
- **Permissions**: granted on top of the preset, as `relation:kind`
|
||||
(`read:logs`, `create:serviceaccount`, …); `custom` starts from nothing, so
|
||||
there the list is the whole grant. Generated from
|
||||
`lib/authz/hooks/useAuthZ/permissions.type.ts`, so a resource added to the
|
||||
catalogue shows up without touching Storybook. A selector-scoped check
|
||||
(`update` on `role:some-id`) matches the entry for its kind; the legacy
|
||||
`assignee:role:signoz-*` permissions are listed individually.
|
||||
- **Check state**: `loaded`, `loading` or `error`, the same forcing the AuthZ
|
||||
dev modal offers.
|
||||
|
||||
`user.role` comes from the same grant, derived the way `AppProvider` derives it,
|
||||
so the legacy role, `hasEditPermission`, `routePermission` and
|
||||
`componentPermission` all follow the same control and no story can set them to
|
||||
something the check endpoint disagrees with. The runtime writes the result to
|
||||
`<body data-signoz-story-role>`, and the provider tree writes what
|
||||
`useAppContext()` actually yields to `<body data-signoz-context-role>`, so
|
||||
whether the page is reading it is one glance away in the Elements panel.
|
||||
|
||||
Granting no legacy role at all (`deny-all`, or `custom` without one ticked)
|
||||
derives `ANONYMOUS`, exactly as `AppProvider` does. The legacy checks are written
|
||||
as `role !== VIEWER`, so an anonymous user passes them and sees *more* than a
|
||||
viewer. That is the app's gap, faithfully reproduced: to see the viewer UI, grant
|
||||
the viewer role. The role-named presets exist because those checks still exist;
|
||||
when the roles go, delete the presets and the derivation. The permission list
|
||||
stays.
|
||||
|
||||
For anything finer than a preset, the app's own dev tools are mounted in every
|
||||
story: `⌘K` → **AuthZ DevTools** lists the permissions the page actually checked
|
||||
and overrides them one by one (granted, denied, delayed, error). Set Access to
|
||||
`dev-tools` first, because the other values reset the override store on render, so
|
||||
a leftover override from a real dev session cannot answer for the controls panel.
|
||||
Overrides only apply while `IS_DEV` is true, which means the dev server, not a
|
||||
static build.
|
||||
|
||||
Adding or renaming a project-level control needs a tab reload: Vite hot-updates
|
||||
`preview.tsx` without re-preparing the open stories, so the panel keeps the
|
||||
controls, and the arg values, it was built with.
|
||||
|
||||
## Module mocks
|
||||
|
||||
Aliased for every story in `.storybook/main.ts`, the same way `jest.config.ts`
|
||||
does it through `moduleNameMapper`. Each replacement is typed as the module it
|
||||
stands in for, so an export added to the real module is a compile error here
|
||||
rather than a story that fails at render:
|
||||
|
||||
| Module | Replacement | Why |
|
||||
| --------------------- | ---------------------------------- | ------------------------------------------ |
|
||||
| `lib/history` | `navigation/history.alias.ts` | keeps a story on its page, see below |
|
||||
| `api/common/logEvent` | `mocks/logEvent.mock.ts` | analytics never leave the iframe |
|
||||
| `constants/env` | `mocks/env.mock.ts` | pins the API origin the handlers answer on |
|
||||
|
||||
Mocks use `fn()` from `storybook/test`, so a play function can assert on them:
|
||||
|
||||
```tsx
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
play: async () => {
|
||||
await expect(logEvent).toHaveBeenCalledWith('Homepage: Visited', {});
|
||||
},
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
A story renders one page, so leaving that page would unmount it.
|
||||
`navigation/pageScope.ts` holds the rule, `navigation/containment.ts` is what the
|
||||
app sees in place of `lib/history`, and the two tell a navigation apart by
|
||||
pathname:
|
||||
|
||||
- **Same page**: a query-param or hash change, which is how tabs, filters,
|
||||
pagination and time ranges are driven. It is applied, and the page re-renders
|
||||
the way it does in the app. Anchors are covered too: an in-page `<a href="?tab=x">`
|
||||
or `<Link to="/home?tab=x">` is intercepted and pushed onto the story's history
|
||||
rather than followed, which would navigate the iframe out of the story.
|
||||
- **Another page**: a different pathname, an off-site href, `window.open` (what
|
||||
`useSafeNavigate({ newTab })` calls) or a relative `go`/`goBack`, which carries
|
||||
no target to compare against. It is swallowed and reported to
|
||||
`NavigationBlockedOverlay`, which lists what was attempted. Nothing is silently
|
||||
dropped.
|
||||
|
||||
`nuqs` is the one gap: it runs on its testing adapter and keeps its own copy of
|
||||
the query string, seeded from the story's `route`. A page that writes params
|
||||
through both `useQueryState` and `history.push({ search })` sees the two diverge
|
||||
inside a story; a page that stays on one mechanism does not.
|
||||
|
||||
## Adding a page story
|
||||
|
||||
The `signoz-page-story` skill in `.claude/skills/` carries this as a workflow:
|
||||
mapping the page, deriving its controls, and the checks a story has to pass.
|
||||
|
||||
1. Point the story at the page component under `src/pages/<Page>`.
|
||||
2. Declare the page's mocks in `<Page>.stories.mocks.ts` next to it, with its
|
||||
payload builders under `<Page>/__mockdata__/`, and spread
|
||||
`storyMocks(<page>Mocks, { route })` into the meta.
|
||||
3. Add `decorators: [withAppLayout]` to the meta.
|
||||
4. Give the default story every widget populated. A page story earns its keep by
|
||||
showing what the page looks like with data, not with empty states.
|
||||
5. Run it and watch the console: an msw warning or a `[storybook] no msw handler`
|
||||
line is an endpoint the page hits that no handler covers yet.
|
||||
6. Reach for a control before a story. A variant earns a story only when it is
|
||||
worth linking to; anything else is a control someone can turn.
|
||||
@@ -1,155 +0,0 @@
|
||||
import type { AuthtypesTransactionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
IsAdminPermission,
|
||||
IsAnonymousPermission,
|
||||
IsEditorPermission,
|
||||
IsViewerPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/legacy';
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
import {
|
||||
formatPermission,
|
||||
gettableTransactionToPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
/**
|
||||
* `relation:kind` for every verb the backend allows on a resource, from the
|
||||
* generated permission catalogue, so a resource added there shows up here
|
||||
* without anyone touching this file. Selector-scoped checks (`role:some-id`)
|
||||
* match the entry for their kind.
|
||||
*/
|
||||
export const permissionCatalogue = (): string[] => {
|
||||
const entries = new Set<string>();
|
||||
|
||||
for (const [relation, types] of Object.entries(
|
||||
permissionsType.data.relations,
|
||||
)) {
|
||||
for (const resource of permissionsType.data.resources) {
|
||||
const appliesToResource = (types as readonly string[]).includes(
|
||||
resource.type,
|
||||
);
|
||||
|
||||
const allowsVerb = (resource.allowedVerbs as readonly string[]).includes(
|
||||
relation,
|
||||
);
|
||||
|
||||
if (appliesToResource && allowsVerb) {
|
||||
entries.add(`${relation}:${resource.kind}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...entries].sort();
|
||||
};
|
||||
|
||||
const CATALOGUE = permissionCatalogue();
|
||||
|
||||
const TELEMETRY_READS = CATALOGUE.filter((permission) =>
|
||||
permissionsType.data.resources.some(
|
||||
(resource) =>
|
||||
resource.type === 'telemetryresource' &&
|
||||
permission === `read:${resource.kind}`,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* `user.role` is itself an authz check in the real app (`AppProvider` derives it
|
||||
* from these), so a caller that wants a role grants the matching permission
|
||||
* rather than setting the role directly.
|
||||
*/
|
||||
export const LEGACY_ROLE_PERMISSIONS = {
|
||||
[USER_ROLES.ADMIN]: formatPermission(IsAdminPermission),
|
||||
[USER_ROLES.EDITOR]: formatPermission(IsEditorPermission),
|
||||
[USER_ROLES.VIEWER]: formatPermission(IsViewerPermission),
|
||||
[USER_ROLES.ANONYMOUS]: formatPermission(IsAnonymousPermission),
|
||||
};
|
||||
|
||||
export const ACCESS_PRESETS = [
|
||||
'admin',
|
||||
'editor',
|
||||
'viewer',
|
||||
'anonymous',
|
||||
'grant-all',
|
||||
'deny-all',
|
||||
'custom',
|
||||
'dev-tools',
|
||||
] as const;
|
||||
|
||||
export type AccessPreset = (typeof ACCESS_PRESETS)[number];
|
||||
|
||||
/**
|
||||
* The legacy roles only differ in authz for the resources the backend already
|
||||
* covers: an admin manages roles, service accounts and API keys, while editor
|
||||
* and viewer are telemetry readers and differ through `user.role` alone. The
|
||||
* presets go away with the roles; the permission list does not.
|
||||
*/
|
||||
const ADMIN_PERMISSIONS = [
|
||||
LEGACY_ROLE_PERMISSIONS[USER_ROLES.ADMIN],
|
||||
// Without the `assignee` wildcard, which would hand out every legacy role at
|
||||
// once. That is what `grant-all` is for.
|
||||
...CATALOGUE.filter((permission) => permission !== 'assignee:role'),
|
||||
];
|
||||
|
||||
const PRESET_PERMISSIONS: Record<AccessPreset, readonly string[]> = {
|
||||
admin: ADMIN_PERMISSIONS,
|
||||
editor: [LEGACY_ROLE_PERMISSIONS[USER_ROLES.EDITOR], ...TELEMETRY_READS],
|
||||
viewer: [LEGACY_ROLE_PERMISSIONS[USER_ROLES.VIEWER], ...TELEMETRY_READS],
|
||||
anonymous: [LEGACY_ROLE_PERMISSIONS[USER_ROLES.ANONYMOUS]],
|
||||
'grant-all': [...Object.values(LEGACY_ROLE_PERMISSIONS), ...CATALOGUE],
|
||||
'deny-all': [],
|
||||
custom: [],
|
||||
'dev-tools': ADMIN_PERMISSIONS,
|
||||
};
|
||||
|
||||
/** Every permission a caller can grant on top of a preset. */
|
||||
export const PERMISSION_OPTIONS = [
|
||||
...Object.values(LEGACY_ROLE_PERMISSIONS),
|
||||
...CATALOGUE,
|
||||
];
|
||||
|
||||
export interface AccessGrant {
|
||||
permissions: ReadonlySet<string>;
|
||||
/** Answers one `authz/check` transaction the way the backend would. */
|
||||
allows(transaction: AuthtypesTransactionDTO): boolean;
|
||||
/**
|
||||
* The legacy role the granted `assignee:role:signoz-*` permissions derive,
|
||||
* the way `AppProvider` derives it. No legacy role granted lands on
|
||||
* `ANONYMOUS`.
|
||||
*/
|
||||
legacyRole: ROLES;
|
||||
}
|
||||
|
||||
const deriveLegacyRole = (granted: ReadonlySet<string>): ROLES => {
|
||||
const role = Object.entries(LEGACY_ROLE_PERMISSIONS).find(([, permission]) =>
|
||||
granted.has(permission),
|
||||
);
|
||||
|
||||
return (role?.[0] ?? USER_ROLES.ANONYMOUS) as ROLES;
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission set a preset plus its extra grants resolve to, and the two
|
||||
* questions the app asks of it. `custom` and `deny-all` start from nothing, so
|
||||
* there the extra grants are the whole set.
|
||||
*/
|
||||
export const accessFor = (
|
||||
preset: AccessPreset,
|
||||
extraPermissions: readonly string[] = [],
|
||||
): AccessGrant => {
|
||||
const permissions = new Set([
|
||||
...PRESET_PERMISSIONS[preset],
|
||||
...extraPermissions,
|
||||
]);
|
||||
|
||||
return {
|
||||
permissions,
|
||||
allows: (transaction): boolean =>
|
||||
permissions.has(
|
||||
formatPermission(gettableTransactionToPermission(transaction)),
|
||||
) ||
|
||||
permissions.has(
|
||||
`${transaction.relation}:${transaction.object.resource.kind}`,
|
||||
),
|
||||
legacyRole: deriveLegacyRole(permissions),
|
||||
};
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { AnyStoryMocks, ControlDescriptor, StoryMockArgs } from './types';
|
||||
|
||||
type UnionToIntersection<TUnion> = (
|
||||
TUnion extends unknown ? (arg: TUnion) => void : never
|
||||
) extends (arg: infer TIntersection) => void
|
||||
? TIntersection
|
||||
: never;
|
||||
|
||||
/** The args of every mock module in a list, as one object type. */
|
||||
export type ComposedMockArgs<TMocks extends readonly AnyStoryMocks[]> =
|
||||
UnionToIntersection<StoryMockArgs<TMocks[number]>>;
|
||||
|
||||
export interface ComposedStoryMocks<TMocks extends readonly AnyStoryMocks[]> {
|
||||
/** In resolution order: the first module to answer a question wins. */
|
||||
members: TMocks;
|
||||
args: ComposedMockArgs<TMocks>;
|
||||
argTypes: Record<string, ControlDescriptor>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One registration point for a set of mock modules: the panel rows they publish
|
||||
* and the args type a story is checked against are both derived from the list,
|
||||
* so adding a module is a single edit.
|
||||
*/
|
||||
export const composeStoryMocks = <TMocks extends readonly AnyStoryMocks[]>(
|
||||
...members: TMocks
|
||||
): ComposedStoryMocks<TMocks> => ({
|
||||
members,
|
||||
args: Object.assign(
|
||||
{},
|
||||
...members.map((mocks) => mocks.args),
|
||||
) as ComposedMockArgs<TMocks>,
|
||||
argTypes: Object.assign({}, ...members.map((mocks) => mocks.argTypes)),
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { ControlDescriptor, MockControl } from './types';
|
||||
|
||||
interface ControlOptions {
|
||||
/** Controls-panel group, so a page's knobs stay together. */
|
||||
group: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const describe = (
|
||||
name: string,
|
||||
{ group, description }: ControlOptions,
|
||||
value: unknown,
|
||||
): ControlDescriptor => ({
|
||||
name,
|
||||
description,
|
||||
table: {
|
||||
category: group,
|
||||
defaultValue: { summary: JSON.stringify(value) },
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleControl = (
|
||||
name: string,
|
||||
options: ControlOptions & { value: boolean },
|
||||
): MockControl<boolean> => ({
|
||||
defaultValue: options.value,
|
||||
argType: {
|
||||
...describe(name, options, options.value),
|
||||
control: { type: 'boolean' },
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Item count for a list. `max` should go past what the page renders so a story
|
||||
* can show the cap being hit.
|
||||
*/
|
||||
export const countControl = (
|
||||
name: string,
|
||||
options: ControlOptions & { value: number; max: number },
|
||||
): MockControl<number> => ({
|
||||
defaultValue: options.value,
|
||||
argType: {
|
||||
...describe(name, options, options.value),
|
||||
control: { type: 'range', min: 0, max: options.max, step: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
export const choiceControl = <TOption extends string>(
|
||||
name: string,
|
||||
options: ControlOptions & { value: TOption; options: readonly TOption[] },
|
||||
): MockControl<TOption> => ({
|
||||
defaultValue: options.value,
|
||||
argType: {
|
||||
...describe(name, options, options.value),
|
||||
control: { type: 'select' },
|
||||
options: [...options.options],
|
||||
},
|
||||
});
|
||||
|
||||
export const multiChoiceControl = <TOption extends string>(
|
||||
name: string,
|
||||
options: ControlOptions & {
|
||||
value: readonly TOption[];
|
||||
options: readonly TOption[];
|
||||
},
|
||||
): MockControl<TOption[]> => ({
|
||||
defaultValue: [...options.value],
|
||||
argType: {
|
||||
...describe(name, options, options.value),
|
||||
control: { type: 'check' },
|
||||
options: [...options.options],
|
||||
},
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import type {
|
||||
ControlDescriptor,
|
||||
MockControlMap,
|
||||
MockControlValues,
|
||||
StoryMocks,
|
||||
StoryMocksDefinition,
|
||||
} from './types';
|
||||
import type { SignozStoryConfig, StoryOwnedConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Turns a page's control declarations into the `args` / `argTypes` its meta
|
||||
* exposes and the reader the runtime uses to fold the panel's current values
|
||||
* back into mock responses.
|
||||
*/
|
||||
export const defineStoryMocks = <TControls extends MockControlMap>(
|
||||
definition: StoryMocksDefinition<TControls>,
|
||||
): StoryMocks<TControls> => {
|
||||
const entries = Object.entries(definition.controls);
|
||||
|
||||
const args = Object.fromEntries(
|
||||
entries.map(([name, control]) => [name, control.defaultValue]),
|
||||
) as MockControlValues<TControls>;
|
||||
|
||||
const argTypes: Record<string, ControlDescriptor> = Object.fromEntries(
|
||||
entries.map(([name, control]) => [name, control.argType]),
|
||||
);
|
||||
|
||||
return {
|
||||
...definition,
|
||||
args,
|
||||
argTypes,
|
||||
read: (storyArgs): MockControlValues<TControls> =>
|
||||
Object.fromEntries(
|
||||
entries.map(([name, control]) => [
|
||||
name,
|
||||
storyArgs[name] ?? control.defaultValue,
|
||||
]),
|
||||
) as MockControlValues<TControls>,
|
||||
};
|
||||
};
|
||||
|
||||
interface StoryMocksMeta<TControls extends MockControlMap> {
|
||||
args: MockControlValues<TControls>;
|
||||
argTypes: Record<string, ControlDescriptor>;
|
||||
parameters: { signoz: SignozStoryConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta fragment a page spreads to publish its controls:
|
||||
* `...storyMocks(homeMocks, { route: ROUTES.HOME })`.
|
||||
*/
|
||||
export const storyMocks = <TControls extends MockControlMap>(
|
||||
mocks: StoryMocks<TControls>,
|
||||
config?: StoryOwnedConfig,
|
||||
): StoryMocksMeta<TControls> => ({
|
||||
args: mocks.args,
|
||||
argTypes: mocks.argTypes,
|
||||
parameters: { signoz: { ...config, mocks } },
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { ArgTypes } from '@storybook/react-vite';
|
||||
import type { RequestHandler } from 'msw';
|
||||
|
||||
import type { MockResponse } from '../msw/types';
|
||||
import type { ResponseState } from '../runtime/responseState';
|
||||
import type { StoryOwnedConfig, StoryRole } from '../types';
|
||||
|
||||
export type ControlDescriptor = ArgTypes[string];
|
||||
|
||||
/** One row of the Storybook controls panel plus the value it starts at. */
|
||||
export interface MockControl<TValue> {
|
||||
defaultValue: TValue;
|
||||
argType: ControlDescriptor;
|
||||
}
|
||||
|
||||
export type MockControlMap = Record<string, MockControl<unknown>>;
|
||||
|
||||
export type MockControlValues<TControls extends MockControlMap> = {
|
||||
[TName in keyof TControls]: TControls[TName] extends MockControl<infer TValue>
|
||||
? TValue
|
||||
: never;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a mock module contributes to the story it runs in. Only `controls` is
|
||||
* required; the rest are the ways a control can reach the page.
|
||||
*/
|
||||
export interface StoryMocksDefinition<TControls extends MockControlMap> {
|
||||
controls: TControls;
|
||||
handlers?(
|
||||
values: MockControlValues<TControls>,
|
||||
response: MockResponse,
|
||||
): RequestHandler[];
|
||||
/** Provider-level knobs no endpoint covers. */
|
||||
config?(values: MockControlValues<TControls>): Partial<StoryOwnedConfig>;
|
||||
/** Seeds module-level app state no provider exposes, e.g. no-auth mode. */
|
||||
effect?(values: MockControlValues<TControls>): void;
|
||||
/**
|
||||
* How the endpoints declared through `response` answer. The first module that
|
||||
* answers decides, page modules ahead of the global ones.
|
||||
*/
|
||||
responseState?(values: MockControlValues<TControls>): ResponseState;
|
||||
/**
|
||||
* The legacy role the story runs as. Derived, never set by a story. See
|
||||
* `authzMocks`, which derives it from the permissions it grants.
|
||||
*/
|
||||
role?(values: MockControlValues<TControls>): StoryRole;
|
||||
}
|
||||
|
||||
export interface StoryMocks<
|
||||
TControls extends MockControlMap,
|
||||
> extends StoryMocksDefinition<TControls> {
|
||||
args: MockControlValues<TControls>;
|
||||
argTypes: Record<string, ControlDescriptor>;
|
||||
read(args: Record<string, unknown>): MockControlValues<TControls>;
|
||||
}
|
||||
|
||||
export type AnyStoryMocks = StoryMocks<MockControlMap>;
|
||||
|
||||
export type StoryMockArgs<TMocks extends AnyStoryMocks> =
|
||||
TMocks extends StoryMocks<infer TControls>
|
||||
? MockControlValues<TControls>
|
||||
: never;
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Decorator } from '@storybook/react-vite';
|
||||
import AppLayout from 'container/AppLayout';
|
||||
|
||||
/** Opt in per story with `decorators: [withAppLayout]`. */
|
||||
export const withAppLayout: Decorator = (Story) => (
|
||||
<AppLayout>
|
||||
<Story />
|
||||
</AppLayout>
|
||||
);
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Decorator } from '@storybook/react-vite';
|
||||
|
||||
import StorybookProviders from '../providers/StorybookProviders';
|
||||
import {
|
||||
resolveStory,
|
||||
type StoryRuntimeContext,
|
||||
} from '../runtime/resolveStory';
|
||||
|
||||
/**
|
||||
* Global decorator: every story renders inside the mocked provider tree the
|
||||
* story runtime resolved. Everything the tree needs in place first (handlers,
|
||||
* module-level state, theme) is applied by the preview loader, which runs
|
||||
* ahead of this.
|
||||
*/
|
||||
export const withProviders: Decorator = (Story, context) => {
|
||||
const world = resolveStory(context as unknown as StoryRuntimeContext);
|
||||
|
||||
return (
|
||||
<StorybookProviders key={world.key} {...world.config}>
|
||||
<Story />
|
||||
</StorybookProviders>
|
||||
);
|
||||
};
|
||||
@@ -1,267 +0,0 @@
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import type { IAppContext } from 'providers/App/types';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
import APIError from 'types/api/error';
|
||||
import type { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
|
||||
import {
|
||||
LicenseEvent,
|
||||
LicensePlatform,
|
||||
type LicenseResModel,
|
||||
LicenseState,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import type { UserPreference } from 'types/api/preferences/preference';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { setNoAuthMode } from 'utils/noAuthMode';
|
||||
|
||||
import { choiceControl } from '../controls/controls';
|
||||
import { defineStoryMocks } from '../controls/defineStoryMocks';
|
||||
import type { StoryMockArgs } from '../controls/types';
|
||||
import { RESPONSE_STATES, type ResponseState } from '../runtime/responseState';
|
||||
|
||||
const APP_SHELL = 'App shell';
|
||||
const DATA = 'Data';
|
||||
const LICENSE = 'License';
|
||||
|
||||
const DAY_IN_SECONDS = 24 * 60 * 60;
|
||||
|
||||
const LICENSES = [
|
||||
'cloud',
|
||||
'enterprise',
|
||||
'community-enterprise',
|
||||
'community',
|
||||
] as const;
|
||||
|
||||
const BANNERS = [
|
||||
'none',
|
||||
'trial-expiry',
|
||||
'payment-failed',
|
||||
'license-expired',
|
||||
'license-terminated',
|
||||
'no-auth',
|
||||
] as const;
|
||||
|
||||
type License = (typeof LICENSES)[number];
|
||||
|
||||
type Banner = (typeof BANNERS)[number];
|
||||
|
||||
const SIDENAV_STATES = ['pinned', 'collapsed'] as const;
|
||||
|
||||
type SidenavState = (typeof SIDENAV_STATES)[number];
|
||||
|
||||
const {
|
||||
activeLicense: baseLicense,
|
||||
trialInfo: baseTrialInfo,
|
||||
featureFlags: baseFeatureFlags,
|
||||
versionData: baseVersionData,
|
||||
} = createAppContextMock(USER_ROLES.ADMIN);
|
||||
|
||||
/**
|
||||
* The status code `/licenses/active` failed with is itself the signal
|
||||
* `useGetTenantLicense` reads: 404 is the enterprise build running unlicensed,
|
||||
* 501 the community build, where the endpoint does not exist at all.
|
||||
*/
|
||||
const licenseFetchError = (httpStatusCode: StatusCodes): APIError =>
|
||||
new APIError({
|
||||
httpStatusCode,
|
||||
error: {
|
||||
code: 'license_unavailable',
|
||||
message: 'storybook: no active license',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
|
||||
const feature = (name: FeatureKeys, active: boolean): FeatureFlagProps => ({
|
||||
name,
|
||||
active,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* What the backend serves an unlicensed enterprise build — the same keys as the
|
||||
* enterprise plan, all inactive. Mirrors `BasicPlan` in
|
||||
* `pkg/types/licensetypes/plan.go`; the community build serves none at all.
|
||||
*/
|
||||
const BASIC_PLAN: FeatureFlagProps[] = [
|
||||
FeatureKeys.SSO,
|
||||
FeatureKeys.GATEWAY,
|
||||
FeatureKeys.PREMIUM_SUPPORT,
|
||||
FeatureKeys.ANOMALY_DETECTION,
|
||||
].map((name) => feature(name, false));
|
||||
|
||||
/**
|
||||
* Which of the four deployments `useGetTenantLicense` distinguishes the story
|
||||
* runs on. The license drives the plan the app believes it is on, so the feature
|
||||
* flags and the enterprise/community build marker follow it.
|
||||
*/
|
||||
const licenseContext = (license: License): Partial<IAppContext> => {
|
||||
switch (license) {
|
||||
case 'enterprise':
|
||||
return {
|
||||
activeLicense: baseLicense && {
|
||||
...baseLicense,
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
},
|
||||
activeLicenseFetchError: null,
|
||||
};
|
||||
|
||||
case 'community-enterprise':
|
||||
return {
|
||||
activeLicense: null,
|
||||
activeLicenseFetchError: licenseFetchError(StatusCodes.NOT_FOUND),
|
||||
featureFlags: BASIC_PLAN,
|
||||
};
|
||||
|
||||
case 'community':
|
||||
return {
|
||||
activeLicense: null,
|
||||
activeLicenseFetchError: licenseFetchError(StatusCodes.NOT_IMPLEMENTED),
|
||||
featureFlags: [],
|
||||
versionData: baseVersionData && { ...baseVersionData, ee: 'N' },
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
activeLicense: baseLicense,
|
||||
activeLicenseFetchError: null,
|
||||
featureFlags: baseFeatureFlags,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A banner that reads the license needs one to read, so the community
|
||||
* deployments fall back to the licensed fixture rather than showing nothing.
|
||||
*/
|
||||
const licensedBanner = (
|
||||
activeLicense: LicenseResModel | null,
|
||||
extend: (license: LicenseResModel) => LicenseResModel,
|
||||
): Partial<IAppContext> => {
|
||||
const license = activeLicense ?? baseLicense;
|
||||
|
||||
return {
|
||||
activeLicense: license && extend(license),
|
||||
activeLicenseFetchError: null,
|
||||
};
|
||||
};
|
||||
|
||||
const bannerContext = (
|
||||
banner: Banner,
|
||||
activeLicense: LicenseResModel | null,
|
||||
): Partial<IAppContext> => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
switch (banner) {
|
||||
case 'trial-expiry':
|
||||
return {
|
||||
trialInfo: {
|
||||
...baseTrialInfo,
|
||||
onTrial: true,
|
||||
trialStart: nowInSeconds - 27 * DAY_IN_SECONDS,
|
||||
trialEnd: nowInSeconds + 3 * DAY_IN_SECONDS,
|
||||
workSpaceBlock: false,
|
||||
trialConvertedToSubscription: false,
|
||||
gracePeriodEnd: -1,
|
||||
},
|
||||
};
|
||||
|
||||
case 'payment-failed':
|
||||
return licensedBanner(activeLicense, (license) => ({
|
||||
...license,
|
||||
event_queue: {
|
||||
...license.event_queue,
|
||||
event: LicenseEvent.DEFAULT,
|
||||
scheduled_at: new Date(
|
||||
Date.now() + 7 * DAY_IN_SECONDS * 1000,
|
||||
).toISOString(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Both restricted-workspace banners need a self-hosted license; the cloud
|
||||
// platform never reaches that branch.
|
||||
case 'license-expired':
|
||||
return licensedBanner(activeLicense, (license) => ({
|
||||
...license,
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
state: LicenseState.EXPIRED,
|
||||
}));
|
||||
|
||||
case 'license-terminated':
|
||||
return licensedBanner(activeLicense, (license) => ({
|
||||
...license,
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
state: LicenseState.TERMINATED,
|
||||
}));
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `AppLayout` lays the shell out from the context rather than the API, so the
|
||||
* side nav only matches the real app when this is seeded.
|
||||
*/
|
||||
const sidenavPreferences = (pinned: boolean): UserPreference[] => [
|
||||
{
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
description: 'Keep the side navigation pinned open',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['user'],
|
||||
value: pinned,
|
||||
},
|
||||
];
|
||||
|
||||
/** Who is looking at the page is `authzMocks`; everything else is here. */
|
||||
export const appShellMocks = defineStoryMocks({
|
||||
controls: {
|
||||
license: choiceControl<License>('License', {
|
||||
group: LICENSE,
|
||||
description:
|
||||
'The deployment the story runs on, as `useGetTenantLicense` reads it. `cloud` and `enterprise` are licensed and carry the enterprise plan; `community-enterprise` is the enterprise build with no license (basic plan, every feature inactive) and `community` the open-source build (no plan, `ee: N`).',
|
||||
options: LICENSES,
|
||||
value: 'cloud',
|
||||
}),
|
||||
banner: choiceControl<Banner>('Banner', {
|
||||
group: APP_SHELL,
|
||||
description:
|
||||
'License, trial and no-auth banners above the shell. The license ones need a license to read, so they override an unlicensed License control.',
|
||||
options: BANNERS,
|
||||
value: 'none',
|
||||
}),
|
||||
sidenav: choiceControl<SidenavState>('Side nav', {
|
||||
group: APP_SHELL,
|
||||
options: SIDENAV_STATES,
|
||||
value: 'pinned',
|
||||
}),
|
||||
dataState: choiceControl<ResponseState>('State', {
|
||||
group: DATA,
|
||||
description: 'How the endpoints the page owns answer.',
|
||||
options: RESPONSE_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
},
|
||||
responseState: ({ dataState }) => dataState,
|
||||
config: ({ license, banner, sidenav }) => {
|
||||
const tenant = licenseContext(license);
|
||||
|
||||
return {
|
||||
appContext: {
|
||||
...tenant,
|
||||
...bannerContext(banner, tenant.activeLicense ?? null),
|
||||
userPreferences: sidenavPreferences(sidenav === 'pinned'),
|
||||
},
|
||||
};
|
||||
},
|
||||
effect: ({ banner }) => {
|
||||
setNoAuthMode(banner === 'no-auth');
|
||||
},
|
||||
});
|
||||
|
||||
export type AppShellArgs = StoryMockArgs<typeof appShellMocks>;
|
||||
@@ -1,83 +0,0 @@
|
||||
import { rest } from 'msw';
|
||||
import type { AuthtypesTransactionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { clearAllAuthZDevOverrides } from 'lib/authz/devtools/useAuthZDevStore';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import {
|
||||
accessFor,
|
||||
ACCESS_PRESETS,
|
||||
type AccessPreset,
|
||||
PERMISSION_OPTIONS,
|
||||
} from '../access/access';
|
||||
import { choiceControl, multiChoiceControl } from '../controls/controls';
|
||||
import { defineStoryMocks } from '../controls/defineStoryMocks';
|
||||
import type { StoryMockArgs } from '../controls/types';
|
||||
import {
|
||||
respondWith,
|
||||
RESPONSE_STATES,
|
||||
type ResponseState,
|
||||
} from '../runtime/responseState';
|
||||
|
||||
const ACCESS = 'Access';
|
||||
|
||||
/**
|
||||
* Every permission check a story makes, answered from the controls panel rather
|
||||
* than from a role. `POST /api/v1/authz/check` is the single gate the app reads:
|
||||
* route guards, `AuthZGuard`, `AuthZButton` and `user.role` all resolve through
|
||||
* it, and `access/access.ts` decides what the grant allows.
|
||||
*/
|
||||
export const authzMocks = defineStoryMocks({
|
||||
controls: {
|
||||
access: choiceControl<AccessPreset>('Access', {
|
||||
group: ACCESS,
|
||||
description:
|
||||
'Base permission set the check endpoint answers with. `custom` starts from nothing, so only the list below counts; `dev-tools` grants an admin set and leaves the AuthZ dev modal (⌘K) in charge. Granting no legacy role lands on `ANONYMOUS`, which the role-based checks still treat as "not a viewer".',
|
||||
options: ACCESS_PRESETS,
|
||||
value: 'admin',
|
||||
}),
|
||||
permissions: multiChoiceControl('Permissions', {
|
||||
group: ACCESS,
|
||||
description:
|
||||
'Granted on top of the preset, as `relation:kind`. A selector-scoped check matches its kind. With Access on `custom` this is the whole list.',
|
||||
options: PERMISSION_OPTIONS,
|
||||
value: [],
|
||||
}),
|
||||
authzState: choiceControl<ResponseState>('Check state', {
|
||||
group: ACCESS,
|
||||
description:
|
||||
'How `authz/check` answers, the way the dev modal can force it.',
|
||||
options: RESPONSE_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
},
|
||||
handlers: ({ access, permissions, authzState }) => {
|
||||
const granted = accessFor(access, permissions);
|
||||
|
||||
return [
|
||||
rest.post(
|
||||
AUTHZ_CHECK_URL,
|
||||
respondWith(authzState, async (req) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
|
||||
return authzMockResponse(
|
||||
payload,
|
||||
payload.map((transaction) => granted.allows(transaction)),
|
||||
);
|
||||
}),
|
||||
),
|
||||
];
|
||||
},
|
||||
role: ({ access, permissions }) => accessFor(access, permissions).legacyRole,
|
||||
// Overrides persist in localStorage, so a leftover one from a real dev session
|
||||
// would silently answer for the controls panel.
|
||||
effect: ({ access }) => {
|
||||
if (access !== 'dev-tools') {
|
||||
clearAllAuthZDevOverrides();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export type AuthzArgs = StoryMockArgs<typeof authzMocks>;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { composeStoryMocks } from '../controls/composeStoryMocks';
|
||||
import { appShellMocks } from './appShellMocks';
|
||||
import { authzMocks } from './authzMocks';
|
||||
|
||||
/**
|
||||
* The mock modules every story carries, page or component, declared at project
|
||||
* level in `.storybook/preview.tsx`. Adding one here publishes its controls and
|
||||
* widens `PageStoryArgs` in the same edit.
|
||||
*/
|
||||
export const globalMocks = composeStoryMocks(authzMocks, appShellMocks);
|
||||
|
||||
export type GlobalMockArgs = typeof globalMocks.args;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { fn } from 'storybook/test';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
|
||||
export const createStoryAppContext = (
|
||||
role: string,
|
||||
overrides?: Partial<IAppContext>,
|
||||
): IAppContext => createAppContextMock(role, overrides, () => fn());
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Replaces `constants/env` in Storybook (aliased in `.storybook/main.ts`).
|
||||
*
|
||||
* The base URL must stay `http://localhost` so the msw handlers shared with
|
||||
* jest (`src/mocks-server/handlers.ts`), which are declared against that
|
||||
* origin, match requests issued from the Storybook iframe. msw intercepts
|
||||
* before the request leaves the page, so the cross-origin URL never hits the
|
||||
* network and CORS never applies.
|
||||
*
|
||||
* The annotation checks the module's shape against the real one, so a value
|
||||
* added to `constants/env` fails to compile here rather than at render.
|
||||
*/
|
||||
const libEnv: typeof import('constants/env') = {
|
||||
ENVIRONMENT: {
|
||||
baseURL: 'http://localhost',
|
||||
wsURL: 'ws://localhost',
|
||||
},
|
||||
};
|
||||
|
||||
export const { ENVIRONMENT } = libEnv;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
/**
|
||||
* Replaces `api/common/logEvent` in Storybook (aliased in `.storybook/main.ts`)
|
||||
* so analytics never leave the iframe. Stories can assert on the calls:
|
||||
* `import logEvent from 'api/common/logEvent'` then `expect(logEvent)...`.
|
||||
*
|
||||
* The annotation checks the module's shape against the real one, so a change to
|
||||
* `logEvent`'s signature fails to compile here rather than at render.
|
||||
*/
|
||||
const libLogEvent: typeof import('api/common/logEvent') = {
|
||||
default: fn(async () => ({
|
||||
statusCode: 200 as const,
|
||||
error: null,
|
||||
message: 'success',
|
||||
payload: { status: 'success', data: '' },
|
||||
})).mockName('logEvent'),
|
||||
};
|
||||
|
||||
export default libLogEvent.default;
|
||||
@@ -1,105 +0,0 @@
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import type { UserPreference } from 'types/api/preferences/preference';
|
||||
|
||||
/**
|
||||
* Wire-shaped payloads for the endpoints the app shell calls on every route.
|
||||
* Shapes follow the fields the components read, not the full generated DTOs.
|
||||
*/
|
||||
|
||||
export const baseUserPreferences: UserPreference[] = [
|
||||
{
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
description: 'Keep the side navigation pinned open',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['user'],
|
||||
value: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const userPreferencesResponse = (
|
||||
preferences: UserPreference[] = baseUserPreferences,
|
||||
): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: preferences,
|
||||
});
|
||||
|
||||
export const zeusHostsResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
hosts: [{ url: 'https://ingest.us.signoz.cloud:443', is_default: true }],
|
||||
},
|
||||
};
|
||||
|
||||
export const versionResponse = {
|
||||
version: 'v0.99.0',
|
||||
ee: 'Y',
|
||||
setupCompleted: true,
|
||||
};
|
||||
|
||||
export const latestGithubReleaseResponse = {
|
||||
tag_name: 'v0.99.0',
|
||||
name: 'v0.99.0',
|
||||
html_url: 'https://github.com/SigNoz/signoz/releases/tag/v0.99.0',
|
||||
};
|
||||
|
||||
export const globalConfigResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
ai_assistant_url: null,
|
||||
external_url: 'https://storybook.signoz.local',
|
||||
ingestion_url: 'https://ingest.us.signoz.cloud:443',
|
||||
mcp_url: null,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `ChangelogSchema` for the current version. Kept non-empty because
|
||||
* `getChangelogByVersion` treats an empty list as a failure, and media is left
|
||||
* null so no story reaches out for an image.
|
||||
*/
|
||||
export const changelogResponse = {
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
documentId: 'changelog-v0-99-0',
|
||||
version: 'v0.99.0',
|
||||
release_date: '2026-08-12',
|
||||
bug_fixes:
|
||||
'Fixed dashboard variables losing their selection on refresh.\nFixed alert history pagination.',
|
||||
maintenance: 'Upgraded the query service to Go 1.24.',
|
||||
createdAt: '2026-08-12T09:00:00.000Z',
|
||||
updatedAt: '2026-08-12T09:00:00.000Z',
|
||||
publishedAt: '2026-08-12T09:00:00.000Z',
|
||||
features: [
|
||||
{
|
||||
id: 11,
|
||||
documentId: 'feature-metrics-explorer',
|
||||
title: 'Metrics explorer',
|
||||
sort_order: 1,
|
||||
createdAt: '2026-08-12T09:00:00.000Z',
|
||||
updatedAt: '2026-08-12T09:00:00.000Z',
|
||||
publishedAt: '2026-08-12T09:00:00.000Z',
|
||||
description:
|
||||
'Browse every metric you send, inspect its labels and turn it into a panel without writing a query.',
|
||||
deployment_type: 'All',
|
||||
media: null,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
documentId: 'feature-trace-funnels',
|
||||
title: 'Trace funnels',
|
||||
sort_order: 2,
|
||||
createdAt: '2026-08-12T09:00:00.000Z',
|
||||
updatedAt: '2026-08-12T09:00:00.000Z',
|
||||
publishedAt: '2026-08-12T09:00:00.000Z',
|
||||
description:
|
||||
'Measure conversion and drop-off across a multi-service request path.',
|
||||
deployment_type: 'All',
|
||||
media: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { MetricRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
/** Typed builders for the query_range v5 response shapes. */
|
||||
|
||||
export const queryRangeV5ScalarResponse = (
|
||||
value: number,
|
||||
queryName = 'A',
|
||||
): MetricRangePayloadV5 => ({
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
name: '__result_0',
|
||||
queryName,
|
||||
aggregationIndex: 0,
|
||||
columnType: 'aggregation',
|
||||
},
|
||||
],
|
||||
data: [[value]],
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
},
|
||||
});
|
||||
|
||||
export const queryRangeV5EmptyResponse = (
|
||||
queryName = 'A',
|
||||
): MetricRangePayloadV5 => ({
|
||||
data: {
|
||||
type: 'raw',
|
||||
data: {
|
||||
results: [{ queryName, nextCursor: '', rows: [] }],
|
||||
},
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
},
|
||||
});
|
||||
|
||||
export const queryRangeV5RawResponse = <T>(
|
||||
rows: Array<{ timestamp: string; data: T }>,
|
||||
options: { queryName?: string; hasMore?: boolean } = {},
|
||||
): MetricRangePayloadV5 => {
|
||||
const { queryName = 'A', hasMore = false } = options;
|
||||
|
||||
return {
|
||||
data: {
|
||||
type: 'raw',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName,
|
||||
nextCursor: hasMore ? 'next-cursor-token' : '',
|
||||
rows,
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
rowsScanned: rows.length,
|
||||
bytesScanned: 0,
|
||||
durationMs: 0,
|
||||
stepIntervals: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
import {
|
||||
changelogResponse,
|
||||
globalConfigResponse,
|
||||
latestGithubReleaseResponse,
|
||||
userPreferencesResponse,
|
||||
versionResponse,
|
||||
zeusHostsResponse,
|
||||
} from './__mockdata__/appShell';
|
||||
|
||||
/**
|
||||
* Endpoints the app shell hits on every route that the jest handlers in
|
||||
* `src/mocks-server/handlers.ts` either do not cover or answer with fixtures
|
||||
* too thin to show the shell doing its job. Resolved ahead of the shared set,
|
||||
* and a page's own control-driven handlers are resolved ahead of these.
|
||||
*/
|
||||
export const appShellHandlers = [
|
||||
rest.get('http://localhost/api/v1/user/preferences', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(userPreferencesResponse())),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v1/user/preferences/:name', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v2/zeus/hosts', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(zeusHostsResponse)),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/global/config', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(globalConfigResponse)),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/version', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(versionResponse)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'https://api.github.com/repos/signoz/signoz/releases/latest',
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(latestGithubReleaseResponse)),
|
||||
),
|
||||
|
||||
rest.get('https://cms.signoz.cloud/api/release-changelogs', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(changelogResponse)),
|
||||
),
|
||||
];
|
||||
@@ -1,36 +0,0 @@
|
||||
import { rest } from 'msw';
|
||||
import { handlers as sharedHandlers } from 'mocks-server/handlers';
|
||||
|
||||
import { appShellHandlers } from './appShellHandlers';
|
||||
|
||||
/**
|
||||
* Last resort: every axios instance is built on `ENVIRONMENT.baseURL`, which
|
||||
* `mocks/env.mock.ts` pins to `http://localhost`, so an endpoint nobody mocked
|
||||
* lands here instead of leaving the browser. Failing loudly beats a request
|
||||
* that hangs until the connection is refused.
|
||||
*/
|
||||
const unmockedApiGuard = [
|
||||
rest.all('http://localhost/api/*', (req, res, ctx) => {
|
||||
console.error(
|
||||
`[storybook] no msw handler for ${req.method} ${req.url.pathname}. Add one to the page's mocks or to src/storybook/msw/appShellHandlers.ts`,
|
||||
);
|
||||
|
||||
return res(
|
||||
ctx.status(501),
|
||||
ctx.json({ status: 'error', error: 'not mocked in Storybook' }),
|
||||
);
|
||||
}),
|
||||
];
|
||||
|
||||
/**
|
||||
* Default handler set for every story, resolved first match wins: the
|
||||
* Storybook-only shell handlers override the jest ones where the shell needs
|
||||
* richer data, and both a page's control-driven handlers and a story's own
|
||||
* `parameters.msw.handlers` are layered on top at render time. An endpoint both
|
||||
* runners need belongs in `src/mocks-server/handlers.ts` instead.
|
||||
*/
|
||||
export const storybookHandlers = [
|
||||
...appShellHandlers,
|
||||
...sharedHandlers,
|
||||
...unmockedApiGuard,
|
||||
];
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { RequestHandler } from 'msw';
|
||||
|
||||
/**
|
||||
* Handlers may be grouped under names, so unrelated overrides in the same story
|
||||
* stay readable.
|
||||
*/
|
||||
export type StoryMswParameter =
|
||||
| RequestHandler[]
|
||||
| {
|
||||
handlers?: RequestHandler[] | Record<string, RequestHandler[] | undefined>;
|
||||
};
|
||||
|
||||
export const collectStoryHandlers = (
|
||||
msw: StoryMswParameter | undefined,
|
||||
): RequestHandler[] => {
|
||||
if (!msw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(msw)) {
|
||||
return msw;
|
||||
}
|
||||
|
||||
const { handlers } = msw;
|
||||
|
||||
if (!handlers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.isArray(handlers)
|
||||
? handlers
|
||||
: Object.values(handlers)
|
||||
.filter((group): group is RequestHandler[] => Boolean(group))
|
||||
.flat();
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import type {
|
||||
DefaultBodyType,
|
||||
PathParams,
|
||||
ResponseResolver,
|
||||
RestContext,
|
||||
RestRequest,
|
||||
} from 'msw';
|
||||
|
||||
export type MockRequest = RestRequest<DefaultBodyType, PathParams>;
|
||||
|
||||
export type MockResolver = ResponseResolver<
|
||||
MockRequest,
|
||||
RestContext,
|
||||
DefaultBodyType
|
||||
>;
|
||||
|
||||
/**
|
||||
* Resolver factory handed to a mock module's `handlers`. Endpoints declared
|
||||
* through it follow the response state, so one declaration covers the loaded,
|
||||
* loading and failed states. Endpoints that have to answer for the page to
|
||||
* render at all (ingestion detection, preferences) take a plain resolver
|
||||
* instead.
|
||||
*/
|
||||
export interface MockResponse {
|
||||
json: <TBody>(
|
||||
build: (req: MockRequest) => TBody | Promise<TBody>,
|
||||
) => MockResolver;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
right: var(--spacing-8);
|
||||
bottom: var(--spacing-8);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
max-width: 420px;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-6) var(--spacing-7);
|
||||
border: 1px solid var(--accent-amber);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--base-black) 45%, transparent);
|
||||
color: var(--l1-foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: var(--spacing-1) var(--spacing-4);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
max-height: 160px;
|
||||
padding-left: var(--spacing-8);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
margin-bottom: var(--spacing-1);
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { clearBlockedNavigations } from './blockedNavigationStore';
|
||||
import { useBlockedNavigations } from './useBlockedNavigations';
|
||||
|
||||
import styles from './NavigationBlockedOverlay.module.scss';
|
||||
|
||||
/**
|
||||
* Surfaces every navigation the story swallowed. Rendered by `withProviders`,
|
||||
* so any story that tries to leave the page says so instead of silently
|
||||
* doing nothing.
|
||||
*/
|
||||
function NavigationBlockedOverlay(): JSX.Element | null {
|
||||
const blockedNavigations = useBlockedNavigations();
|
||||
|
||||
if (blockedNavigations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={styles.overlay}
|
||||
aria-live="polite"
|
||||
data-testid="navigation-blocked-overlay"
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text as="span" size="small" weight="semibold" color="warning">
|
||||
Navigation blocked in Storybook
|
||||
</Typography.Text>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.button}
|
||||
onClick={clearBlockedNavigations}
|
||||
data-testid="navigation-blocked-clear"
|
||||
>
|
||||
<Typography.Text as="span" size="small" weight="medium">
|
||||
clear
|
||||
</Typography.Text>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className={styles.list}>
|
||||
{blockedNavigations.map((navigation) => (
|
||||
<li key={navigation.id} className={styles.item}>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
{navigation.via}
|
||||
</Typography.Text>{' '}
|
||||
<Typography.Text as="span" size="small">
|
||||
→ {navigation.to}
|
||||
</Typography.Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavigationBlockedOverlay;
|
||||
@@ -1,40 +0,0 @@
|
||||
export interface BlockedNavigation {
|
||||
id: number;
|
||||
/** History method the app called, e.g. `push`, `replace`, `window.open`. */
|
||||
via: string;
|
||||
/** Target the app tried to reach, already resolved to an href. */
|
||||
to: string;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let blockedNavigations: BlockedNavigation[] = [];
|
||||
let nextId = 1;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const emit = (): void => {
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const subscribeToBlockedNavigations = (
|
||||
listener: Listener,
|
||||
): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return (): void => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const getBlockedNavigations = (): BlockedNavigation[] =>
|
||||
blockedNavigations;
|
||||
|
||||
export const recordBlockedNavigation = (via: string, to: string): void => {
|
||||
blockedNavigations = [...blockedNavigations, { id: nextId, via, to }];
|
||||
nextId += 1;
|
||||
emit();
|
||||
};
|
||||
|
||||
export const clearBlockedNavigations = (): void => {
|
||||
blockedNavigations = [];
|
||||
emit();
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
import {
|
||||
History,
|
||||
LocationDescriptor,
|
||||
LocationDescriptorObject,
|
||||
parsePath,
|
||||
} from 'history';
|
||||
import { fn, type Mock } from 'storybook/test';
|
||||
|
||||
import { recordBlockedNavigation } from './blockedNavigationStore';
|
||||
import { navigateWithinPage, storyHistory, toHref } from './pageScope';
|
||||
|
||||
const guardedNavigate = (
|
||||
via: 'push' | 'replace',
|
||||
): Mock<(to: LocationDescriptor, state?: unknown) => void> =>
|
||||
fn((to: LocationDescriptor, state?: unknown): void => {
|
||||
const target: LocationDescriptorObject =
|
||||
typeof to === 'string' ? { ...parsePath(to), state } : { state, ...to };
|
||||
|
||||
if (navigateWithinPage(target, { replace: via === 'replace' })) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordBlockedNavigation(via, toHref(to));
|
||||
}).mockName(`history.${via}`);
|
||||
|
||||
const blockedRelativeNavigate = (via: string): Mock<(delta?: number) => void> =>
|
||||
fn((delta?: number): void => {
|
||||
recordBlockedNavigation(via, delta === undefined ? via : `${via}(${delta})`);
|
||||
}).mockName(`history.${via}`);
|
||||
|
||||
const overriddenMethods = {
|
||||
push: guardedNavigate('push'),
|
||||
replace: guardedNavigate('replace'),
|
||||
go: blockedRelativeNavigate('go'),
|
||||
goBack: blockedRelativeNavigate('goBack'),
|
||||
goForward: blockedRelativeNavigate('goForward'),
|
||||
} as const;
|
||||
|
||||
type OverriddenMethod = keyof typeof overriddenMethods;
|
||||
|
||||
const isOverriddenMethod = (prop: string | symbol): prop is OverriddenMethod =>
|
||||
typeof prop === 'string' && prop in overriddenMethods;
|
||||
|
||||
/**
|
||||
* What the app sees in place of `lib/history`. Reads (`location`, `action`,
|
||||
* `listen`) are proxied to the story's memory history so react-router renders
|
||||
* normally; navigation goes through `pageScope`, and whatever would leave the
|
||||
* page is swallowed and reported to `blockedNavigationStore`.
|
||||
* `react-router-dom-v5-compat` drives its `useNavigate` through this same
|
||||
* object, so `useSafeNavigate` is covered too.
|
||||
*/
|
||||
export const containedHistory: History = new Proxy(storyHistory, {
|
||||
get(target, prop, receiver) {
|
||||
if (isOverriddenMethod(prop)) {
|
||||
return overriddenMethods[prop];
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
export const hasInAppHistory = (): boolean => false;
|
||||
|
||||
export const resetStoryHistory = (): void => {
|
||||
Object.values(overriddenMethods).forEach((method) => method.mockClear());
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import {
|
||||
containedHistory,
|
||||
hasInAppHistory as containedHasInAppHistory,
|
||||
} from './containment';
|
||||
|
||||
/**
|
||||
* Replaces `lib/history` in Storybook (aliased in `.storybook/main.ts`). The
|
||||
* containment rule lives in `containment.ts`; this file only has to keep the
|
||||
* module's shape, and the annotation is what checks it against the real one.
|
||||
* An export added to `lib/history` fails to compile here instead of failing at
|
||||
* render in whichever component imports it.
|
||||
*/
|
||||
const libHistory: typeof import('lib/history') = {
|
||||
default: containedHistory,
|
||||
hasInAppHistory: containedHasInAppHistory,
|
||||
};
|
||||
|
||||
export default libHistory.default;
|
||||
|
||||
export const { hasInAppHistory } = libHistory;
|
||||
@@ -1,55 +0,0 @@
|
||||
import { recordBlockedNavigation } from './blockedNavigationStore';
|
||||
import {
|
||||
isBlockableHref,
|
||||
navigateWithinPage,
|
||||
toStoryLocation,
|
||||
} from './pageScope';
|
||||
|
||||
/**
|
||||
* Takes over the navigations that never reach the story's history: plain anchors
|
||||
* and `window.open` (used by `useSafeNavigate` for `newTab`). An anchor staying
|
||||
* on the story's page is applied, because letting the browser follow it would
|
||||
* navigate the iframe away and unmount the story. Anything else is reported as
|
||||
* blocked. Returns the teardown.
|
||||
*/
|
||||
export const interceptExternalNavigation = (): (() => void) => {
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
const target = event.target as Element | null;
|
||||
const anchor = target?.closest?.('a[href]');
|
||||
|
||||
if (!anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = anchor.getAttribute('href') ?? '';
|
||||
|
||||
if (!isBlockableHref(href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ahead of react-router's own `Link` handler, which skips a click that is
|
||||
// already handled, so an in-page link is never pushed twice.
|
||||
event.preventDefault();
|
||||
|
||||
const to = toStoryLocation(href, window.location.href);
|
||||
|
||||
if (to && navigateWithinPage(to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordBlockedNavigation('link', href);
|
||||
};
|
||||
|
||||
document.addEventListener('click', onClick, true);
|
||||
|
||||
const originalOpen = window.open;
|
||||
window.open = (url?: string | URL): null => {
|
||||
recordBlockedNavigation('window.open', String(url ?? ''));
|
||||
return null;
|
||||
};
|
||||
|
||||
return (): void => {
|
||||
document.removeEventListener('click', onClick, true);
|
||||
window.open = originalOpen;
|
||||
};
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import {
|
||||
createMemoryHistory,
|
||||
LocationDescriptor,
|
||||
MemoryHistory,
|
||||
parsePath,
|
||||
} from 'history';
|
||||
|
||||
/**
|
||||
* The story's own history. A story renders one page, so this never leaves it:
|
||||
* `pageScope` decides what counts as staying, and `containment.ts` is what the
|
||||
* app sees in place of `lib/history`.
|
||||
*/
|
||||
export const storyHistory: MemoryHistory = createMemoryHistory({
|
||||
initialEntries: ['/'],
|
||||
});
|
||||
|
||||
export const toHref = (to: LocationDescriptor): string =>
|
||||
typeof to === 'string' ? to : storyHistory.createHref(to);
|
||||
|
||||
/** `/home/` and `/home` are the same page as far as a story is concerned. */
|
||||
const normalizePathname = (pathname: string): string =>
|
||||
pathname.length > 1 && pathname.endsWith('/')
|
||||
? pathname.slice(0, -1)
|
||||
: pathname;
|
||||
|
||||
export const isSamePagePathname = (pathname: string | undefined): boolean =>
|
||||
!pathname ||
|
||||
normalizePathname(pathname) ===
|
||||
normalizePathname(storyHistory.location.pathname);
|
||||
|
||||
/**
|
||||
* Applies a navigation that stays on the story's page: a query-param or hash
|
||||
* change, which is how tabs, filters and pagination are driven. Returns false
|
||||
* when the target is another page, leaving the caller to report it as blocked.
|
||||
*/
|
||||
export const navigateWithinPage = (
|
||||
to: LocationDescriptor,
|
||||
{ replace = false }: { replace?: boolean } = {},
|
||||
): boolean => {
|
||||
const target = typeof to === 'string' ? parsePath(to) : to;
|
||||
|
||||
if (!isSamePagePathname(target.pathname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
storyHistory[replace ? 'replace' : 'push']({
|
||||
...target,
|
||||
pathname: storyHistory.location.pathname,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Places the story at a route without going through the block. */
|
||||
export const setStoryLocation = (to: LocationDescriptor): void => {
|
||||
storyHistory.replace(to);
|
||||
};
|
||||
|
||||
/**
|
||||
* An in-page anchor or a `javascript:` href is the browser's business, not the
|
||||
* story's: it is left alone rather than applied or reported.
|
||||
*/
|
||||
export const isBlockableHref = (href: string): boolean =>
|
||||
href.length > 0 && !href.startsWith('#') && !href.startsWith('javascript:');
|
||||
|
||||
/**
|
||||
* An anchor href as a location the story's history understands, or `undefined`
|
||||
* when it leads off the page. Relative hrefs (`?tab=logs`) carry no pathname and
|
||||
* stay on the page; app links do, and are resolved against the iframe so an
|
||||
* off-site href fails the host check before its path is compared.
|
||||
*/
|
||||
export const toStoryLocation = (
|
||||
href: string,
|
||||
base: string,
|
||||
): string | undefined => {
|
||||
if (href.startsWith('?')) {
|
||||
return href;
|
||||
}
|
||||
|
||||
const url = new URL(href, base);
|
||||
|
||||
return url.host === new URL(base).host
|
||||
? `${url.pathname}${url.search}${url.hash}`
|
||||
: undefined;
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import {
|
||||
BlockedNavigation,
|
||||
getBlockedNavigations,
|
||||
subscribeToBlockedNavigations,
|
||||
} from './blockedNavigationStore';
|
||||
|
||||
export const useBlockedNavigations = (): BlockedNavigation[] =>
|
||||
useSyncExternalStore(subscribeToBlockedNavigations, getBlockedNavigations);
|
||||
@@ -1,92 +0,0 @@
|
||||
import { ReactNode, useEffect, useMemo } from 'react';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import AppHarness from '@/harness/AppHarness';
|
||||
import history from 'lib/history';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import { createStoryAppContext } from '../mocks/createStoryAppContext';
|
||||
import { interceptExternalNavigation } from '../navigation/interceptExternalNavigation';
|
||||
import NavigationBlockedOverlay from '../navigation/NavigationBlockedOverlay';
|
||||
import { ResolvedStoryConfig } from '../types';
|
||||
import { createStorybookQueryClient } from './createStorybookQueryClient';
|
||||
import { createStorybookStore } from './createStorybookStore';
|
||||
import { useStoryRoute } from './useStoryRoute';
|
||||
|
||||
interface StorybookProvidersProps extends ResolvedStoryConfig {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the role `useAppContext()` yields to `<body data-signoz-context-role>`,
|
||||
* next to the `data-signoz-story-role` the runtime resolved. Both come from the
|
||||
* same access grant, so a disagreement means the story is reading a different
|
||||
* `AppContext` than the one the runtime filled.
|
||||
*/
|
||||
function StoryContextProbe(): null {
|
||||
const { user } = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
document.body.dataset.signozContextRole = user?.role ?? '';
|
||||
}, [user?.role]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Storybook adapter over `AppHarness`: the app's provider tree, with the
|
||||
* router on the contained history, nuqs on its testing adapter, and a fresh
|
||||
* store and query cache per story.
|
||||
*/
|
||||
function StorybookProviders({
|
||||
children,
|
||||
role,
|
||||
appContext,
|
||||
queryBuilder,
|
||||
route = '/',
|
||||
reduxState,
|
||||
}: StorybookProvidersProps): JSX.Element {
|
||||
const searchParams = useStoryRoute(route);
|
||||
const queryClient = useMemo(createStorybookQueryClient, []);
|
||||
const store = useMemo(() => createStorybookStore(reduxState), [reduxState]);
|
||||
const appContextValue = useMemo(
|
||||
() => createStoryAppContext(role, appContext),
|
||||
[role, appContext],
|
||||
);
|
||||
|
||||
useEffect(interceptExternalNavigation, []);
|
||||
|
||||
return (
|
||||
<AppHarness
|
||||
appContext={appContextValue}
|
||||
store={store}
|
||||
queryClient={queryClient}
|
||||
queryBuilder={queryBuilder}
|
||||
router={(routed): ReactNode => (
|
||||
<Router history={history}>
|
||||
<CompatRouter>{routed}</CompatRouter>
|
||||
</Router>
|
||||
)}
|
||||
searchParams={(scoped): ReactNode => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
{scoped}
|
||||
</NuqsTestingAdapter>
|
||||
)}
|
||||
overlays={
|
||||
<>
|
||||
<StoryContextProbe />
|
||||
{/* The AuthZ dev modal and its floating indicator, so a story can
|
||||
override single permissions by hand. */}
|
||||
<CmdKPalette userRole={role} />
|
||||
<NavigationBlockedOverlay />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AppHarness>
|
||||
);
|
||||
}
|
||||
|
||||
export default StorybookProviders;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
|
||||
import type { StoryTheme } from '../types';
|
||||
|
||||
export const applyThemeBodyClass = (theme: StoryTheme): void => {
|
||||
const isDarkMode = theme === THEME_MODE.DARK;
|
||||
|
||||
document.body.dataset.theme = 'default';
|
||||
document.body.classList.toggle('darkMode', isDarkMode);
|
||||
document.body.classList.toggle('dark', isDarkMode);
|
||||
document.body.classList.toggle('lightMode', !isDarkMode);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { QueryClient } from 'react-query';
|
||||
|
||||
/**
|
||||
* One client per story: retries off so a deliberately failing handler renders
|
||||
* its error state immediately, and no cache carried over between stories.
|
||||
*/
|
||||
export const createStorybookQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
applyMiddleware,
|
||||
legacy_createStore as createStore,
|
||||
Store,
|
||||
} from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import reducers, { AppState } from 'store/reducers';
|
||||
|
||||
/**
|
||||
* A fresh store per story, seeded with the real reducers so dispatches keep
|
||||
* working, unlike the mock store used in jest. Nothing leaks between stories.
|
||||
*/
|
||||
export const createStorybookStore = (reduxState?: Partial<AppState>): Store =>
|
||||
createStore(
|
||||
reducers,
|
||||
reduxState as ReturnType<typeof reducers> | undefined,
|
||||
applyMiddleware(thunk),
|
||||
);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { setStoryLocation } from '../navigation/pageScope';
|
||||
|
||||
/**
|
||||
* Places the story's history at its route before the router mounts and hands
|
||||
* back the search params for the nuqs testing adapter.
|
||||
*/
|
||||
export const useStoryRoute = (route: string): URLSearchParams =>
|
||||
useMemo(() => {
|
||||
setStoryLocation(route);
|
||||
const [, search = ''] = route.split('?');
|
||||
return new URLSearchParams(search);
|
||||
}, [route]);
|
||||
@@ -1,173 +0,0 @@
|
||||
import type { RequestHandler, SetupWorker } from 'msw';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import type { AnyStoryMocks, StoryMockArgs } from '../controls/types';
|
||||
import { globalMocks, type GlobalMockArgs } from '../globals';
|
||||
import { storybookHandlers } from '../msw/handlers';
|
||||
import { collectStoryHandlers } from '../msw/storyHandlers';
|
||||
import type { MockResolver, MockResponse } from '../msw/types';
|
||||
import { applyThemeBodyClass } from '../providers/applyThemeBodyClass';
|
||||
import type {
|
||||
ResolvedStoryConfig,
|
||||
SignozStoryConfig,
|
||||
SignozStoryParameters,
|
||||
StoryOwnedConfig,
|
||||
StoryRole,
|
||||
StoryTheme,
|
||||
} from '../types';
|
||||
import { respondWith, type ResponseState } from './responseState';
|
||||
|
||||
/** Args of a page story: its own controls plus the ones every story carries. */
|
||||
export type PageStoryArgs<TMocks extends AnyStoryMocks> = GlobalMockArgs &
|
||||
StoryMockArgs<TMocks>;
|
||||
|
||||
/**
|
||||
* What Storybook hands both the loader and the decorator. Declared structurally,
|
||||
* so the runtime does not depend on which lifecycle hook is calling it.
|
||||
*/
|
||||
export interface StoryRuntimeContext {
|
||||
id: string;
|
||||
parameters: SignozStoryParameters;
|
||||
args: Record<string, unknown>;
|
||||
globals?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StoryWorld {
|
||||
config: ResolvedStoryConfig;
|
||||
theme: StoryTheme;
|
||||
/**
|
||||
* Changes with every control a mock reads. The provider tree is keyed on it so
|
||||
* the story remounts with a fresh query cache instead of showing what the
|
||||
* previous control values resolved to.
|
||||
*/
|
||||
key: string;
|
||||
/** In resolution order: msw answers with the first handler that matches. */
|
||||
handlers: RequestHandler[];
|
||||
install(worker: SetupWorker): void;
|
||||
/**
|
||||
* Everything that has to be in place before the provider tree mounts:
|
||||
* module-level app state, the theme `ThemeProvider` reads at boot, and the
|
||||
* `<body>` markers that say what the controls resolved to.
|
||||
*/
|
||||
apply(): void;
|
||||
}
|
||||
|
||||
const withoutMocks = (config: SignozStoryConfig): StoryOwnedConfig => {
|
||||
const owned = { ...config };
|
||||
delete (owned as SignozStoryConfig).mocks;
|
||||
return owned;
|
||||
};
|
||||
|
||||
const mergeConfigs = (configs: StoryOwnedConfig[]): StoryOwnedConfig =>
|
||||
configs.reduce(
|
||||
(merged, config) => ({
|
||||
...merged,
|
||||
...config,
|
||||
appContext: { ...merged.appContext, ...config.appContext },
|
||||
}),
|
||||
{} as StoryOwnedConfig,
|
||||
);
|
||||
|
||||
const createMockResponse = (state: ResponseState): MockResponse => ({
|
||||
json: (build): MockResolver => respondWith(state, build),
|
||||
});
|
||||
|
||||
const firstAnswer = <TAnswer>(
|
||||
answers: (TAnswer | undefined)[],
|
||||
fallback: TAnswer,
|
||||
): TAnswer =>
|
||||
answers.find((answer): answer is TAnswer => answer !== undefined) ?? fallback;
|
||||
|
||||
const resolveWorld = (context: StoryRuntimeContext): StoryWorld => {
|
||||
const { parameters, args } = context;
|
||||
const storyConfig = parameters.signoz ?? {};
|
||||
|
||||
// A page's own mocks resolve ahead of the global ones, so the page wins every
|
||||
// question both answer.
|
||||
const members: AnyStoryMocks[] = storyConfig.mocks
|
||||
? [storyConfig.mocks, ...globalMocks.members]
|
||||
: [...globalMocks.members];
|
||||
|
||||
const values = members.map((mocks) => mocks.read(args));
|
||||
|
||||
const response = createMockResponse(
|
||||
firstAnswer(
|
||||
members.map((mocks, index) => mocks.responseState?.(values[index])),
|
||||
'loaded',
|
||||
),
|
||||
);
|
||||
|
||||
const role = firstAnswer(
|
||||
members.map((mocks, index) => mocks.role?.(values[index])),
|
||||
USER_ROLES.ADMIN as StoryRole,
|
||||
);
|
||||
|
||||
const config = mergeConfigs([
|
||||
// Reversed, so a page's own config wins over the global one, and the
|
||||
// story's own `parameters.signoz` is the last word over both.
|
||||
...members
|
||||
.map((mocks, index) => mocks.config?.(values[index]) ?? {})
|
||||
.reverse(),
|
||||
withoutMocks(storyConfig),
|
||||
]);
|
||||
|
||||
const theme = config.theme ?? (context.globals?.theme as StoryTheme) ?? 'dark';
|
||||
|
||||
const valuesKey = JSON.stringify(values);
|
||||
|
||||
const handlers = [
|
||||
// A story that declares its own handler always wins.
|
||||
...collectStoryHandlers(parameters.msw),
|
||||
...members.flatMap(
|
||||
(mocks, index) => mocks.handlers?.(values[index], response) ?? [],
|
||||
),
|
||||
// Shell endpoints, the jest handlers, then the catch-all that logs.
|
||||
...storybookHandlers,
|
||||
];
|
||||
|
||||
return {
|
||||
config: { ...config, role },
|
||||
theme,
|
||||
key: `${theme}|${valuesKey}`,
|
||||
handlers,
|
||||
install: (worker): void => {
|
||||
worker.resetHandlers(...handlers);
|
||||
},
|
||||
apply: (): void => {
|
||||
members.forEach((mocks, index) => mocks.effect?.(values[index]));
|
||||
|
||||
// `ThemeProvider` seeds its state from localStorage, so the value has to
|
||||
// be in place before it mounts; `key` forces the remount on a change.
|
||||
set(LOCALSTORAGE.THEME, theme);
|
||||
applyThemeBodyClass(theme);
|
||||
|
||||
// Readable from the Elements panel, so what the controls resolved to can
|
||||
// be checked without reaching into the story store.
|
||||
document.body.dataset.signozStoryRole = role;
|
||||
document.body.dataset.signozStoryMocks = valuesKey;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
let memo: { signature: string; world: StoryWorld } | undefined;
|
||||
|
||||
/**
|
||||
* The single owner of "story context → the world the story renders in". Both the
|
||||
* preview loader and the provider decorator ask for the same story render, so
|
||||
* the result is memoised on what the story is and what its controls hold.
|
||||
*/
|
||||
export const resolveStory = (context: StoryRuntimeContext): StoryWorld => {
|
||||
const signature = JSON.stringify([
|
||||
context.id,
|
||||
context.args,
|
||||
context.globals?.theme,
|
||||
]);
|
||||
|
||||
if (memo?.signature !== signature) {
|
||||
memo = { signature, world: resolveWorld(context) };
|
||||
}
|
||||
|
||||
return memo.world;
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { DefaultBodyType } from 'msw';
|
||||
|
||||
import type { MockRequest, MockResolver } from '../msw/types';
|
||||
|
||||
export const RESPONSE_STATES = ['loaded', 'loading', 'error'] as const;
|
||||
|
||||
export type ResponseState = (typeof RESPONSE_STATES)[number];
|
||||
|
||||
/**
|
||||
* The three answers every mocked endpoint can give, in one place: the payload
|
||||
* the caller built, a request that never resolves, or a failure. A story reaches
|
||||
* all three by turning one control, and a fourth state added here reaches every
|
||||
* endpoint declared through it.
|
||||
*/
|
||||
export const respondWith =
|
||||
<TBody>(
|
||||
state: ResponseState,
|
||||
build: (req: MockRequest) => TBody | Promise<TBody>,
|
||||
): MockResolver =>
|
||||
async (req, res, ctx) => {
|
||||
if (state === 'loading') {
|
||||
return res(ctx.delay('infinite'));
|
||||
}
|
||||
|
||||
if (state === 'error') {
|
||||
return res(
|
||||
ctx.status(500),
|
||||
ctx.json({ status: 'error', error: 'storybook: forced failure' }),
|
||||
);
|
||||
}
|
||||
|
||||
return res(ctx.status(200), ctx.json((await build(req)) as DefaultBodyType));
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
/* `styles.scss` sizes `#root`; the Storybook preview mounts into
|
||||
`#storybook-root`, which needs the same box for `AppLayout` to lay out. */
|
||||
#storybook-root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { IAppContext } from 'providers/App/types';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
import type { ROLES } from 'types/roles';
|
||||
|
||||
import type { AnyStoryMocks } from './controls/types';
|
||||
import type { StoryMswParameter } from './msw/storyHandlers';
|
||||
|
||||
export type StoryTheme = 'dark' | 'light';
|
||||
|
||||
export type StoryRole = ROLES;
|
||||
|
||||
/** What a story or a mock module may set about the tree it renders in. */
|
||||
export interface StoryOwnedConfig {
|
||||
/** Deep-merged over the default mocked `AppContext` value. */
|
||||
appContext?: Partial<IAppContext>;
|
||||
/** When set, replaces `QueryBuilderProvider` with a fixed context value. */
|
||||
queryBuilder?: Partial<QueryBuilderContextType>;
|
||||
/** Initial route, search included, e.g. `/home?relativeTime=1h`. */
|
||||
route?: string;
|
||||
/** Overrides the theme toolbar for this story. */
|
||||
theme?: StoryTheme;
|
||||
/**
|
||||
* When set, the real redux store is seeded with this state instead of the
|
||||
* reducers' own initial state.
|
||||
*/
|
||||
reduxState?: Partial<AppState>;
|
||||
}
|
||||
|
||||
export interface SignozStoryConfig extends StoryOwnedConfig {
|
||||
/**
|
||||
* The page's control-driven mocks, declared with `defineStoryMocks` and
|
||||
* attached by spreading `storyMocks(...)` into the meta.
|
||||
*/
|
||||
mocks?: AnyStoryMocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the story runtime hands the provider tree. `role` is derived from the
|
||||
* Access controls rather than set by a story, so the mocked `AppContext` and the
|
||||
* mocked `authz/check` endpoint always answer from the same grant.
|
||||
*/
|
||||
export interface ResolvedStoryConfig extends StoryOwnedConfig {
|
||||
role: StoryRole;
|
||||
}
|
||||
|
||||
export interface SignozStoryParameters {
|
||||
signoz?: SignozStoryConfig;
|
||||
msw?: StoryMswParameter;
|
||||
}
|
||||
170
frontend/src/tests/fixtures/appContextMock.ts
vendored
170
frontend/src/tests/fixtures/appContextMock.ts
vendored
@@ -1,170 +0,0 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import {
|
||||
LicenseEvent,
|
||||
LicensePlatform,
|
||||
LicenseState,
|
||||
LicenseStatus,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
/**
|
||||
* Factory for the spies assigned to the callable members of `IAppContext`.
|
||||
* Jest passes `jest.fn`, Storybook passes `fn` from `storybook/test`; the
|
||||
* fixture itself stays free of any test-runner import so both can consume it.
|
||||
*/
|
||||
export type SpyFactory = () => (...args: unknown[]) => void;
|
||||
|
||||
const noopSpyFactory: SpyFactory = () => (): void => {};
|
||||
|
||||
export const defaultFeatureFlags = [
|
||||
{ name: FeatureKeys.SSO, active: true, usage: 0, usage_limit: -1, route: '' },
|
||||
{
|
||||
name: FeatureKeys.USE_SPAN_METRICS,
|
||||
active: false,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.GATEWAY,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.PREMIUM_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.CHAT_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
];
|
||||
|
||||
export function createAppContextMock(
|
||||
role: string,
|
||||
appContextOverrides?: Partial<IAppContext>,
|
||||
createSpy: SpyFactory = noopSpyFactory,
|
||||
): IAppContext {
|
||||
return {
|
||||
activeLicense: {
|
||||
key: 'test-key',
|
||||
event_queue: {
|
||||
created_at: '0',
|
||||
event: LicenseEvent.NO_EVENT,
|
||||
scheduled_at: '0',
|
||||
status: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
state: LicenseState.ACTIVATED,
|
||||
status: LicenseStatus.VALID,
|
||||
platform: LicensePlatform.CLOUD,
|
||||
created_at: '0',
|
||||
plan: {
|
||||
created_at: '0',
|
||||
description: '',
|
||||
is_active: true,
|
||||
name: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
plan_id: '0',
|
||||
free_until: '0',
|
||||
updated_at: '0',
|
||||
valid_from: 0,
|
||||
valid_until: 0,
|
||||
},
|
||||
trialInfo: {
|
||||
trialStart: -1,
|
||||
trialEnd: -1,
|
||||
onTrial: false,
|
||||
workSpaceBlock: false,
|
||||
trialConvertedToSubscription: false,
|
||||
gracePeriodEnd: -1,
|
||||
},
|
||||
isFetchingActiveLicense: false,
|
||||
activeLicenseFetchError: null,
|
||||
changelog: null,
|
||||
user: {
|
||||
accessJwt: 'some-token',
|
||||
refreshJwt: 'some-refresh-token',
|
||||
id: 'some-user-id',
|
||||
email: 'does-not-matter@signoz.io',
|
||||
displayName: 'John Doe',
|
||||
createdAt: 1732544623,
|
||||
organization: 'Nightswatch',
|
||||
orgId: 'does-not-matter-id',
|
||||
role: role as ROLES,
|
||||
},
|
||||
org: [
|
||||
{
|
||||
createdAt: 0,
|
||||
id: 'does-not-matter-id',
|
||||
displayName: 'Pentagon',
|
||||
},
|
||||
],
|
||||
hasEditPermission: role === USER_ROLES.ADMIN || role === USER_ROLES.EDITOR,
|
||||
isFetchingUser: false,
|
||||
userFetchError: null,
|
||||
featureFlags: defaultFeatureFlags,
|
||||
isFetchingFeatureFlags: false,
|
||||
featureFlagsFetchError: null,
|
||||
hostsData: null,
|
||||
isFetchingHosts: false,
|
||||
hostsFetchError: null,
|
||||
orgPreferences: [
|
||||
{
|
||||
name: ORG_PREFERENCES.ORG_ONBOARDING,
|
||||
description: 'Organisation Onboarding',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['org'],
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
userPreferences: [],
|
||||
updateUserPreferenceInContext: createSpy(),
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
orgPreferencesFetchError: null,
|
||||
isLoggedIn: true,
|
||||
isPreflightLoading: false,
|
||||
showChangelogModal: false,
|
||||
updateUser: createSpy(),
|
||||
updateOrg: createSpy(),
|
||||
updateOrgPreferences: createSpy(),
|
||||
activeLicenseRefetch: createSpy(),
|
||||
updateChangelog: createSpy(),
|
||||
toggleChangelogModal: createSpy(),
|
||||
versionData: {
|
||||
version: '1.0.0',
|
||||
ee: 'Y',
|
||||
setupCompleted: true,
|
||||
},
|
||||
|
||||
...appContextOverrides,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, RenderOptions, RenderResult } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
@@ -17,10 +19,16 @@ import {
|
||||
} from 'providers/QueryBuilder';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
import thunk from 'redux-thunk';
|
||||
import store from 'store';
|
||||
import {
|
||||
LicenseEvent,
|
||||
LicensePlatform,
|
||||
LicenseState,
|
||||
LicenseStatus,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
// import { MemoryRouter as V5MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
|
||||
// Mock ResizeObserver
|
||||
@@ -98,13 +106,154 @@ jest.mock('react-i18next', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
|
||||
export const defaultFeatureFlags = [
|
||||
{ name: FeatureKeys.SSO, active: true, usage: 0, usage_limit: -1, route: '' },
|
||||
{
|
||||
name: FeatureKeys.USE_SPAN_METRICS,
|
||||
active: false,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.GATEWAY,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.PREMIUM_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.CHAT_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
];
|
||||
|
||||
export function getAppContextMock(
|
||||
role: string,
|
||||
appContextOverrides?: Partial<IAppContext>,
|
||||
): IAppContext {
|
||||
return createAppContextMock(role, appContextOverrides, () => jest.fn());
|
||||
return {
|
||||
activeLicense: {
|
||||
key: 'test-key',
|
||||
event_queue: {
|
||||
created_at: '0',
|
||||
event: LicenseEvent.NO_EVENT,
|
||||
scheduled_at: '0',
|
||||
status: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
state: LicenseState.ACTIVATED,
|
||||
status: LicenseStatus.VALID,
|
||||
platform: LicensePlatform.CLOUD,
|
||||
created_at: '0',
|
||||
plan: {
|
||||
created_at: '0',
|
||||
description: '',
|
||||
is_active: true,
|
||||
name: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
plan_id: '0',
|
||||
free_until: '0',
|
||||
updated_at: '0',
|
||||
valid_from: 0,
|
||||
valid_until: 0,
|
||||
},
|
||||
trialInfo: {
|
||||
trialStart: -1,
|
||||
trialEnd: -1,
|
||||
onTrial: false,
|
||||
workSpaceBlock: false,
|
||||
trialConvertedToSubscription: false,
|
||||
gracePeriodEnd: -1,
|
||||
},
|
||||
isFetchingActiveLicense: false,
|
||||
activeLicenseFetchError: null,
|
||||
changelog: null,
|
||||
user: {
|
||||
accessJwt: 'some-token',
|
||||
refreshJwt: 'some-refresh-token',
|
||||
id: 'some-user-id',
|
||||
email: 'does-not-matter@signoz.io',
|
||||
displayName: 'John Doe',
|
||||
createdAt: 1732544623,
|
||||
organization: 'Nightswatch',
|
||||
orgId: 'does-not-matter-id',
|
||||
role: role as ROLES,
|
||||
},
|
||||
org: [
|
||||
{
|
||||
createdAt: 0,
|
||||
id: 'does-not-matter-id',
|
||||
displayName: 'Pentagon',
|
||||
},
|
||||
],
|
||||
hasEditPermission: role === USER_ROLES.ADMIN || role === USER_ROLES.EDITOR,
|
||||
isFetchingUser: false,
|
||||
userFetchError: null,
|
||||
featureFlags: defaultFeatureFlags,
|
||||
isFetchingFeatureFlags: false,
|
||||
featureFlagsFetchError: null,
|
||||
hostsData: null,
|
||||
isFetchingHosts: false,
|
||||
hostsFetchError: null,
|
||||
orgPreferences: [
|
||||
{
|
||||
name: ORG_PREFERENCES.ORG_ONBOARDING,
|
||||
description: 'Organisation Onboarding',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['org'],
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
userPreferences: [],
|
||||
updateUserPreferenceInContext: jest.fn(),
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
orgPreferencesFetchError: null,
|
||||
isLoggedIn: true,
|
||||
isPreflightLoading: false,
|
||||
showChangelogModal: false,
|
||||
updateUser: jest.fn(),
|
||||
updateOrg: jest.fn(),
|
||||
updateOrgPreferences: jest.fn(),
|
||||
activeLicenseRefetch: jest.fn(),
|
||||
updateChangelog: jest.fn(),
|
||||
toggleChangelogModal: jest.fn(),
|
||||
versionData: {
|
||||
version: '1.0.0',
|
||||
ee: 'Y',
|
||||
setupCompleted: true,
|
||||
},
|
||||
|
||||
...appContextOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function AllTheProviders({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user