Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs

This commit is contained in:
Ryan McKinley
2025-07-29 13:06:06 +02:00
196 changed files with 5018 additions and 8134 deletions
-84
View File
@@ -4229,36 +4229,12 @@ exports[`no skipping a11y tests in stories`] = {
"packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Button/Button.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Carousel/Carousel.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Cascader/Cascader.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/DateTimePickers/TimeOfDayPicker.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/FileDropzone/FileDropzone.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
@@ -4268,45 +4244,21 @@ exports[`no skipping a11y tests in stories`] = {
"packages/grafana-ui/src/components/Forms/Checkbox.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Forms/Field.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Forms/FieldArray.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Forms/FieldSet.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Forms/Form.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Forms/InlineField.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/InlineToast/InlineToast.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Input/Input.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Layout/Grid/Grid.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Layout/Stack/Stack.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Link/TextLink.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/LoadingBar/LoadingBar.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Menu/Menu.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
@@ -4325,54 +4277,18 @@ exports[`no skipping a11y tests in stories`] = {
"packages/grafana-ui/src/components/ScrollContainer/ScrollContainer.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Segment/Segment.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Segment/SegmentAsync.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Select/Select.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Select/SelectPerf.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Slider/RangeSlider.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Slider/Slider.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Switch/Switch.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Table/Table.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/TableInputCSV/TableInputCSV.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Tags/TagList.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/Text/Text.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/ThemeDemos/ThemeDemo.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/ThemeDemos/Typography.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
"packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx:5381": [
[0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"]
],
+1 -3
View File
@@ -407,6 +407,7 @@
/e2e/ @grafana/grafana-frontend-platform
/e2e/cloud-plugins-suite/ @grafana/partner-datasources
/e2e-playwright/ @grafana/grafana-frontend-platform
/e2e-playwright/dashboard-new-layouts @grafana/dashboards-squad
/e2e-playwright/plugin-e2e/ @grafana/oss-big-tent @grafana/partner-datasources
/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/ @grafana/plugins-platform-frontend
/e2e-playwright/test-plugins/grafana-extensionstest-app/ @grafana/plugins-platform-frontend
@@ -1003,8 +1004,6 @@ embed.go @grafana/grafana-as-code
/.github/workflows/scripts/json-file-to-job-output.js @grafana/plugins-platform-frontend
/.github/workflows/stale.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/storybook-a11y.yml @grafana/grafana-frontend-platform
/.github/workflows/storybook-verification.yml @grafana/grafana-frontend-platform
/.github/workflows/storybook-verification-playwright.yml @grafana/grafana-frontend-platform
/.github/workflows/update-make-docs.yml @grafana/docs-tooling
/.github/workflows/scripts/kinds/verify-kinds.go @grafana/platform-monitoring
/.github/workflows/scripts/create-security-branch/create-security-branch.sh @grafana/grafana-developer-enablement-squad
@@ -1013,7 +1012,6 @@ embed.go @grafana/grafana-as-code
/.github/workflows/verify-kinds.yml @grafana/platform-monitoring
/.github/workflows/dashboards-issue-add-label.yml @grafana/dashboards-squad
/.github/workflows/run-schema-v2-e2e.yml @grafana/dashboards-squad
/.github/workflows/e2e-dashboard-new-layouts.yml @grafana/dashboards-squad
/.github/workflows/run-dashboard-search-e2e.yml @grafana/grafana-search-and-storage
/.github/workflows/trigger-dashboard-search-e2e.yml @grafana/grafana-search-and-storage
/.github/workflows/ephemeral-instances-pr-comment.yml @grafana/grafana-operator-experience-squad
@@ -1,42 +0,0 @@
name: Run e2e for dashboardNewLayouts
on:
pull_request:
branches:
- '**'
paths:
- 'e2e/dashboard-new-layouts/**'
- 'public/app/features/dashboard-scene/**'
env:
ARCH: linux-amd64
jobs:
dashboard-new-layouts-e2e:
runs-on: ubuntu-latest
continue-on-error: true
if: github.event.pull_request.draft == false
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Pin Go version to mod file
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- run: go version
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- name: Install dependencies
run: yarn install --immutable
- name: Build grafana
run: make build
- name: Install Cypress dependencies
uses: cypress-io/github-action@108b8684ae52e735ff7891524cbffbcd4be5b19f
with:
runTests: false
- name: Run dashboardNewLayouts e2e
run: yarn e2e:dashboard-new-layouts
+29 -2
View File
@@ -143,8 +143,6 @@ jobs:
path: e2e/various-suite
- suite: dashboards-suite
path: e2e/dashboards-suite
- suite: smoke-tests-suite
path: e2e/smoke-tests-suite
- suite: panels-suite
path: e2e/panels-suite
- suite: various-suite (old arch)
@@ -198,6 +196,34 @@ jobs:
path: videos
retention-days: 1
run-storybook-test:
name: Verify Storybook (Playwright)
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.changed == 'true'
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- name: Install dependencies
run: yarn install --immutable
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Storybook and E2E tests
run: yarn e2e:playwright:storybook
run-playwright-tests:
needs:
- build-grafana
@@ -234,6 +260,7 @@ jobs:
required-playwright-tests:
needs:
- run-playwright-tests
- run-storybook-test
- build-grafana
if: ${{ !cancelled() }}
name: All Playwright tests complete
@@ -1,47 +0,0 @@
name: Verify Storybook (Playwright)
on:
pull_request:
paths:
- 'packages/grafana-ui/**'
- 'e2e-playwright/storybook/**'
- '!docs/**'
- '!*.md'
push:
branches:
- main
paths:
- 'packages/grafana-ui/**'
- 'e2e-playwright/storybook/**'
- '!docs/**'
- '!*.md'
permissions: {}
jobs:
verify-storybook:
name: Verify Storybook (Playwright)
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- name: Install dependencies
run: yarn install --immutable
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Storybook and E2E tests
run: yarn e2e:playwright:storybook
@@ -1,52 +0,0 @@
name: Verify Storybook
on:
pull_request:
paths:
- 'packages/grafana-ui/**'
- '!docs/**'
- '!*.md'
push:
branches:
- main
paths:
- 'packages/grafana-ui/**'
- '!docs/**'
- '!*.md'
permissions: {}
jobs:
verify-storybook:
name: Verify Storybook
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- name: Install dependencies
run: yarn install --immutable
- name: Run Storybook and E2E tests
uses: cypress-io/github-action@108b8684ae52e735ff7891524cbffbcd4be5b19f
with:
browser: chrome
start: yarn storybook --quiet
wait-on: 'http://localhost:9001'
wait-on-timeout: 60
command: yarn e2e:storybook
install: false
env:
HOST: localhost
PORT: 9001
+2 -2
View File
@@ -1902,9 +1902,9 @@ update_strategy = minor
# tuning. 0 disables Live, -1 means unlimited connections.
max_connections = 100
# message_size_limit is the maximum size in bytes of Websocket messages from clients. Defaults to 64KB.
# message_size_limit is the maximum size in bytes of Websocket messages from clients. Defaults to 8MB.
# The limit can be disabled by setting it to -1.
message_size_limit = 65536
message_size_limit = 8388608
# allowed_origins is a comma-separated list of origins that can establish connection with Grafana Live.
# If not set then origin will be matched over root_url. Supports wildcard symbol "*".
@@ -197,8 +197,20 @@ The `grafana-default-email` contact point that's provisioned with every new Graf
This is sufficient to have your Alerting configuration up and running in Grafana Cloud with minimal effort.
#### Migration assistant limitations on Grafana Alerting resources
Migration of Silences is not supported by the migration assistant and needs to be configured manually. Alert History is also not available for migration.
Attempting to migrate a large number of alert rules might result in the following error:
```
Maximum number of alert rule groups reached: Delete some alert rule groups or upgrade your plan and try again.
```
To avoid this, refer to the [Alert rule limits in Grafana Cloud](https://grafana.com/docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-grafana-managed-rule/#alert-rule-limits-in-grafana-cloud) when migrating alert rules.
#### Prevent duplicated alert notifications
Successfully migrating Alerting resources to your Grafana Cloud instance could result in 2 sets of notifications being generated:
1. From your OSS/Enterprise instance
@@ -184,6 +184,14 @@ The **Share view** option allows you to link the movement and zoom actions of mu
You might need to reload the dashboard for this feature to work.
{{< /admonition >}}
#### No map repeating
The **No map repeating** option prevents the base map tiles from repeating horizontally when you pan across the world. This constrains the view to a single instance of the world map and avoids visual confusion when displaying global datasets.
{{< admonition type="note" >}}
Enabling this option requires the map to reinitialize.
{{< /admonition >}}
### Map layers options
Geomaps support showing multiple layers. Each layer determines how you visualize geospatial data on top of the base map.
+1 -1
View File
@@ -46,7 +46,7 @@ We have one more community contributor to thank for this release. [Chris Hodges]
Keep reading to learn about what else we have in store for 12.1.
<!-- {{< youtube id=Umy-kCKkMQM >}} -->
{{< youtube id=Umy-kCKkMQM >}}
For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). For the specific steps we recommend when you upgrade to v12.1, check out our [Upgrade Guide](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/upgrade-guide/upgrade-v12.1/).
-19
View File
@@ -1,19 +0,0 @@
import { test, expect } from '@grafana/plugin-e2e';
test(
'Scenario test: Can login successfully',
{
tag: ['@scenarios'],
},
async ({ selectors, page }) => {
await page.goto(selectors.pages.Login.url);
await page.getByTestId(selectors.pages.Login.username).fill('admin');
await page.getByTestId(selectors.pages.Login.password).fill('admin');
await page.getByTestId(selectors.pages.Login.submit).click();
await page.getByTestId(selectors.pages.Login.skip).click();
await expect(page.getByTestId(selectors.components.NavToolbar.commandPaletteTrigger)).toBeVisible();
}
);
@@ -4,7 +4,7 @@ import { GrafanaBootConfig } from '@grafana/runtime';
test.describe(
'Panels smokescreen',
{
tag: ['@smoke'],
tag: ['@acceptance'],
},
() => {
test('Tests each panel type in the panel edit view to ensure no crash', async ({
@@ -14,6 +14,7 @@ test.describe(
}) => {
// this test can absolutely take longer than the default 30s timeout
test.setTimeout(60000);
// Create new dashboard
const dashboardPage = await gotoDashboardPage({});
@@ -30,19 +31,20 @@ test.describe(
// Loop through every panel type and ensure no crash
for (const [_, panel] of Object.entries(panelTypes)) {
// Skip hidden and deprecated panels
if (!panel.hideFromList && panel.state !== 'deprecated') {
// Open visualization picker
const vizPicker = dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker);
await vizPicker.click();
await dashboardPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(panel.name)).click();
// Verify panel type is selected
await expect(vizPicker).toHaveText(panel.name);
// Ensure no unexpected error occurred
await expect(page.getByText('An unexpected error happened')).toBeHidden();
if (panel.hideFromList || panel.state === 'deprecated') {
continue; // Skip hidden and deprecated panels
}
// Select the panel type in the viz picker
const vizPicker = dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker);
await vizPicker.click();
await dashboardPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(panel.name)).click();
// Verify panel type is selected
await expect(vizPicker).toHaveText(panel.name);
// Ensure no unexpected error occurred
await expect(page.getByText('An unexpected error happened')).toBeHidden();
}
});
}
@@ -5,7 +5,7 @@ import { test, expect } from '@grafana/plugin-e2e';
test.describe(
'Smoke tests',
{
tag: ['@smoke'],
tag: ['@acceptance'],
},
() => {
test('Login, create test data source, create dashboard and panel scenario', async ({
@@ -0,0 +1,40 @@
import { test, expect } from '@grafana/plugin-e2e';
test(
'Can login successfully',
{
tag: ['@acceptance'],
},
async ({ selectors, page, grafanaAPICredentials }) => {
test.skip(grafanaAPICredentials.password === 'admin', 'Does not run with default password');
await page.goto(selectors.pages.Login.url);
await page.getByTestId(selectors.pages.Login.username).fill(grafanaAPICredentials.user);
await page.getByTestId(selectors.pages.Login.password).fill(grafanaAPICredentials.password);
await page.getByTestId(selectors.pages.Login.submit).click();
await expect(page.getByTestId(selectors.components.NavToolbar.commandPaletteTrigger)).toBeVisible();
}
);
test(
'Can login successfully and skip password change',
{
tag: ['@acceptance'],
},
async ({ selectors, page, grafanaAPICredentials }) => {
test.skip(grafanaAPICredentials.password !== 'admin', 'Only runs with the default password');
await page.goto(selectors.pages.Login.url);
await page.getByTestId(selectors.pages.Login.username).fill(grafanaAPICredentials.user);
await page.getByTestId(selectors.pages.Login.password).fill(grafanaAPICredentials.password);
await page.getByTestId(selectors.pages.Login.submit).click();
await page.getByTestId(selectors.pages.Login.skip).click();
await expect(page.getByTestId(selectors.components.NavToolbar.commandPaletteTrigger)).toBeVisible();
}
);
@@ -1,30 +0,0 @@
import { e2e } from '../utils';
import { flows } from './dashboard-edit-flows';
describe('Dashboard panels', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can duplicate a panel', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Paste tab' });
e2e.flows.scenes.toggleEditMode();
const panelTitle = 'Unique';
flows.changePanelTitle('New panel', panelTitle);
e2e.components.Panels.Panel.title(panelTitle).should('have.length', 1);
e2e.components.Panels.Panel.menu(panelTitle).click({ force: true });
e2e.components.Panels.Panel.menuItems('More...').trigger('mouseover');
e2e.components.Panels.Panel.menuItems('Duplicate').click();
e2e.components.Panels.Panel.title(panelTitle).should('have.length', 2);
// Save, reload, and ensure duplicate has persisted
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.components.Panels.Panel.title(panelTitle).should('have.length', 2);
});
});
@@ -1,58 +0,0 @@
import { e2e } from '../utils';
const deselectPanels = () => {
e2e.pages.Dashboard.Controls().click();
};
// Common flows for adding/editing variables on the new edit pane
export const flows = {
newEditPaneVariableClick() {
e2e.components.NavToolbar.editDashboard.editButton().should('be.visible').click();
e2e.components.PanelEditor.Outline.section().should('be.visible').click();
e2e.components.PanelEditor.Outline.item('Variables').should('be.visible').click();
e2e.components.PanelEditor.ElementEditPane.addVariableButton().should('be.visible').click();
},
newEditPanelCommonVariableInputs(variable: Variable) {
e2e.components.PanelEditor.ElementEditPane.variableType(variable.type)
.scrollIntoView()
.should('be.visible')
.click();
e2e.components.PanelEditor.ElementEditPane.variableNameInput().clear().type(variable.name).blur();
e2e.components.PanelEditor.ElementEditPane.variableLabelInput().clear().type(variable.label).blur();
},
firstPanelTitleShouldBe(panelTitle: string) {
return e2e.components.Panels.Panel.headerContainer()
.first()
.within(() => cy.get('h2').first().should('have.text', panelTitle));
},
deselectPanels,
changePanelTitle(oldPanelTitle: string, newPanelTitle: string) {
deselectPanels();
const oldPanelRegex = new RegExp(`^${oldPanelTitle}$`);
e2e.flows.scenes.selectPanel(oldPanelRegex);
e2e.components.PanelEditor.OptionsPane.fieldInput('Title')
.should('have.value', oldPanelTitle)
.clear()
.type(newPanelTitle);
e2e.components.PanelEditor.OptionsPane.fieldInput('Title').should('have.value', newPanelTitle);
},
changePanelDescription(panelTitle: string, newDescription: string) {
deselectPanels();
const panelTitleRegex = new RegExp(`^${panelTitle}$`);
e2e.flows.scenes.selectPanel(panelTitleRegex);
e2e.components.PanelEditor.OptionsPane.fieldLabel('panel-options Description').within(() => {
cy.get('textarea').type(newDescription);
cy.get('textarea').should('have.value', newDescription);
});
},
};
export type Variable = {
type: string;
name: string;
label?: string;
description?: string;
value: string;
};
@@ -1,506 +0,0 @@
import { e2e } from '../utils';
describe('Grouping panels', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
/*
* Rows
*/
it('can group and ungroup new panels into row', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Group new panels into row' });
e2e.components.NavToolbar.editDashboard.editButton().click();
// Group into row
e2e.flows.scenes.groupIntoRow();
// Verify row and panel titles
e2e.components.DashboardRow.title('New row').should('be.visible');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
// Save dashboards and reload
e2e.flows.scenes.saveDashboard();
cy.reload();
// Verify row and panel titles after reload
e2e.components.DashboardRow.title('New row').should('be.visible');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.NavToolbar.editDashboard.editButton().click();
// Ungroup
e2e.flows.scenes.ungroupPanels();
// Verify Row title is gone
e2e.components.DashboardRow.title('New row').should('not.exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
//Save dashboards and reload
e2e.flows.scenes.saveDashboard();
cy.reload();
// Verify Row title is gone
e2e.components.DashboardRow.title('New row').should('not.exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
});
it('can add and remove several rows', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Add and remove rows' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.flows.scenes.groupIntoRow();
e2e.components.CanvasGridAddActions.addRow().click({ scrollBehavior: 'bottom' });
e2e.flows.scenes.addPanel();
e2e.components.CanvasGridAddActions.addRow().click({ scrollBehavior: 'bottom' });
e2e.components.DashboardEditPaneSplitter.primaryBody().scrollTo('bottom', { ensureScrollable: false });
e2e.components.CanvasGridAddActions.addPanel().should('have.length', 3).last().click();
e2e.components.DashboardRow.title('New row').should('exist');
e2e.components.DashboardRow.title('New row 1').should('exist');
e2e.components.DashboardRow.title('New row 2').should('exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 5);
//Save dashboards and reload
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.components.DashboardRow.title('New row').should('exist');
e2e.components.DashboardRow.title('New row 1').should('exist');
e2e.components.DashboardRow.title('New row 2').should('exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 5);
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.DashboardRow.title('New row 1').parent().click();
e2e.components.EditPaneHeader.deleteButton().click();
e2e.pages.ConfirmModal.delete().click();
e2e.components.DashboardRow.title('New row 2').parent().click();
e2e.components.EditPaneHeader.deleteButton().click();
e2e.pages.ConfirmModal.delete().click();
e2e.components.DashboardRow.title('New row').should('exist');
e2e.components.DashboardRow.title('New row 1').should('not.exist');
e2e.components.DashboardRow.title('New row 2').should('not.exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.components.DashboardRow.title('New row').should('exist');
e2e.components.DashboardRow.title('New row 1').should('not.exist');
e2e.components.DashboardRow.title('New row 2').should('not.exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
});
it('can paste a copied row', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Paste row' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.flows.scenes.groupIntoRow();
e2e.components.DashboardRow.title('New row').should('exist');
e2e.flows.scenes.editPaneCopy();
e2e.components.CanvasGridAddActions.pasteRow().click({ scrollBehavior: 'bottom' });
e2e.components.DashboardEditPaneSplitter.primaryBody().scrollTo('bottom', { ensureScrollable: false });
e2e.components.DashboardRow.title('New row').should('exist');
e2e.components.DashboardRow.title('New row 1').should('exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 6);
e2e.flows.scenes.saveDashboard();
cy.reload();
cy.scrollTo('bottom');
e2e.components.DashboardRow.title('New row').should('exist');
e2e.components.DashboardRow.title('New row 1').should('exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 6);
});
it('can duplicate a row', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Duplicate row' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.flows.scenes.groupIntoRow();
e2e.components.DashboardRow.title('New row').should('exist');
e2e.flows.scenes.editPaneDuplicate();
e2e.components.DashboardEditPaneSplitter.primaryBody().scrollTo('bottom', { ensureScrollable: false });
e2e.components.DashboardRow.title('New row').should('exist');
e2e.components.DashboardRow.title('New row 1').should('exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 6);
e2e.flows.scenes.saveDashboard();
cy.reload();
cy.scrollTo('bottom');
e2e.components.DashboardRow.title('New row').should('exist');
e2e.components.DashboardRow.title('New row 1').should('exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 6);
});
it('can collapse rows', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Collapse rows' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.flows.scenes.groupIntoRow();
e2e.components.DashboardRow.title('New row').should('exist');
e2e.flows.scenes.editPaneDuplicate();
e2e.components.DashboardEditPaneSplitter.primaryBody().scrollTo('bottom', { ensureScrollable: false });
e2e.components.DashboardRow.title('New row').should('exist');
e2e.components.DashboardRow.title('New row 1').should('exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 6);
e2e.components.DashboardRow.title('New row').click();
e2e.components.DashboardRow.title('New row 1').click();
e2e.components.DashboardRow.title('New row').should('exist');
e2e.components.DashboardRow.title('New row 1').should('exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 0);
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.components.DashboardRow.title('New row').should('exist');
e2e.components.DashboardRow.title('New row 1').should('exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 0);
});
it('can convert rows into tabs when changing layout', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Rows to tabs' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.flows.scenes.groupIntoRow();
e2e.components.DashboardRow.title('New row').should('exist');
e2e.flows.scenes.editPaneDuplicate();
e2e.components.DashboardRow.title('New row').should('exist');
e2e.components.DashboardRow.title('New row 1').should('exist');
e2e.components.EditPaneHeader.backButton().click({ force: true });
// expand collapsed layouts section
e2e.components.OptionsGroup.toggle('group-layout-category').click();
e2e.flows.scenes.selectTabsLayout();
e2e.components.Tab.title('New row').should('be.visible');
e2e.components.Tab.title('New row 1').should('be.visible');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.Tab.title('New row 1').click();
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.components.Tab.title('New row').should('be.visible');
e2e.components.Tab.title('New row 1').should('be.visible');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.Tab.title('New row').click();
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
});
it('can group and ungroup new panels into row with tab', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Group new panels into tab with row' });
e2e.components.NavToolbar.editDashboard.editButton().click();
// Group into row with tab
e2e.flows.scenes.groupIntoRow();
e2e.flows.scenes.groupIntoTab();
// Verify tab and panel titles
e2e.components.DashboardRow.title('New row').should('be.visible');
e2e.components.Tab.title('New tab').should('be.visible');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
// Save dashboards and reload
e2e.flows.scenes.saveDashboard();
cy.reload();
// Verify tab, row and panel titles after reload
e2e.components.DashboardRow.title('New row').should('be.visible');
e2e.components.Tab.title('New tab').should('be.visible');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.NavToolbar.editDashboard.editButton().click();
// Ungroup
e2e.flows.scenes.ungroupPanels(); // ungroup tabs
e2e.flows.scenes.ungroupPanels(); // ungroup rows
// Verify tab and row titles is gone
e2e.components.DashboardRow.title('New row').should('not.exist');
e2e.components.Tab.title('New tab').should('not.exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
// Save dashboards and reload
e2e.flows.scenes.saveDashboard();
cy.reload();
// Verify Row title is gone
e2e.components.DashboardRow.title('New row').should('not.exist');
e2e.components.Tab.title('New tab').should('not.exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
});
/*
* Tabs
*/
it('can group and ungroup new panels into tab', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Group new panels into tab' });
e2e.components.NavToolbar.editDashboard.editButton().click();
// Group into tab
e2e.flows.scenes.groupIntoTab();
// Verify tab and panel titles
e2e.components.Tab.title('New tab').should('be.visible');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
// Save dashboards and reload
e2e.flows.scenes.saveDashboard();
cy.reload();
// Verify row and panel titles after reload
e2e.components.Tab.title('New tab').should('be.visible');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.NavToolbar.editDashboard.editButton().click();
// Ungroup
e2e.flows.scenes.ungroupPanels();
// Verify Row title is gone
e2e.components.Tab.title('New tab').should('not.exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
// Save dashboards and reload
e2e.flows.scenes.saveDashboard();
cy.reload();
// Verify Row title is gone
e2e.components.Tab.title('New tab').should('not.exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
});
it('can add and remove several tabs', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Add and remove tabs' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.flows.scenes.groupIntoTab();
e2e.components.CanvasGridAddActions.addTab().click();
e2e.flows.scenes.addPanel();
e2e.components.CanvasGridAddActions.addTab().click();
e2e.flows.scenes.addPanel();
e2e.components.Tab.title('New tab').should('exist');
e2e.components.Tab.title('New tab 1').should('exist');
e2e.components.Tab.title('New tab 2').should('exist');
e2e.components.Tab.title('New tab 2').should('have.attr', 'aria-selected', 'true');
e2e.components.Panels.Panel.title('New panel').should('have.length', 1);
//Save dashboards and reload
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.components.Tab.title('New tab').should('exist');
e2e.components.Tab.title('New tab 1').should('exist');
e2e.components.Tab.title('New tab 2').should('exist');
e2e.components.Tab.title('New tab 2').should('have.attr', 'aria-selected', 'true');
e2e.components.Panels.Panel.title('New panel').should('have.length', 1);
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.Tab.title('New tab 2').click();
e2e.components.EditPaneHeader.deleteButton().click();
e2e.pages.ConfirmModal.delete().click();
e2e.components.Tab.title('New tab 1').click();
e2e.components.EditPaneHeader.deleteButton().click();
e2e.pages.ConfirmModal.delete().click();
e2e.components.Tab.title('New tab').should('exist');
e2e.components.Tab.title('New tab 1').should('not.exist');
e2e.components.Tab.title('New tab 2').should('not.exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.components.Tab.title('New tab').should('exist');
e2e.components.Tab.title('New tab 1').should('not.exist');
e2e.components.Tab.title('New tab 2').should('not.exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
});
it('can paste a copied tab', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Paste tab' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.flows.scenes.groupIntoTab();
e2e.components.Tab.title('New tab').should('exist');
e2e.flows.scenes.editPaneCopy();
e2e.components.CanvasGridAddActions.pasteTab().click();
e2e.components.Tab.title('New tab').should('exist');
e2e.components.Tab.title('New tab 1').should('exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.components.Tab.title('New tab').should('exist');
e2e.components.Tab.title('New tab 1').should('exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
});
it('can duplicate a tab', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Duplicate tab' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.flows.scenes.groupIntoTab();
e2e.components.Tab.title('New tab').should('exist');
e2e.flows.scenes.editPaneDuplicate();
e2e.components.Tab.title('New tab').should('exist');
e2e.components.Tab.title('New tab 1').should('exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.components.Tab.title('New tab').should('exist');
e2e.components.Tab.title('New tab 1').should('exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
});
it('can convert tabs into rows when changing layout', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Tabs to rows' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.flows.scenes.groupIntoTab();
e2e.components.Tab.title('New tab').should('exist');
e2e.flows.scenes.editPaneDuplicate();
e2e.flows.scenes.editPaneDuplicate();
e2e.components.Tab.title('New tab').should('exist');
e2e.components.Tab.title('New tab 1').should('exist');
e2e.components.Tab.title('New tab 2').should('exist');
e2e.components.EditPaneHeader.backButton().click({ force: true });
// expand collapsed layouts section
e2e.components.OptionsGroup.toggle('group-layout-category').click();
e2e.flows.scenes.selectRowsLayout();
e2e.components.DashboardRow.title('New tab').should('exist');
e2e.components.Panels.Panel.title('New panel').first().should('be.visible'); // wait for panels to load
e2e.components.DashboardRow.title('New tab 1').should('exist');
e2e.components.DashboardRow.title('New tab 2').should('exist');
e2e.components.DashboardEditPaneSplitter.primaryBody().scrollTo('bottom', { ensureScrollable: false });
e2e.components.Panels.Panel.title('New panel').should('have.length', 9);
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.components.DashboardRow.title('New tab').should('exist');
e2e.components.Panels.Panel.title('New panel').first().should('be.visible'); // wait for panels to load
e2e.components.DashboardRow.title('New tab 1').should('exist');
e2e.components.DashboardRow.title('New tab 2').should('exist');
cy.scrollTo('bottom');
e2e.components.Panels.Panel.title('New panel').should('have.length', 9);
});
it('can group and ungroup new panels into tab with row', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Group new panels into tab with row' });
e2e.components.NavToolbar.editDashboard.editButton().click();
// Group into tab
e2e.flows.scenes.groupIntoTab();
e2e.flows.scenes.groupIntoRow();
// Verify tab and panel titles
e2e.components.Tab.title('New tab').should('be.visible');
e2e.components.DashboardRow.title('New row').should('be.visible');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
// Save dashboards and reload
e2e.flows.scenes.saveDashboard();
cy.reload();
// Verify tab, row and panel titles after reload
e2e.components.Tab.title('New tab').should('be.visible');
e2e.components.DashboardRow.title('New row').should('be.visible');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.NavToolbar.editDashboard.editButton().click();
// Ungroup
e2e.flows.scenes.ungroupPanels(); // ungroup rows
e2e.flows.scenes.ungroupPanels(); // ungroup tabs
// Verify tab and row titles is gone
e2e.components.Tab.title('New tab').should('not.exist');
e2e.components.DashboardRow.title('New row').should('not.exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
// Save dashboards and reload
e2e.flows.scenes.saveDashboard();
cy.reload();
// Verify Row title is gone
e2e.components.Tab.title('New tab').should('not.exist');
e2e.components.DashboardRow.title('New row').should('not.exist');
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
});
});
@@ -1,26 +0,0 @@
import { e2e } from '../utils';
const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard';
describe('Dashboard Outline', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can use dashboard outline', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
e2e.flows.scenes.toggleEditMode();
e2e.components.PanelEditor.Outline.section().click();
// Should be able to click Variables item in outline to see add variable button
e2e.components.PanelEditor.Outline.item('Variables').click();
e2e.components.PanelEditor.ElementEditPane.addVariableButton().should('exist');
// Clicking a panel should scroll that panel in view
cy.contains('Dashboard panel 48').should('not.exist');
e2e.components.PanelEditor.Outline.item('Panel #48').click();
cy.contains('Dashboard panel 48').should('exist');
});
});
@@ -1,27 +0,0 @@
import { e2e } from '../utils';
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
const DASHBOARD_NAME = 'Test variable output';
describe('Dashboard panels', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can add a new panel', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
cy.contains(DASHBOARD_NAME).should('be.visible');
// Toggle edit mode
e2e.components.NavToolbar.editDashboard.editButton().should('be.visible').click();
e2e.flows.scenes.addPanel();
// Check that new panel has been added
e2e.components.Panels.Panel.title('New panel').should('be.visible');
// Check that pressing the configure button shows the panel editor
e2e.flows.scenes.configurePanel();
e2e.components.PanelEditor.General.content().should('be.visible');
});
});
@@ -1,70 +0,0 @@
import { e2e } from '../utils';
import { flows, Variable } from './dashboard-edit-flows';
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
const DASHBOARD_NAME = 'Test variable output';
describe('Dashboard edit - Ad hoc variables', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can add a new adhoc variable', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
cy.contains(DASHBOARD_NAME).should('be.visible');
const variable: Variable = {
type: 'adhoc',
name: 'VariableUnderTest',
value: 'label1',
label: 'VariableUnderTest',
};
// common steps to add a new variable
flows.newEditPaneVariableClick();
flows.newEditPanelCommonVariableInputs(variable);
e2e.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.datasourceSelect().should('be.visible').click();
const dataSource = 'gdev-loki';
cy.contains(dataSource).scrollIntoView().should('be.visible').click();
// mock the API call to get the labels
const labels = ['label1', 'label2'];
cy.intercept('GET', '**/resources/labels*', {
statusCode: 200,
body: {
status: 'success',
data: labels,
},
}).as('labels');
// select the variable in the dashboard and confirm the variable value is set
e2e.pages.Dashboard.SubMenu.submenuItem().should('be.visible').click();
e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('be.visible').contains(variable.label);
// mock the API call to get the label values
const labelValues = ['label2Value1'];
cy.intercept('GET', `**/resources/label/${labels[1]}/values*`, {
statusCode: 200,
body: {
status: 'success',
data: labelValues,
},
}).as('label-values');
// choose the label and value
cy.get('div[data-testid]').contains(labels[1]).click();
cy.get('div[data-testid]').contains('=').click();
cy.get('div[data-testid]').contains(labelValues[0]).click();
cy.focused().type('{esc}');
// assert the panel is visible and has the correct value
e2e.components.Panels.Panel.content()
.should('be.visible')
.first()
.within(() => {
cy.get('.markdown-html').should('include.text', `VariableUnderTest: ${labels[1]}="${labelValues[0]}"`);
});
});
});
@@ -1,49 +0,0 @@
import { e2e } from '../utils';
import { flows, Variable } from './dashboard-edit-flows';
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
const DASHBOARD_NAME = 'Test variable output';
describe('Dashboard edit - datasource variables', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can add a new datasource variable', () => {
e2e.pages.Dashboards.visit();
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
cy.contains(DASHBOARD_NAME).should('be.visible');
const dsType = 'cloudwatch';
const variable: Variable = {
type: 'datasource',
name: 'VariableUnderTest',
label: 'VariableUnderTest',
value: `gdev-${dsType}`,
};
// Common steps to add a new variable
flows.newEditPaneVariableClick();
flows.newEditPanelCommonVariableInputs(variable);
e2e.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect().should('be.visible').click();
cy.get(`#combobox-option-${dsType}`).click();
const regexFilter = 'cloud';
e2e.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.nameFilter().should('be.visible').type(regexFilter);
// Assert the variable dropdown is visible with correct label
e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('be.visible').contains(variable.label);
// Assert the variable values are correctly displayed in the panel
e2e.components.Panels.Panel.content()
.should('be.visible')
.first()
.within(() => {
cy.get('.markdown-html').should('include.text', `${variable.name}: ${variable.value}`);
});
});
});
@@ -1,68 +0,0 @@
import { e2e } from '../utils';
import { flows, Variable } from './dashboard-edit-flows';
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
const DASHBOARD_NAME = 'Test variable output';
describe('Dashboard edit - Group By variables', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can add a new group by variable', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
cy.contains(DASHBOARD_NAME).should('be.visible');
const variable: Variable = {
type: 'groupby',
name: 'VariableUnderTest',
value: 'label1',
label: 'VariableUnderTest',
};
// common steps to add a new variable
flows.newEditPaneVariableClick();
flows.newEditPanelCommonVariableInputs(variable);
e2e.pages.Dashboard.Settings.Variables.Edit.GroupByVariable.dataSourceSelect().should('be.visible').click();
const dataSource = 'gdev-loki';
cy.contains(dataSource).scrollIntoView().should('be.visible').click();
// mock the API call to get the labels
const labels = ['label1', 'label2'];
cy.intercept('GET', '**/resources/labels*', {
statusCode: 200,
body: {
status: 'success',
data: labels,
},
}).as('labels');
// select the variable in the dashboard and confirm the variable value is set
e2e.pages.Dashboard.SubMenu.submenuItem().should('be.visible').click();
e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('be.visible').contains(variable.label);
// mock the API call to get the label values
const labelValues = ['label2Value1'];
cy.intercept('GET', `**/resources/label/${labels[1]}/values*`, {
statusCode: 200,
body: {
status: 'success',
data: labelValues,
},
}).as('label-values');
// choose the label and value
cy.get('div[data-testid]').contains(labels[1]).click();
cy.focused().type('{esc}');
// assert the panel is visible and has the correct value
e2e.components.Panels.Panel.content()
.should('be.visible')
.first()
.within(() => {
cy.get('.markdown-html').should('include.text', `VariableUnderTest: ${labels[1]}`);
});
});
});
@@ -1,37 +0,0 @@
import { e2e } from '../utils';
import { flows } from './dashboard-edit-flows';
const PAGE_UNDER_TEST = '5SdHCadmz/panel-tests-graph';
describe('Dashboard', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can edit panel title and description', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
e2e.flows.scenes.toggleEditMode();
const oldTitle = 'No Data Points Warning';
flows.firstPanelTitleShouldBe(oldTitle);
const newDescription = 'A description of this panel';
flows.changePanelDescription(oldTitle, newDescription);
const newTitle = 'New Panel Title';
flows.changePanelTitle(oldTitle, newTitle);
// Check that new title is reflected in panel header
flows.firstPanelTitleShouldBe(newTitle);
// Reveal description tooltip and check that its value is as expected
const descriptionIcon = () => cy.get('[data-testid="title-items-container"] > span').first();
descriptionIcon().click({ force: true });
descriptionIcon().then((el) => {
const tooltipId = el.attr('aria-describedby');
cy.get(`[id="${tooltipId}"]`).should('have.text', `${newDescription}\n`);
});
});
});
@@ -1,26 +0,0 @@
import { e2e } from '../utils';
const PAGE_UNDER_TEST = '5SdHCadmz/panel-tests-graph';
describe('Dashboard', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can toggle transparent background switch', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
e2e.flows.scenes.toggleEditMode();
e2e.flows.scenes.selectPanel(/^No Data Points Warning$/);
e2e.components.Panels.Panel.title('No Data Points Warning').then((el) => {
cy.wrap(el.css('background')).should('not.match', /rgba\(0, 0, 0, 0\)/);
});
cy.get('#transparent-background').click({ force: true });
e2e.components.Panels.Panel.title('No Data Points Warning').then((el) => {
cy.wrap(el.css('background')).should('match', /rgba\(0, 0, 0, 0\)/);
});
});
});
@@ -1,68 +0,0 @@
import { e2e } from '../utils';
import { flows, Variable } from './dashboard-edit-flows';
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
const DASHBOARD_NAME = 'Test variable output';
describe('Dashboard edit - Query variable', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can add a new query variable', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
cy.contains(DASHBOARD_NAME).should('be.visible');
const queryVariableOptions = ['default'];
const variable: Variable = {
type: 'query',
name: 'VariableUnderTest',
value: queryVariableOptions[0],
label: 'VariableUnderTest', // constant doesn't really need a label
};
// common steps to add a new variable
flows.newEditPaneVariableClick();
flows.newEditPanelCommonVariableInputs(variable);
// open the modal query variable editor
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsOpenButton().should('be.visible').click();
// select a core data source that just runs a query during preview
e2e.components.DataSourcePicker.container().should('be.visible').click();
// spy on the API call to get the query options
cy.intercept('GET', '/api/datasources/**').as('getOptions');
const dataSource = 'gdev-cloudwatch';
// this will trigger an API call to get the query options
cy.contains(dataSource).scrollIntoView().should('be.visible').click();
// wait for the API call to finish
cy.wait('@getOptions');
// show the preview of the query results
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.previewButton().should('be.visible').click();
// assert the query results are shown
e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('be.visible');
e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption()
.first()
.then(($el) => {
const previewOption = $el.text().trim();
cy.wrap(previewOption).as('previewOption');
});
// close the modal
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.closeButton().should('be.visible').click();
// assert the query variable values are in the variable value select
cy.get('@previewOption').then((opt) => {
e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.name).next().should('have.text', opt);
// assert the panel is visible and has the correct value
e2e.components.Panels.Panel.content()
.should('be.visible')
.first()
.within(() => {
cy.get('.markdown-html').should('include.text', `VariableUnderTest: ${opt}`);
});
});
});
});
@@ -1,147 +0,0 @@
import { e2e } from '../utils';
import { flows, Variable } from './dashboard-edit-flows';
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
const DASHBOARD_NAME = 'Test variable output';
describe('Dashboard edit - variables', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can add a new custom variable', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
cy.contains(DASHBOARD_NAME).should('be.visible');
const variable: Variable = {
type: 'custom',
name: 'foo',
label: 'Foo',
value: 'one,two,three',
};
// common steps to add a new variable
flows.newEditPaneVariableClick();
flows.newEditPanelCommonVariableInputs(variable);
// set the custom variable value
e2e.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput().clear().type(variable.value).blur();
// assert the dropdown for the variable is visible and has the correct values
e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('be.visible').contains(variable.label);
const values = variable.value.split(',');
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(values[0]).should('be.visible');
// check that variable deletion works
e2e.components.EditPaneHeader.deleteButton().click();
e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('not.exist');
});
it('can add a new constant variable', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
cy.contains(DASHBOARD_NAME).should('be.visible');
const variable: Variable = {
type: 'constant',
name: 'VariableUnderTest',
value: 'foo',
label: 'VariableUnderTest', // constant doesn't really need a label
};
// common steps to add a new variable
flows.newEditPaneVariableClick();
flows.newEditPanelCommonVariableInputs(variable);
// set the constant variable value
const type = 'variable-type Value';
const field = e2e.components.PanelEditor.OptionsPane.fieldLabel(type);
field.should('be.visible');
field.find('input').should('be.visible').clear().type(variable.value).blur();
// assert the panel is visible and has the correct value
e2e.components.Panels.Panel.content()
.should('be.visible')
.first()
.within(() => {
cy.get('.markdown-html').should('include.text', `VariableUnderTest: ${variable.value}`);
});
});
it('can add a new textbox variable', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
cy.contains(DASHBOARD_NAME).should('be.visible');
const variable: Variable = {
type: 'textbox',
name: 'VariableUnderTest',
value: 'foo',
label: 'VariableUnderTest',
};
// common steps to add a new variable
flows.newEditPaneVariableClick();
flows.newEditPanelCommonVariableInputs(variable);
// set the textbox variable value
const type = 'variable-type Value';
const field = e2e.components.PanelEditor.OptionsPane.fieldLabel(type);
field.should('be.visible');
field.find('input').should('be.visible').clear().type(variable.value).blur();
// select the variable in the dashboard and confirm the variable value is set
e2e.pages.Dashboard.SubMenu.submenuItem().should('be.visible').click();
e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('be.visible').contains(variable.label);
// assert the panel is visible and has the correct value
e2e.components.Panels.Panel.content()
.should('be.visible')
.first()
.within(() => {
cy.get('.markdown-html').should('include.text', `VariableUnderTest: ${variable.value}`);
});
});
it('can add a new interval variable', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
cy.contains(DASHBOARD_NAME).should('be.visible');
const variable: Variable = {
type: 'interval',
name: 'VariableUnderTest',
value: '1m',
label: 'VariableUnderTest',
};
// common steps to add a new variable
flows.newEditPaneVariableClick();
flows.newEditPanelCommonVariableInputs(variable);
// enable the auto option
e2e.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.autoEnabledCheckbox().click({ force: true });
// select the variable in the dashboard and confirm the variable value is set
e2e.pages.Dashboard.SubMenu.submenuItem().should('be.visible').click();
e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('be.visible').contains(variable.label);
// assert the panel is visible and has the correct value
e2e.components.Panels.Panel.content()
.should('be.visible')
.first()
.within(() => {
cy.get('.markdown-html').should('include.text', `VariableUnderTest: ${variable.value}`);
});
// select the variable in the dashboard and set the Auto option
e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.name).next().should('have.text', `1m`).click();
e2e.components.Select.option().contains('Auto').click();
// assert the panel is visible and has the correct "Auto" value
e2e.components.Panels.Panel.content()
.should('be.visible')
.first()
.within(() => {
cy.get('.markdown-html').should('include.text', `VariableUnderTest: 10m`);
});
});
});
@@ -1,59 +0,0 @@
import { e2e } from '../utils';
const PAGE_UNDER_TEST = 'ed155665/annotation-filtering';
describe('Dashboard', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can drag and drop panels', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
e2e.flows.scenes.toggleEditMode();
e2e.flows.scenes.movePanel(/^Panel three$/, /^Panel one$/);
e2e.components.Panels.Panel.headerContainer()
.contains(/^Panel three$/)
.then((panel3) => {
e2e.components.Panels.Panel.headerContainer()
.contains(/^Panel one$/)
.should('be.lowerThan', panel3);
});
e2e.flows.scenes.movePanel(/^Panel two$/, /^Panel three$/);
e2e.components.Panels.Panel.headerContainer()
.contains(/^Panel three$/)
.then((panel3) => {
e2e.components.Panels.Panel.headerContainer()
.contains(/^Panel two$/)
.should('be.higherThan', panel3);
});
});
// Note, moving a panel from a nested row to a parent row currently just deletes the panel
// This test will need to be updated once the correct behavior is implemented.
it('can move panel from nested row to parent row', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
e2e.flows.scenes.toggleEditMode();
e2e.flows.scenes.groupIntoRow();
e2e.flows.scenes.groupIntoRow();
cy.get('[data-testid="data-testid dashboard-row-title-New row"]')
.first()
.then((el) => {
const rect = el.offset();
e2e.components.Panels.Panel.headerContainer()
.contains(/^Panel one$/)
.trigger('pointerdown', { which: 1 })
.trigger('pointermove', { clientX: rect.left, clientY: rect.top })
.trigger('pointerup');
});
e2e.components.Panels.Panel.headerContainer()
.contains(/^Panel one$/)
.should('not.exist');
});
});
@@ -1,309 +0,0 @@
import { e2e } from '../utils';
describe('Dashboard', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can switch to auto grid layout', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Switch to auto grid' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.OptionsGroup.toggle('grid-layout-category').click();
e2e.flows.scenes.selectAutoGridLayout();
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
const checkInputs = () => {
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth().should('be.visible');
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns().should('be.visible');
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight().should('be.visible');
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.fillScreen().should('exist');
};
checkInputs();
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.NavToolbar.editDashboard.editButton().click();
checkInputs();
});
it('can change min column width in auto grid layout', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Set min column width' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.OptionsGroup.toggle('grid-layout-category').click();
e2e.flows.scenes.selectAutoGridLayout();
let firstStandardPanelTopOffset = 0;
// standard min column width will have 1 panel on a second row in edit mode
e2e.components.Panels.Panel.title('New panel')
.first()
.then((el) => {
firstStandardPanelTopOffset = el.offset().top;
});
e2e.components.Panels.Panel.title('New panel')
.last()
.then((el) => {
expect(el.offset().top).to.be.greaterThan(firstStandardPanelTopOffset);
});
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth().should('be.visible').click();
cy.get('[id=combobox-option-narrow]').click();
const checkOffset = () => {
// narrow min column width will have all panels on the same row
let narrowPanelTopOffset = 0;
e2e.components.Panels.Panel.title('New panel')
.first()
.then((el) => {
narrowPanelTopOffset = el.offset().top;
});
e2e.components.Panels.Panel.title('New panel')
.last()
.then((el) => {
expect(el.offset().top).to.eq(narrowPanelTopOffset);
});
};
checkOffset();
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth().should('have.value', 'Narrow');
checkOffset();
});
it('can change to custom min column width in auto grid layout', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Set custom min column width' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.OptionsGroup.toggle('grid-layout-category').click();
e2e.flows.scenes.selectAutoGridLayout();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth().should('be.visible').click();
cy.get('[id=combobox-option-custom]').click();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth()
.should('be.visible')
.clear()
.type('900')
.blur();
cy.wait(100); // cy too fast and executes next command before resizing is done
// // changing to 900 custom width to have each panel span the whole row to verify offset
e2e.flows.scenes.verifyPanelsStackedVertically();
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.flows.scenes.verifyPanelsStackedVertically();
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth().should('have.value', '900');
e2e.flows.scenes.verifyPanelsStackedVertically();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.clearCustomMinColumnWidth().should('be.visible').click();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth().should('have.value', 'Standard');
});
it('can change max columns in auto grid layout', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Set max columns' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.OptionsGroup.toggle('grid-layout-category').click();
e2e.flows.scenes.selectAutoGridLayout();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns().should('be.visible').click();
cy.get('[id=combobox-option-1]').click();
// changing to 1 max column to have each panel span the whole row to verify offset
e2e.flows.scenes.verifyPanelsStackedVertically();
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.flows.scenes.verifyPanelsStackedVertically();
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns().should('have.value', '1');
e2e.flows.scenes.verifyPanelsStackedVertically();
});
it('can change row height in auto grid layout', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Set row height' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.OptionsGroup.toggle('grid-layout-category').click();
e2e.flows.scenes.selectAutoGridLayout();
let regularRowHeight = 0;
e2e.components.Panels.Panel.title('New panel')
.first()
.then((el) => {
regularRowHeight = el.height();
});
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight().should('be.visible').click();
cy.get('[id=combobox-option-short]').click();
e2e.components.Panels.Panel.title('New panel')
.first()
.then((el) => {
expect(el.height()).to.be.lessThan(regularRowHeight);
});
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight().should('be.visible').click();
cy.get('[id=combobox-option-tall]').click();
const checkHeight = () => {
e2e.components.Panels.Panel.title('New panel')
.first()
.then((el) => {
expect(el.height()).to.be.greaterThan(regularRowHeight);
});
};
checkHeight();
e2e.flows.scenes.saveDashboard();
cy.reload();
checkHeight();
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight().should('have.value', 'Tall');
checkHeight();
});
it('can change to custom row height in auto grid layout', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Set custom row height' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.OptionsGroup.toggle('grid-layout-category').click();
e2e.flows.scenes.selectAutoGridLayout();
let regularRowHeight = 0;
e2e.components.Panels.Panel.title('New panel')
.first()
.then((el) => {
regularRowHeight = el.height();
});
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight().should('be.visible').click();
cy.get('[id=combobox-option-custom]').click();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.customRowHeight().clear().type('800').blur();
cy.wait(100); // cy too fast and executes next command before resizing is done
e2e.components.Panels.Panel.title('New panel')
.first()
.then((el) => {
const elHeight = el.height();
expect(elHeight).be.closeTo(800, 5); // some flakyness and get 798 sometimes
expect(elHeight).to.be.greaterThan(regularRowHeight);
});
e2e.flows.scenes.saveDashboard();
cy.reload();
e2e.components.Panels.Panel.title('New panel')
.first()
.then((el) => {
expect(el.height()).be.closeTo(800, 5); // some flakyness and get 798 sometimes
});
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.customRowHeight().should('have.value', '800');
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.clearCustomRowHeight().should('be.visible').click();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight().should('have.value', 'Standard');
});
it('can change fill screen in auto grid layout', () => {
e2e.flows.scenes.importV2Dashboard({ title: 'Set fill screen' });
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.Panels.Panel.title('New panel').should('have.length', 3);
e2e.components.OptionsGroup.toggle('grid-layout-category').click();
e2e.flows.scenes.selectAutoGridLayout();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth().should('be.visible').click();
cy.get('[id=combobox-option-narrow]').click();
let initialHeight = 0;
e2e.components.Panels.Panel.title('New panel')
.first()
.then((el) => {
initialHeight = el.height();
});
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.fillScreen().click({ force: true });
const checkHeight = () => {
e2e.components.Panels.Panel.title('New panel')
.first()
.then((el) => {
expect(el.height()).to.be.greaterThan(initialHeight);
});
};
checkHeight();
e2e.flows.scenes.saveDashboard();
cy.reload();
checkHeight();
e2e.components.NavToolbar.editDashboard.editButton().click();
e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.fillScreen().should('be.checked');
checkHeight();
});
});
@@ -1,35 +0,0 @@
import { e2e } from '../utils';
const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard';
describe('Dashboard panels', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can remove a panel', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
e2e.flows.scenes.toggleEditMode();
e2e.flows.scenes.removePanels(/^Panel #1$/);
// Check that panel has been deleted
e2e.components.Panels.Panel.headerContainer()
.contains(/^Panel #1$/)
.should('not.exist');
});
it('can remove several panels at once', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
e2e.flows.scenes.toggleEditMode();
e2e.flows.scenes.removePanels(/^Panel #1$/, /^Panel #2$/, /^Panel #3$/);
// Check that panels have been deleted
e2e.components.Panels.Panel.headerContainer()
.contains(/^Panel #[123]$/)
.should('not.exist');
});
});
@@ -1,31 +0,0 @@
import { e2e } from '../utils';
const PAGE_UNDER_TEST = 'ed155665/annotation-filtering';
describe('Dashboard', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('can change dashboard description and title', () => {
e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` });
e2e.flows.scenes.toggleEditMode();
// Check that current dashboard title is visible in breadcrumb
cy.get('[aria-label="Breadcrumbs"]').contains('Annotation filtering').should('exist');
const titleInput = () => cy.get('[aria-label="dashboard-options Title field property editor"] input');
titleInput().should('have.value', 'Annotation filtering').clear().type('New dashboard title');
titleInput().should('have.value', 'New dashboard title');
// Check that new dashboard title is reflected in breadcrumb
cy.get('[aria-label="Breadcrumbs"]').contains('New dashboard title').should('exist');
// Check that we can successfully change the dashboard description
const descriptionTextArea = () =>
cy.get('[aria-label="dashboard-options Description field property editor"] textarea');
descriptionTextArea().clear().type('Dashboard description');
descriptionTextArea().should('have.value', 'Dashboard description');
});
});
@@ -13,7 +13,7 @@ describe('Geomap layer types', () => {
it('Tests changing the layer type', () => {
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
cy.get('[data-testid="layer-drag-drop-list"]').should('be.visible');
cy.get('[data-testid="layer-drag-drop-list"]').scrollIntoView().should('be.visible');
e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).should('be.visible');
cy.get('[data-testid="layer-drag-drop-list"]').contains('markers');
-23
View File
@@ -30,7 +30,6 @@ rootForEnterpriseSuite="./e2e/extensions"
rootForOldArch="./e2e/old-arch"
rootForKubernetesDashboards="./e2e/dashboards-suite"
rootForSearchDashboards="./e2e/dashboards-search-suite"
rootForDashboardNewLayouts="./e2e/dashboard-new-layouts"
declare -A cypressConfig=(
[screenshotsFolder]=./e2e/"${args[0]}"/screenshots
@@ -148,28 +147,6 @@ case "$1" in
;;
esac
;;
"dashboard-new-layouts")
env[kubernetesDashboards]=true
env[dashboardNewLayouts]=true
env[groupByVariable]=true
cypressConfig[specPattern]=$rootForDashboardNewLayouts/$testFilesForSingleSuite
cypressConfig[video]=false
case "$2" in
"debug")
echo -e "Debug mode"
env[SLOWMO]=1
PARAMS="--no-exit"
enterpriseSuite=$(basename "${args[2]}")
;;
"dev")
echo "Dev mode"
# remove comment to run in slomo ( demo mode )
# env[SLOWMO]=1
CMD="cypress open"
enterpriseSuite=$(basename "${args[2]}")
;;
esac
;;
"enterprise-smtp")
env[SMTP_PLUGIN_ENABLED]=true
cypressConfig[specPattern]=./e2e/extensions/enterprise/smtp-suite/$testFilesForSingleSuite
@@ -1,3 +0,0 @@
import { smokeTestScenario } from '../shared/smokeTestScenario';
smokeTestScenario();
@@ -1,38 +0,0 @@
import { GrafanaBootConfig } from '@grafana/runtime';
import { e2e } from '../utils';
describe('Panels smokescreen', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), false);
});
after(() => {
e2e.flows.revertAllChanges();
});
it('Tests each panel type in the panel edit view to ensure no crash', () => {
e2e.flows.addDashboard();
e2e.flows.addPanel({
dataSourceName: 'gdev-testdata',
timeout: 10000,
visitDashboardAtStart: false,
});
cy.window().then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
// Loop through every panel type and ensure no crash
Object.entries(win.grafanaBootData.settings.panels).forEach(([_, panel]) => {
// TODO: Remove Flame Graph check as part of addressing #66803
if (!panel.hideFromList && panel.state !== 'deprecated') {
e2e.components.PanelEditor.toggleVizPicker().click();
e2e.components.PluginVisualization.item(panel.name).scrollIntoView().should('be.visible').click();
e2e.components.PanelEditor.toggleVizPicker().should((e) => expect(e).to.contain(panel.name));
// TODO: Come up with better check / better failure messaging to clearly indicate which panel failed
cy.contains('An unexpected error happened').should('not.exist');
}
});
});
});
});
-14
View File
@@ -1,14 +0,0 @@
// very basic test to verify that the button story loads correctly
// this is only intended to catch some basic build errors with storybook
// NOTE: storybook must already be running (`yarn storybook`) for this test to work
describe('Verify storybook', () => {
it('Loads the button story correctly', () => {
cy.visit('?path=/story/inputs-button--basic');
getIframeBody().find('button:contains("Example button")').should('be.visible');
});
});
// see https://www.cypress.io/blog/2020/02/12/working-with-iframes-in-cypress
function getIframeBody() {
return cy.get('#storybook-preview-iframe').its('0.contentDocument.body').should('not.be.empty').then(cy.wrap);
}
-3
View File
@@ -1,3 +0,0 @@
import { smokeTestScenario } from '../../shared/smokeTestScenario';
smokeTestScenario();
+3 -4
View File
@@ -15,8 +15,6 @@
"e2e:old-arch": "./e2e/start-and-run-suite old-arch",
"e2e:schema-v2": "./e2e/start-and-run-suite dashboards-schema-v2",
"e2e:dashboards-search": "./e2e/start-and-run-suite dashboards-search",
"e2e:dashboard-new-layouts": "./e2e/start-and-run-suite dashboard-new-layouts",
"e2e:dashboard-new-layouts:dev": "./e2e/start-and-run-suite dashboard-new-layouts dev",
"e2e:debug": "./e2e/start-and-run-suite debug",
"e2e:dev": "./e2e/start-and-run-suite dev",
"e2e:benchmark:live": "./e2e/start-and-run-suite benchmark live",
@@ -25,6 +23,7 @@
"e2e:enterprise:debug": "./e2e/start-and-run-suite enterprise debug",
"e2e:playwright": "yarn playwright test",
"e2e:playwright:storybook": "yarn playwright test -c playwright.storybook.config.ts",
"e2e:acceptance": "yarn playwright test --grep @acceptance",
"e2e:storybook": "PORT=9001 ./e2e/run-suite storybook true",
"e2e:plugin:build": "nx run-many -t build --projects='@test-plugins/*'",
"e2e:plugin:build:dev": "nx run-many -t dev --projects='@test-plugins/*' --maxParallel=100",
@@ -239,7 +238,7 @@
"react-select-event": "5.5.1",
"redux-mock-store": "1.5.5",
"rimraf": "6.0.1",
"sass": "1.83.4",
"sass": "1.89.2",
"sass-loader": "16.0.5",
"smtp-tester": "^2.1.0",
"style-loader": "4.0.0",
@@ -272,7 +271,7 @@
"@formatjs/intl-durationformat": "^0.7.0",
"@glideapps/glide-data-grid": "^6.0.0",
"@grafana/alerting": "workspace:*",
"@grafana/assistant": "0.0.11",
"@grafana/assistant": "0.0.12",
"@grafana/aws-sdk": "0.7.1",
"@grafana/azure-sdk": "0.0.7",
"@grafana/data": "workspace:*",
@@ -186,7 +186,7 @@ class LightColors implements ThemeColorsBase<Partial<ThemeRichColor>> {
text = {
primary: `rgba(${this.blackBase}, 1)`,
secondary: `rgba(${this.blackBase}, 0.75)`,
disabled: `rgba(${this.blackBase}, 0.64)`,
disabled: `rgba(${this.blackBase}, 0.65)`,
link: this.primary.text,
maxContrast: palette.black,
};
@@ -80,6 +80,19 @@ describe('PromQueryField', () => {
expect(bcButton).toBeDisabled();
});
it('renders no metrics chooser if hidden by props', async () => {
const props = {
...defaultProps,
hideMetricsBrowser: true,
};
const queryField = render(<PromQueryField {...props} />);
// wait for component to render
await screen.findByTestId('dummy-code-input');
expect(queryField.queryByRole('button')).not.toBeInTheDocument();
});
it('renders an initial hint if no data and initial hint provided', async () => {
const props = defaultProps;
props.datasource.lookupsDisabled = true;
@@ -25,6 +25,7 @@ import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWr
interface PromQueryFieldProps extends QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions> {
ExtraFieldElement?: ReactNode;
hideMetricsBrowser?: boolean;
'data-testid'?: string;
}
@@ -40,6 +41,7 @@ export const PromQueryField = (props: PromQueryFieldProps) => {
range,
onChange,
onRunQuery,
hideMetricsBrowser = false,
} = props;
const theme = useTheme2();
@@ -111,20 +113,22 @@ export const PromQueryField = (props: PromQueryFieldProps) => {
className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1"
data-testid={props['data-testid']}
>
<button
className="gf-form-label query-keyword pointer"
onClick={onClickChooserButton}
disabled={datasource.lookupsDisabled}
type="button"
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.openButton}
>
{datasource.lookupsDisabled ? (
<Trans i18nKey="grafana-prometheus.metrics-browser.disabled-label">(Disabled)</Trans>
) : (
<Trans i18nKey="grafana-prometheus.metrics-browser.enabled-label">Metrics browser</Trans>
)}
<Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
</button>
{!hideMetricsBrowser && (
<button
className="gf-form-label query-keyword pointer"
onClick={onClickChooserButton}
disabled={datasource.lookupsDisabled}
type="button"
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.openButton}
>
{datasource.lookupsDisabled ? (
<Trans i18nKey="grafana-prometheus.metrics-browser.disabled-label">(Disabled)</Trans>
) : (
<Trans i18nKey="grafana-prometheus.metrics-browser.enabled-label">Metrics browser</Trans>
)}
<Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
</button>
)}
<div className="flex-grow-1 min-width-15">
<MonacoQueryFieldWrapper
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": "Zaškrtnutím této možnosti upřednostníte koncový bod řady s parametrem {{exampleParameter}} před koncovým bodem hodnot štítků s parametrem {{exampleParameter}}. Zatímco koncový bod hodnot štítků je považován za výkonnější, někteří uživatelé mohou upřednostňovat řady kvůli metodě POST, zatímco koncový bod hodnot štítků má pouze metodu GET."
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": "Wenn diese Option aktiviert ist, wird der Reihenendpunkt mit dem Parameter {{exampleParameter}} gegenüber dem Endpunkt der Label-Werte mit dem Parameter {{exampleParameter}} bevorzugt. Obwohl der Endpunkt der Label-Werte als leistungsfähiger gilt, könnten manche Nutzer die Reihe bevorzugen, da sie über eine POST-Methode verfügt, während der Endpunkt der Label-Werte nur über eine GET-Methode verfügt."
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": ""
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": "Cocher cette option favorisera le point de terminaison de la série avec le paramètre {{exampleParameter}} par rapport au point de terminaison des valeurs d’étiquette avec le paramètre {{exampleParameter}}. Bien que le point de terminaison des valeurs d’étiquette soit considéré comme plus performant, certains utilisateurs peuvent préférer la série, car elle dispose d’une méthode POST tandis que le point de terminaison des valeurs d’étiquette ne dispose que d’une méthode GET."
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": "Ennek az opciónak a bejelölésével előnyben részesíti a sorozat végpontját {{exampleParameter}} paraméterrel a címkeértékek végpontjához képest {{exampleParameter}} paraméterrel. Bár a címkeértékek végpontja hatékonyabbnak tekinthető, egyes felhasználók előnyben részesíthetik a sorozatot, mert rendelkezik POST-metódussal, míg a címkeértékek végpontja csak GET-metódussal rendelkezik."
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": "Memeriksa opsi ini akan mengutamakan titik akhir data seri dengan {{exampleParameter}} parameter daripada titik akhir nilai label dengan {{exampleParameter}} parameter. Meskipun titik akhir nilai label dianggap memiliki kinerja yang lebih baik, beberapa pengguna mungkin lebih menyukai data seri karena memiliki metode POST, sementara titik akhir nilai label hanya memiliki metode GET."
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": ""
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": "このオプションをオンにすると、{{exampleParameter}}パラメーターを持つラベル値エンドポイントより、{{exampleParameter}}パラメーターを持つ系列エンドポイントが優先されます。ラベル値エンドポイントの方がパフォーマンスが高いと考えられていますが、ラベル値エンドポイントにはGETメソッドしかないのに対し、系列にはPOSTメソッドがあるため、系列を好むユーザーもいます。"
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": ""
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": "Als je deze optie aanvinkt, wordt het serie-eindpunt met parameter {{exampleParameter}} de voorkeur gegeven boven het labelwaarden-eindpunt met parameter {{exampleParameter}}. Hoewel het eindpunt van de labelwaarden als beter presterend wordt beschouwd, geven sommige gebruikers de voorkeur aan de serie omdat deze een POST-methode heeft, terwijl het eindpunt van de labelwaarden alleen een GET-methode heeft."
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": ""
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": ""
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": ""
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": ""
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": ""
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": ""
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": "选中此选项将优先使用带有 {{exampleParameter}} 参数的序列端点,而不是带有 {{exampleParameter}} 参数的标签值端点。虽然标签值端点在性能方面表现更好,但有些用户可能更喜欢序列,因为它具有 POST 方法,而标签值端点仅具有 GET 方法。"
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -199,6 +199,10 @@
"tooltip-use-series-endpoint": "勾選此選項將優先使用具有 {{exampleParameter}} 參數的序列端點,而非具有 {{exampleParameter}} 參數的標籤值端點。雖然標籤值端點被視為擁有更佳效能,但有些使用者可能會更喜歡該序列,因為它具有 POST 方法,而標籤值端點僅具有 GET 方法。"
}
},
"metrics-browser": {
"disabled-label": "",
"enabled-label": ""
},
"prom-query-legend-editor": {
"get-legend-mode-options": {
"description-auto": "",
@@ -33,6 +33,7 @@ export interface MapViewConfig {
lon?: number;
maxZoom?: number;
minZoom?: number;
noRepeat?: boolean;
padding?: number;
shared?: boolean;
zoom?: number;
@@ -43,6 +44,7 @@ export const defaultMapViewConfig: Partial<MapViewConfig> = {
id: 'zero',
lat: 0,
lon: 0,
noRepeat: false,
zoom: 1,
};
@@ -6,6 +6,8 @@ export interface MapLayerOptions<TConfig = any> extends raw.MapLayerOptions {
// Custom options depending on the type
config?: TConfig;
filterData?: MatcherConfig;
// Disable world repetition for basemap layers
noRepeat?: boolean;
}
export interface DataQuery extends raw.DataQuery {
+1 -1
View File
@@ -131,7 +131,7 @@
"tslib": "2.8.1",
"uplot": "1.6.32",
"uuid": "11.1.0",
"uwrap": "0.1.1"
"uwrap": "0.1.2"
},
"devDependencies": {
"@babel/core": "7.28.0",
@@ -1,5 +1,5 @@
import { StoryFn, Meta } from '@storybook/react';
import { useState } from 'react';
import { useId, useState } from 'react';
import { Combobox } from '../Combobox/Combobox';
import { Checkbox } from '../Forms/Checkbox';
@@ -35,8 +35,6 @@ const meta: Meta = {
'validationMessageHorizontalOverflow',
],
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
argTypes: {
saveErrorMessage: { control: 'text' },
@@ -76,10 +74,12 @@ const themeOptions = [
export const Basic: StoryFn = (args) => {
const [inputValue, setInputValue] = useState('');
const id = useId();
return (
<AutoSaveField onFinishChange={args.inputSuccessful ? getSuccess : getError} {...args}>
{(onChange) => (
<Input
id={id}
value={inputValue}
onChange={(e) => {
const value = e.currentTarget.value;
@@ -105,12 +105,19 @@ export const AllComponents: StoryFn = (args) => {
const [checkBoxValue, setCheckBoxValue] = useState(false);
const [textAreaValue, setTextAreaValue] = useState('');
const [switchValue, setSwitchValue] = useState(false);
const textId = useId();
const comboboxId = useId();
const radioButtonId = useId();
const checkBoxId = useId();
const textAreaId = useId();
const switchId = useId();
return (
<div>
<AutoSaveField onFinishChange={args.inputSuccessful ? getSuccess : getError} label="Text as a child" {...args}>
{(onChange) => (
<Input
id={textId}
value={inputTextValue}
onChange={(e) => {
const value = e.currentTarget.value;
@@ -123,6 +130,7 @@ export const AllComponents: StoryFn = (args) => {
<AutoSaveField onFinishChange={args.inputSuccessful ? getSuccess : getError} label="Combobox as child" {...args}>
{(onChange) => (
<Combobox
id={comboboxId}
options={themeOptions}
value={comoboboxValue}
onChange={(v) => {
@@ -139,6 +147,7 @@ export const AllComponents: StoryFn = (args) => {
>
{(onChange) => (
<RadioButtonGroup
id={radioButtonId}
options={themeOptions}
value={radioButtonValue}
onChange={(themeOption) => {
@@ -155,6 +164,7 @@ export const AllComponents: StoryFn = (args) => {
>
{(onChange) => (
<Checkbox
id={checkBoxId}
label="Checkbox test"
description="This is a checkbox input"
name="checkbox-test"
@@ -174,6 +184,7 @@ export const AllComponents: StoryFn = (args) => {
>
{(onChange) => (
<TextArea
id={textAreaId}
value={textAreaValue}
onChange={(e) => {
const value = e.currentTarget.value;
@@ -190,6 +201,7 @@ export const AllComponents: StoryFn = (args) => {
>
{(onChange) => (
<Switch
id={switchId}
label="Switch test"
name="switch-test"
value={switchValue}
@@ -18,8 +18,6 @@ export default {
docs: {
page: mdx,
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
argTypes: {
size: {
@@ -81,25 +79,25 @@ export const Examples: StoryFn<typeof Button> = () => {
<div>Inside ButtonGroup</div>
<ButtonGroup>
<Button icon="sync">Run query</Button>
<Button icon="angle-down" />
<Button icon="angle-down" aria-label="Expand menu" />
</ButtonGroup>
<ButtonGroup>
<Button variant="destructive" icon="sync">
Run query
</Button>
<Button variant="destructive" icon="angle-down" />
<Button variant="destructive" icon="angle-down" aria-label="Expand menu" />
</ButtonGroup>
<ButtonGroup>
<Button variant="success" icon="sync">
Run query
</Button>
<Button variant="success" icon="angle-down" />
<Button variant="success" icon="angle-down" aria-label="Expand menu" />
</ButtonGroup>
<ButtonGroup>
<Button variant="secondary" icon="sync">
Run query
</Button>
<Button variant="secondary" icon="angle-down" />
<Button variant="secondary" icon="angle-down" aria-label="Expand menu" />
</ButtonGroup>
</Stack>
<Card noMargin>
@@ -19,8 +19,6 @@ const meta: Meta<typeof Carousel> = {
component: Carousel,
parameters: {
docs: { page: mdx },
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
argTypes: {
images: {
@@ -24,9 +24,10 @@ describe('Carousel', () => {
render(<Carousel images={testImages} />);
testImages.forEach((image) => {
const imgElement = screen.getByAltText(image.name);
expect(imgElement).toBeInTheDocument();
expect(imgElement).toHaveAttribute('src', image.path);
const name = screen.getByText(image.name);
expect(name).toBeInTheDocument();
const imageElement = document.querySelector(`img[src="${image.path}"]`);
expect(imageElement).toBeInTheDocument();
});
});
@@ -140,7 +141,7 @@ describe('Carousel', () => {
render(<Carousel images={testImages} />);
const images = screen.getAllByRole('img');
const images = screen.getAllByRole('presentation');
images.forEach((img) => {
fireEvent.error(img);
});
@@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { OverlayContainer, useOverlay } from '@react-aria/overlays';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useId } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
@@ -26,6 +26,7 @@ export const Carousel: React.FC<CarouselProps> = ({ images }) => {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({});
const [validImages, setValidImages] = useState<CarouselImage[]>(images);
const id = useId();
const styles = useStyles2(getStyles);
const resetButtonStyles = useStyles2(clearButtonStyles);
@@ -100,17 +101,22 @@ export const Carousel: React.FC<CarouselProps> = ({ images }) => {
return (
<>
<div className={cx(styles.imageGrid)}>
{validImages.map((image, index) => (
<button
type="button"
key={image.path}
onClick={() => openPreview(index)}
className={cx(resetButtonStyles, styles.imageButton)}
>
<img src={image.path} alt={image.name} onError={() => handleImageError(image.path)} />
<p>{image.name}</p>
</button>
))}
{validImages.map((image, index) => {
const imageNameId = `${id}-carousel-image-${index}`;
return (
<button
aria-label={t('grafana-ui.carousel.aria-label-open-image', 'Open image preview')}
aria-describedby={imageNameId}
type="button"
key={image.path}
onClick={() => openPreview(index)}
className={cx(resetButtonStyles, styles.imageButton)}
>
<img src={image.path} alt="" onError={() => handleImageError(image.path)} />
<p id={imageNameId}>{image.name}</p>
</button>
);
})}
</div>
{selectedIndex !== null && (
@@ -1,5 +1,7 @@
import { StoryFn, Meta } from '@storybook/react';
import { useState } from 'react';
import { useId, useState } from 'react';
import { Field } from '../Forms/Field';
import { Cascader, CascaderOption } from './Cascader';
import mdx from './Cascader.mdx';
@@ -49,8 +51,6 @@ const meta: Meta<typeof Cascader> = {
'formatCreateLabel',
],
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
args: {
onSelect,
@@ -61,7 +61,14 @@ const meta: Meta<typeof Cascader> = {
},
};
const Template: StoryFn<typeof Cascader> = (args) => <Cascader {...args} />;
const Template: StoryFn<typeof Cascader> = (args) => {
const id = useId();
return (
<Field label="Cascader field">
<Cascader {...args} id={id} />
</Field>
);
};
export const Simple = Template.bind({});
Simple.args = {
@@ -93,10 +100,15 @@ export const WithOptionsStateUpdate = () => {
value: 'initial',
},
]);
const id = useId();
setTimeout(() => setOptions(options), 2000);
return <Cascader options={updatedOptions} onSelect={onSelect} />;
return (
<Field label="Cascader field with updated options">
<Cascader options={updatedOptions} onSelect={onSelect} id={id} />
</Field>
);
};
export default meta;
@@ -1,6 +1,9 @@
import { action } from '@storybook/addon-actions';
import { useArgs } from '@storybook/preview-api';
import { Meta, StoryFn } from '@storybook/react';
import { useId } from 'react';
import { Field } from '../Forms/Field';
import { ColorPickerInput } from './ColorPickerInput';
@@ -11,22 +14,24 @@ const meta: Meta<typeof ColorPickerInput> = {
controls: {
exclude: ['onChange', 'onColorChange'],
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
};
export const Basic: StoryFn<typeof ColorPickerInput> = ({ color }) => {
const [, updateArgs] = useArgs();
const id = useId();
return (
<div style={{ minHeight: '100dvh', display: 'grid', placeContent: 'center' }}>
<ColorPickerInput
value={color}
onChange={(color) => {
action('Color changed')(color);
updateArgs({ color });
}}
/>
<Field label="Select color">
<ColorPickerInput
id={id}
value={color}
onChange={(color) => {
action('Color changed')(color);
updateArgs({ color });
}}
/>
</Field>
</div>
);
};
@@ -21,8 +21,6 @@ const meta: Meta = {
controls: {
exclude: ['className', 'onClick', 'onCancel', 'onConfirm'],
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
args: {
buttonText: 'Edit',
@@ -27,7 +27,12 @@ export const DeleteButton = ({ size, disabled, onConfirm, 'aria-label': ariaLabe
onConfirm={onConfirm}
closeOnConfirm={closeOnConfirm}
>
<Button aria-label={ariaLabel} variant="destructive" icon="times" size={size || 'sm'} />
<Button
aria-label={ariaLabel ?? t('grafana-ui.confirm-button.aria-label-delete', 'Delete')}
variant="destructive"
icon="times"
size={size || 'sm'}
/>
</ConfirmButton>
);
};
@@ -3,6 +3,7 @@ import { StoryFn, Meta } from '@storybook/react';
import * as React from 'react';
import { IconButton } from '../IconButton/IconButton';
import { Text } from '../Text/Text';
import { ContextMenu, ContextMenuProps } from './ContextMenu';
import mdx from './ContextMenu.mdx';
@@ -19,8 +20,6 @@ const meta: Meta<typeof ContextMenu> = {
controls: {
exclude: ['renderMenuItems', 'renderHeader', 'onClose', 'children'],
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
args: {
x: 200,
@@ -31,7 +30,7 @@ const meta: Meta<typeof ContextMenu> = {
};
const renderHeader = (): React.ReactNode => {
return <h6>Menu</h6>;
return <Text variant="h6">Menu</Text>;
};
export const Basic: StoryFn<typeof ContextMenu> = (args: ContextMenuProps) => {
@@ -1,9 +1,12 @@
import { action } from '@storybook/addon-actions';
import { useArgs } from '@storybook/preview-api';
import { Meta, StoryFn } from '@storybook/react';
import { useId } from 'react';
import { dateTime } from '@grafana/data';
import { Field } from '../Forms/Field';
import { TimeOfDayPicker } from './TimeOfDayPicker';
const meta: Meta<typeof TimeOfDayPicker> = {
@@ -13,8 +16,6 @@ const meta: Meta<typeof TimeOfDayPicker> = {
controls: {
exclude: ['onChange'],
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
args: {
value: dateTime(Date.now()),
@@ -24,14 +25,18 @@ const meta: Meta<typeof TimeOfDayPicker> = {
export const Basic: StoryFn<typeof TimeOfDayPicker> = (args) => {
const [, updateArgs] = useArgs();
const id = useId();
return (
<TimeOfDayPicker
{...args}
onChange={(newValue?) => {
action('on selected')(newValue);
updateArgs({ value: newValue });
}}
/>
<Field label="Select a time">
<TimeOfDayPicker
{...args}
id={id}
onChange={(newValue?) => {
action('on selected')(newValue);
updateArgs({ value: newValue });
}}
/>
</Field>
);
};
@@ -26,6 +26,7 @@ interface BaseProps {
disabledSeconds?: () => number[];
placeholder?: string;
allowEmpty?: boolean;
id?: string;
}
interface AllowEmptyProps extends BaseProps {
@@ -52,6 +53,7 @@ export const TimeOfDayPicker = ({
disabledHours,
disabledMinutes,
disabledSeconds,
id,
placeholder,
// note: we can't destructure allowEmpty/onChange here
// in order to discriminate the types properly later in the onChange handler
@@ -62,6 +64,7 @@ export const TimeOfDayPicker = ({
return (
<RcPicker<Moment>
id={id}
generateConfig={generateConfig}
locale={locale}
allowClear={
@@ -1,5 +1,5 @@
import { Meta, StoryFn } from '@storybook/react';
import { useState, useCallback } from 'react';
import { useState, useCallback, useId } from 'react';
import * as React from 'react';
import { Input } from '../Input/Input';
@@ -26,18 +26,19 @@ const meta: Meta<typeof Field> = {
controls: {
exclude: ['children', 'className'],
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
};
export const Simple: StoryFn<typeof Field> = (args) => (
<div>
<Field {...args}>
<Input id="thisField" />
</Field>
</div>
);
export const Simple: StoryFn<typeof Field> = (args) => {
const id = useId();
return (
<div>
<Field {...args}>
<Input id={id} />
</Field>
</div>
);
};
Simple.args = {
label: 'Graphite API key',
@@ -50,6 +51,7 @@ Simple.args = {
};
export const HorizontalLayout: StoryFn<typeof Field> = (args) => {
const id = useId();
const [checked, setChecked] = useState(false);
const onChange = useCallback(
(e: React.FormEvent<HTMLInputElement>) => setChecked(e.currentTarget.checked),
@@ -58,7 +60,7 @@ export const HorizontalLayout: StoryFn<typeof Field> = (args) => {
return (
<div>
<Field {...args}>
<Switch checked={checked} onChange={onChange} />
<Switch checked={checked} onChange={onChange} id={id} />
</Field>
</div>
);
@@ -1,4 +1,5 @@
import { Meta, StoryFn } from '@storybook/react';
import { useId } from 'react';
import { Button } from '../Button/Button';
import { Input } from '../Input/Input';
@@ -21,8 +22,6 @@ const meta: Meta<typeof FieldSet> = {
controls: {
exclude: ['children'],
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
argTypes: {
label: { control: 'text' },
@@ -30,22 +29,26 @@ const meta: Meta<typeof FieldSet> = {
};
export const Basic: StoryFn<typeof FieldSet> = (args: Props) => {
const nameId = useId();
const emailId = useId();
const colorId = useId();
const fontSizeId = useId();
return (
<Form onSubmit={() => console.log('Submit')}>
{() => (
<>
<FieldSet {...args}>
<Field label="Name">
<Input name="name" />
<Input name="name" id={nameId} />
</Field>
<Field label="Email">
<Input name="email" />
<Input name="email" id={emailId} />
</Field>
<Field label="Color">
<Input name="color" />
<Input name="color" id={colorId} />
</Field>
<Field label="Font size">
<Input name="fontsize" />
<Input name="fontsize" id={fontSizeId} />
</Field>
</FieldSet>
<Button variant="primary">Save</Button>
@@ -1,4 +1,5 @@
import { StoryFn } from '@storybook/react';
import { useId } from 'react';
import { ValidateResult } from 'react-hook-form';
import { withStoryContainer } from '../../utils/storybook/withStoryContainer';
@@ -23,8 +24,6 @@ export default {
docs: {
page: mdx,
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
};
@@ -57,71 +56,82 @@ interface FormDTO {
};
}
const renderForm = (defaultValues?: FormDTO) => (
<Form
defaultValues={defaultValues}
onSubmit={(data: FormDTO) => {
console.log(data);
}}
>
{({ register, control, errors }) => {
console.log(errors);
return (
<>
<Legend>Edit user</Legend>
const renderForm = (defaultValues?: FormDTO) => {
const nameId = useId();
const emailId = useId();
const usernameId = useId();
const nestedPathId = useId();
const textId = useId();
const checkboxId = useId();
const switchId = useId();
const radioId = useId();
const selectId = useId();
return (
<Form
defaultValues={defaultValues}
onSubmit={(data: FormDTO) => {
console.log(data);
}}
>
{({ register, control, errors }) => {
console.log(errors);
return (
<>
<Legend>Edit user</Legend>
<Field label="Name" invalid={!!errors.name} error="Name is required">
<Input {...register('name', { required: true })} placeholder="Roger Waters" />
</Field>
<Field label="Name" invalid={!!errors.name} error="Name is required">
<Input {...register('name', { required: true })} placeholder="Roger Waters" id={nameId} />
</Field>
<Field label="Email" invalid={!!errors.email} error="E-mail is required">
<Input {...register('email', { required: true })} id="email" placeholder="roger.waters@grafana.com" />
</Field>
<Field label="Email" invalid={!!errors.email} error="E-mail is required">
<Input {...register('email', { required: true })} id={emailId} placeholder="roger.waters@grafana.com" />
</Field>
<Field label="Username">
<Input {...register('username')} placeholder="mr.waters" />
</Field>
<Field label="Nested object">
<Input {...register('nested.path')} placeholder="Nested path" />
</Field>
<Field label="Username">
<Input {...register('username')} placeholder="mr.waters" id={usernameId} />
</Field>
<Field label="Nested object">
<Input {...register('nested.path')} placeholder="Nested path" id={nestedPathId} />
</Field>
<Field label="Textarea" invalid={!!errors.text} error="Text is required">
<TextArea {...register('text', { required: true })} placeholder="Long text" />
</Field>
<Field label="Textarea" invalid={!!errors.text} error="Text is required">
<TextArea {...register('text', { required: true })} placeholder="Long text" id={textId} />
</Field>
<Field label="Checkbox" invalid={!!errors.checkbox} error="We need your consent">
<Checkbox {...register('checkbox', { required: true })} label="Do you consent?" />
</Field>
<Field label="Checkbox" invalid={!!errors.checkbox} error="We need your consent">
<Checkbox {...register('checkbox', { required: true })} label="Do you consent?" id={checkboxId} />
</Field>
<Field label="Switch">
<Switch name="switch" {...register} />
</Field>
<Field label="Switch">
<Switch name="switch" {...register} id={switchId} />
</Field>
<Field label="RadioButton">
<InputControl
name="radio"
control={control}
render={({ field }) => <RadioButtonGroup {...field} options={selectOptions} />}
/>
</Field>
<Field label="RadioButton" htmlFor={radioId}>
<InputControl
name="radio"
control={control}
render={({ field }) => <RadioButtonGroup {...field} options={selectOptions} id={radioId} />}
/>
</Field>
<Field label="Select" invalid={!!errors.select} error="Select is required">
<InputControl
name="select"
control={control}
rules={{
required: true,
}}
render={({ field }) => <Select {...field} options={selectOptions} />}
/>
</Field>
<Field label="Select" invalid={!!errors.select} error="Select is required" htmlFor={selectId}>
<InputControl
name="select"
control={control}
rules={{
required: true,
}}
render={({ field }) => <Select {...field} options={selectOptions} inputId={selectId} />}
/>
</Field>
<Button type="submit">Update</Button>
</>
);
}}
</Form>
);
<Button type="submit">Update</Button>
</>
);
}}
</Form>
);
};
export const Basic = () => {
return <>{renderForm()}</>;
@@ -1,5 +1,5 @@
import { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
import { useId, useState } from 'react';
import { Combobox } from '../Combobox/Combobox';
import { Input } from '../Input/Input';
@@ -23,15 +23,14 @@ const meta: Meta<typeof InlineField> = {
controls: {
exclude: ['htmlFor', 'className', 'children'],
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
};
export const basic: StoryFn<typeof InlineField> = (args) => {
const id = useId();
return (
<InlineField {...args}>
<Input placeholder="Inline input" />
<Input placeholder="Inline input" id={id} />
</InlineField>
);
};
@@ -50,9 +49,10 @@ basic.args = {
};
export const withTooltip: StoryFn<typeof InlineField> = (args) => {
const id = useId();
return (
<InlineField {...args}>
<Input placeholder="Inline input" />
<Input placeholder="Inline input" id={id} />
</InlineField>
);
};
@@ -64,9 +64,10 @@ withTooltip.args = {
};
export const grow: StoryFn<typeof InlineField> = (args) => {
const id = useId();
return (
<InlineField {...args}>
<Input placeholder="Inline input" />
<Input placeholder="Inline input" id={id} />
</InlineField>
);
};
@@ -83,9 +84,10 @@ export const withCombobox: StoryFn<typeof InlineField> = (args) => {
{ value: 2, label: 'Two' },
];
const [selected, setSelected] = useState(1);
const id = useId();
return (
<InlineField {...args}>
<Combobox width={16} onChange={(v) => setSelected(v.value)} options={comboboxOptions} value={selected} />
<Combobox width={16} onChange={(v) => setSelected(v.value)} options={comboboxOptions} value={selected} id={id} />
</InlineField>
);
};
@@ -96,25 +98,29 @@ withCombobox.args = {
};
export const multiple: StoryFn<typeof InlineField> = () => {
const id1 = useId();
const id2 = useId();
const id3 = useId();
return (
<>
<InlineField label="Field 1">
<Input placeholder="Inline input" />
<Input placeholder="Inline input" id={id1} />
</InlineField>
<InlineField label="Field 2">
<Input placeholder="Inline input" />
<Input placeholder="Inline input" id={id2} />
</InlineField>
<InlineField label="Field 3">
<Input placeholder="Inline input" />
<Input placeholder="Inline input" id={id3} />
</InlineField>
</>
);
};
export const error: StoryFn<typeof InlineField> = (args) => {
const id = useId();
return (
<InlineField {...args}>
<Input placeholder="Inline input" />
<Input placeholder="Inline input" id={id} />
</InlineField>
);
};
@@ -1,7 +1,8 @@
import { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
import { useId, useState } from 'react';
import { ClipboardButton } from '../ClipboardButton/ClipboardButton';
import { Field } from '../Forms/Field';
import { Input } from '../Input/Input';
import { InlineToast as InlineToastImpl, InlineToastProps } from './InlineToast';
@@ -14,8 +15,6 @@ const story: Meta = {
docs: {
page: mdx,
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
argTypes: {
referenceElement: {
@@ -30,13 +29,16 @@ export default story;
export const InlineToast: StoryFn<InlineToastProps> = (args) => {
const [el, setEl] = useState<null | HTMLInputElement>(null);
const id = useId();
return (
<div style={{ maxWidth: 500, width: `calc(100% - 100px)` }}>
<InlineToastImpl {...args} referenceElement={el}>
Saved
</InlineToastImpl>
<Input ref={setEl} />
<Field label="Input with InlineToast">
<Input ref={setEl} id={id} />
</Field>
</div>
);
};
@@ -1,5 +1,5 @@
import { StoryFn, Meta } from '@storybook/react';
import { useState } from 'react';
import { useId, useState } from 'react';
import { KeyValue } from '@grafana/data';
@@ -31,8 +31,6 @@ const meta: Meta = {
controls: {
exclude: ['prefix', 'suffix', 'addonBefore', 'addonAfter'],
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
args: {
type: 'text',
@@ -73,20 +71,24 @@ export const Simple: StoryFn = (args) => {
const addonBefore = <div style={{ display: 'flex', alignItems: 'center', padding: '5px' }}>Input</div>;
const prefix = parseAccessory(args.prefixVisible);
const suffix = parseAccessory(args.suffixVisible);
const id = useId();
return (
<Input
disabled={args.disabled}
width={args.width}
prefix={prefix}
invalid={args.invalid}
suffix={suffix}
loading={args.loading}
addonBefore={args.before && addonBefore}
addonAfter={args.after && addonAfter}
type={args.type}
placeholder={args.placeholder}
/>
<Field label="Simple input">
<Input
id={id}
disabled={args.disabled}
width={args.width}
prefix={prefix}
invalid={args.invalid}
suffix={suffix}
loading={args.loading}
addonBefore={args.before && addonBefore}
addonAfter={args.after && addonAfter}
type={args.type}
placeholder={args.placeholder}
/>
</Field>
);
};
Simple.args = {
@@ -98,11 +100,12 @@ Simple.args = {
export const WithFieldValidation: StoryFn = (args) => {
const [value, setValue] = useState('');
const id = useId();
return (
<div>
<Field invalid={value === ''} error={value === '' ? 'This input is required' : ''}>
<Input value={value} onChange={(e) => setValue(e.currentTarget.value)} {...args} />
<Field invalid={value === ''} error={value === '' ? 'This input is required' : ''} label="Input with validation">
<Input id={id} value={value} onChange={(e) => setValue(e.currentTarget.value)} {...args} />
</Field>
</div>
);
@@ -1,7 +1,7 @@
import { Meta, StoryFn } from '@storybook/react';
import { useTheme2 } from '../../../themes/ThemeContext';
import { SpacingTokenControl } from '../../../utils/storybook/themeStorybookControls';
import { Card } from '../../Card/Card';
import { Grid } from './Grid';
import mdx from './Grid.mdx';
@@ -17,8 +17,6 @@ const meta: Meta<typeof Grid> = {
docs: {
page: mdx,
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
args: {
gap: 1,
@@ -26,13 +24,12 @@ const meta: Meta<typeof Grid> = {
};
export const ColumnsNumber: StoryFn<typeof Grid> = (args) => {
const theme = useTheme2();
return (
<Grid {...args}>
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} style={{ background: theme.colors.background.secondary, textAlign: 'center', ...dimensions[i] }}>
N# {i}
</div>
<Card key={i} style={dimensions[i]}>
<Card.Heading>N# {i}</Card.Heading>
</Card>
))}
</Grid>
);
@@ -56,13 +53,12 @@ ColumnsNumber.parameters = {
};
export const ColumnsMinWidth: StoryFn<typeof Grid> = (args) => {
const theme = useTheme2();
return (
<Grid {...args}>
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} style={{ background: theme.colors.background.secondary, textAlign: 'center' }}>
N# {i}
</div>
<Card key={i}>
<Card.Heading>N# {i}</Card.Heading>
</Card>
))}
</Grid>
);
@@ -17,8 +17,6 @@ const meta: Meta<typeof LoadingBar> = {
docs: {
page: mdx,
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
};
@@ -27,7 +27,7 @@ export function LoadingBar({ width, delay = DEFAULT_ANIMATION_DELAY, ariaLabel =
return (
<div style={containerStyles}>
<div aria-label={ariaLabel} className={styles.bar} />
<div aria-label={ariaLabel} role="status" className={styles.bar} />
</div>
);
}
@@ -223,7 +223,7 @@ export const Examples = () => {
<Button size="sm" variant="secondary" key="A">
Breakdown
</Button>,
<Button size="sm" variant="secondary" icon="times" key="B" />,
<Button aria-label="Close" size="sm" variant="secondary" icon="times" key="B" />,
],
})}
{renderPanel('With radio button', {
@@ -12,7 +12,7 @@ import { SegmentSection } from './SegmentSection';
const AddButton = (
<span className="gf-form-label query-part">
<Icon name="plus-circle" />
<Icon aria-label="Add" name="plus-circle" />
</span>
);
@@ -62,10 +62,6 @@ export const ArrayOptions = () => {
const meta: Meta<typeof Segment> = {
title: 'Inputs/Segment',
component: Segment,
parameters: {
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
};
export const ArrayOptionsWithPrimitiveValue = () => {
@@ -13,7 +13,7 @@ import { SegmentSection } from './SegmentSection';
const AddButton = (
<span className="gf-form-label query-part">
<Icon name="plus" />
<Icon aria-label="Add" name="plus" />
</span>
);
@@ -63,10 +63,6 @@ export const ArrayOptions = () => {
const meta: Meta<typeof SegmentAsync> = {
title: 'Inputs/SegmentAsync',
component: SegmentAsync,
parameters: {
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
};
export const ArrayOptionsWithPrimitiveValue = () => {
@@ -32,10 +32,6 @@ export const BasicInput = () => {
const meta: Meta<typeof SegmentInput> = {
title: 'Inputs/SegmentInput',
component: SegmentInput,
parameters: {
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
};
export const BasicInputWithPlaceholder = () => {
@@ -98,6 +94,7 @@ export const InputWithAutoFocus = () => {
<InputComponent initialValue="test" key={i} />
))}
<button
aria-label="Add"
type="button"
className="gf-form-label query-part"
onClick={() => {
@@ -1,10 +1,8 @@
import { css } from '@emotion/css';
import { GrafanaTheme, GrafanaTheme2 } from '@grafana/data';
export const getSegmentStyles = (theme: GrafanaTheme | GrafanaTheme2) => {
const palette = 'v1' in theme ? theme.v1.palette : theme.palette;
import { GrafanaTheme2 } from '@grafana/data';
export const getSegmentStyles = (theme: GrafanaTheme2) => {
return {
segment: css({
cursor: 'pointer',
@@ -12,7 +10,7 @@ export const getSegmentStyles = (theme: GrafanaTheme | GrafanaTheme2) => {
}),
queryPlaceholder: css({
color: palette.gray2,
color: theme.colors.text.disabled,
}),
disabled: css({
@@ -1,10 +1,10 @@
import { Meta } from '@storybook/react';
import { useMemo } from 'react';
import { useId, useMemo } from 'react';
import ReactSelect, { createFilter } from 'react-select';
import { SelectableValue } from '@grafana/data';
import { Label } from '../Forms/Label';
import { Field } from '../Forms/Field';
import { Select, VirtualizedSelect } from './Select';
@@ -16,10 +16,6 @@ const meta: Meta = {
control: { type: 'number' },
},
},
parameters: {
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
};
export default meta;
@@ -37,6 +33,10 @@ function customFilter(opt: SelectableValue, searchQuery: string) {
}
export function PerformanceScenarios({ numberOfOptions }: { numberOfOptions: number }) {
const virtualId = useId();
const virtualIgnoreAccentsId = useId();
const normalId = useId();
const standardId = useId();
const options = useMemo(() => {
const opts: SelectableValue[] = [];
const ALPHABET = 'qwertyuiopasdfghjklzxcvbnm'.split('');
@@ -51,20 +51,29 @@ export function PerformanceScenarios({ numberOfOptions }: { numberOfOptions: num
return (
<div>
<Label>Virtual:</Label>
<VirtualizedSelect options={options} onChange={() => {}} />
<Field label="Virtual:">
<VirtualizedSelect inputId={virtualId} options={options} onChange={() => {}} />
</Field>
<br />
<Label>Virtual with ignoreAccents false:</Label>
<VirtualizedSelect filterOption={customFilter} options={options} onChange={() => {}} />
<Field label="Virtual with ignoreAccents false:">
<VirtualizedSelect
inputId={virtualIgnoreAccentsId}
filterOption={customFilter}
options={options}
onChange={() => {}}
/>
</Field>
<br />
<Label>Normal:</Label>
<Select options={options} onChange={() => {}} />
<Field label="Normal:">
<Select inputId={normalId} options={options} onChange={() => {}} />
</Field>
<br />
<Label>Standard react-select</Label>
<ReactSelect options={options} onChange={() => {}} />
<Field label="Standard react-select">
<ReactSelect inputId={standardId} options={options} onChange={() => {}} />
</Field>
<br />
<p>Rendered with {options.length.toLocaleString()} options</p>
@@ -2,6 +2,8 @@ import { action } from '@storybook/addon-actions';
import { Meta, StoryFn } from '@storybook/react';
import { PureComponent } from 'react';
import { Field } from '../Forms/Field';
import { Props, StatsPicker } from './StatsPicker';
interface State {
@@ -21,17 +23,20 @@ class WrapperWithState extends PureComponent<Props, State> {
const { stats } = this.state;
return (
<StatsPicker
placeholder={placeholder}
allowMultiple={allowMultiple}
stats={stats}
onChange={(stats: string[]) => {
action('Picked:')(stats);
this.setState({ stats });
}}
menuPlacement={menuPlacement}
width={width}
/>
<Field label="Pick stats">
<StatsPicker
inputId="stats-picker"
placeholder={placeholder}
allowMultiple={allowMultiple}
stats={stats}
onChange={(stats: string[]) => {
action('Picked:')(stats);
this.setState({ stats });
}}
menuPlacement={menuPlacement}
width={width}
/>
</Field>
);
}
}
@@ -43,8 +48,6 @@ const meta: Meta<typeof StatsPicker> = {
controls: {
exclude: ['onChange', 'stats', 'defaultStat', 'className'],
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
};
@@ -1,5 +1,5 @@
import { Meta, StoryFn } from '@storybook/react';
import { useState, useCallback } from 'react';
import { useState, useCallback, useId } from 'react';
import * as React from 'react';
import { Field } from '../Forms/Field';
@@ -16,8 +16,6 @@ const meta: Meta<typeof Switch> = {
docs: {
page: mdx,
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
args: {
disabled: false,
@@ -63,7 +61,12 @@ export const Uncontrolled: StoryFn<typeof Switch> = (args) => {
(e: React.FormEvent<HTMLInputElement>) => setChecked(e.currentTarget.checked),
[setChecked]
);
return <Switch value={checked} disabled={args.disabled} onChange={onChange} invalid={args.invalid} />;
const id = useId();
return (
<Field label="Uncontrolled switch" disabled={args.disabled} invalid={args.invalid}>
<Switch id={id} value={checked} onChange={onChange} />
</Field>
);
};
export default meta;
@@ -8,11 +8,8 @@ import { TableCellDisplayMode } from '../../types';
import { MaybeWrapWithLink } from '../MaybeWrapWithLink';
import { ImageCellProps } from '../types';
const DATALINKS_HEIGHT_OFFSET = 10;
export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx }: ImageCellProps) => {
const calculatedHeight = height - DATALINKS_HEIGHT_OFFSET;
const styles = useStyles2(getStyles, calculatedHeight, justifyContent);
const styles = useStyles2(getStyles, height, justifyContent);
const { text } = field.display!(value);
const { alt, title } =
@@ -27,7 +24,7 @@ export const ImageCell = ({ cellOptions, field, height, justifyContent, value, r
);
};
const getStyles = (theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({
const getStyles = (_theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({
image: css({
height,
width: 'auto',
@@ -26,7 +26,7 @@ import {
ReducerID,
} from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { FieldColorModeId, TableCellHeight } from '@grafana/schema';
import { FieldColorModeId } from '@grafana/schema';
import { useStyles2, useTheme2 } from '../../../themes/ThemeContext';
import { ContextMenu } from '../../ContextMenu/ContextMenu';
@@ -52,29 +52,30 @@ import {
useRowHeight,
useScrollbarWidth,
useSortedRows,
useTypographyCtx,
} from './hooks';
import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps } from './types';
import {
applySort,
computeColWidths,
createTypographyContext,
displayJsonValue,
extractPixelValue,
frameToRecords,
getAlignment,
getApplyToRowBgFn,
getCellColors,
getCellLinks,
getCellOptions,
getDefaultRowHeight,
getDisplayName,
getIsNestedTable,
getVisibleFields,
shouldTextOverflow,
getApplyToRowBgFn,
computeColWidths,
applySort,
getCellColors,
getCellOptions,
shouldTextWrap,
isCellInspectEnabled,
getCellLinks,
withDataLinksActionsTooltip,
displayJsonValue,
getAlignment,
getJustifyContent,
getVisibleFields,
isCellInspectEnabled,
shouldTextOverflow,
shouldTextWrap,
TextAlign,
withDataLinksActionsTooltip,
} from './utils';
type CellRootRenderer = (key: React.Key, props: CellRendererProps<TableRow, TableSummaryRow>) => React.ReactNode;
@@ -160,7 +161,6 @@ export function TableNG(props: TableNGProps) {
} = useSortedRows(filteredRows, data.fields, { hasNestedFrames, initialSortBy });
const defaultRowHeight = getDefaultRowHeight(theme, cellHeight);
const defaultHeaderHeight = getDefaultRowHeight(theme, TableCellHeight.Sm);
const [isInspecting, setIsInspecting] = useState(false);
const [expandedRows, setExpandedRows] = useState(() => new Set<number>());
@@ -172,13 +172,20 @@ export function TableNG(props: TableNGProps) {
() => (hasNestedFrames ? width - COLUMN.EXPANDER_WIDTH : width) - scrollbarWidth,
[width, hasNestedFrames, scrollbarWidth]
);
const typographyCtx = useTypographyCtx();
const typographyCtx = useMemo(
() =>
createTypographyContext(
theme.typography.fontSize,
theme.typography.fontFamily,
extractPixelValue(theme.typography.body.letterSpacing!) * theme.typography.fontSize
),
[theme]
);
const widths = useMemo(() => computeColWidths(visibleFields, availableWidth), [visibleFields, availableWidth]);
const headerHeight = useHeaderHeight({
columnWidths: widths,
fields: visibleFields,
enabled: hasHeader,
defaultHeight: defaultHeaderHeight,
sortColumns,
showTypeIcons: showTypeIcons ?? false,
typographyCtx,
@@ -285,7 +292,6 @@ export function TableNG(props: TableNGProps) {
};
let lastRowIdx = -1;
let _rowHeight = 0;
// shared when whole row will be styled by a single cell's color
let rowCellStyle: Partial<CSSProperties> = {
color: undefined,
@@ -381,7 +387,6 @@ export function TableNG(props: TableNGProps) {
// meh, this should be cached by the renderRow() call?
if (rowIdx !== lastRowIdx) {
_rowHeight = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight;
lastRowIdx = rowIdx;
rowCellStyle.color = undefined;
@@ -420,6 +425,9 @@ export function TableNG(props: TableNGProps) {
const renderCellContent = (props: RenderCellProps<TableRow, TableSummaryRow>): JSX.Element => {
const rowIdx = props.row.__index;
const value = props.row[props.column.key];
// TODO: it would be nice to get rid of passing height down as a prop. but this value
// is cached so the cost of calling for every cell is low.
const height = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight;
const frame = data;
return (
@@ -428,7 +436,7 @@ export function TableNG(props: TableNGProps) {
cellOptions,
frame,
field,
height: _rowHeight,
height,
justifyContent,
rowIdx,
theme,
@@ -580,7 +588,7 @@ export function TableNG(props: TableNGProps) {
{...commonDataGridProps}
className={clsx(styles.grid, styles.gridNested)}
headerRowClass={clsx(styles.headerRow, { [styles.displayNone]: !hasNestedHeaders })}
headerRowHeight={hasNestedHeaders ? defaultHeaderHeight : 0}
headerRowHeight={hasNestedHeaders ? TABLE.HEADER_HEIGHT : 0}
columns={nestedColumns}
rows={expandedRecords}
renderers={{ renderRow, renderCell: renderCellRoot }}
@@ -599,7 +607,6 @@ export function TableNG(props: TableNGProps) {
crossFilterOrder,
crossFilterRows,
data,
defaultHeaderHeight,
defaultRowHeight,
enableSharedCrosshair,
expandedRows,
@@ -13,7 +13,9 @@ export const TABLE = {
PAGINATION_LIMIT: 750,
SCROLL_BAR_WIDTH: 8,
SCROLL_BAR_MARGIN: 2,
FONT_SIZE: 14,
LINE_HEIGHT: 22,
HEADER_HEIGHT: 28,
NESTED_NO_DATA_HEIGHT: 60,
BORDER_RIGHT: 0.666667,
BORDER_RIGHT: 1,
};
@@ -1,23 +1,19 @@
import { act, renderHook } from '@testing-library/react';
import { varPreLine } from 'uwrap';
import { cacheFieldDisplayNames, createDataFrame, Field, FieldType } from '@grafana/data';
import { TableCellDisplayMode } from '@grafana/schema';
import { TABLE } from './constants';
import {
useFilteredRows,
usePaginatedRows,
useSortedRows,
useFooterCalcs,
useHeaderHeight,
useTypographyCtx,
useRowHeight,
} from './hooks';
jest.mock('uwrap', () => ({
// ...jest.requireActual('uwrap'),
varPreLine: jest.fn(() => ({
count: jest.fn(() => 1),
})),
}));
import { TableRow } from './types';
import { createTypographyContext } from './utils';
describe('TableNG hooks', () => {
function setupData() {
@@ -28,21 +24,21 @@ describe('TableNG hooks', () => {
type: FieldType.string,
display: (v) => ({ text: v as string, numeric: NaN }),
config: {},
values: [],
values: ['Alice', 'Bob', 'Charlie'],
},
{
name: 'age',
type: FieldType.number,
display: (v) => ({ text: (v as number).toString(), numeric: v as number }),
config: {},
values: [],
values: [30, 25, 35],
},
{
name: 'active',
type: FieldType.boolean,
display: (v) => ({ text: (v as boolean).toString(), numeric: NaN }),
config: {},
values: [],
values: [true, false, true],
},
];
@@ -149,7 +145,7 @@ describe('TableNG hooks', () => {
height: 300,
width: 800,
enabled: false,
headerHeight: 28,
headerHeight: TABLE.HEADER_HEIGHT,
footerHeight: 0,
})
);
@@ -201,7 +197,7 @@ describe('TableNG hooks', () => {
height: 140,
width: 800,
rowHeight: 10,
headerHeight: 28,
headerHeight: TABLE.HEADER_HEIGHT,
footerHeight: 45,
})
);
@@ -429,16 +425,16 @@ describe('TableNG hooks', () => {
});
describe('useHeaderHeight', () => {
const typographyCtx = createTypographyContext(14, 'sans-serif');
it('should return 0 when no header is present', () => {
const { fields } = setupData();
const { result } = renderHook(() => {
const typographyCtx = useTypographyCtx();
return useHeaderHeight({
fields,
columnWidths: [],
enabled: false,
typographyCtx,
defaultHeight: 28,
sortColumns: [],
});
});
@@ -448,31 +444,20 @@ describe('TableNG hooks', () => {
it('should return the default height when wrap is disabled', () => {
const { fields } = setupData();
const { result } = renderHook(() => {
const typographyCtx = useTypographyCtx();
return useHeaderHeight({
fields,
columnWidths: [],
enabled: true,
typographyCtx,
defaultHeight: 28,
sortColumns: [],
});
});
expect(result.current).toBe(22);
expect(result.current).toBe(28);
});
it('should return the appropriate height for wrapped text', () => {
// Simulate 2 lines of text
jest.mocked(varPreLine).mockReturnValue({
count: jest.fn(() => 2),
each: jest.fn(),
split: jest.fn(),
test: jest.fn(),
});
const { fields } = setupData();
const { result } = renderHook(() => {
const typographyCtx = useTypographyCtx();
return useHeaderHeight({
fields: fields.map((field) => {
if (field.name === 'name') {
@@ -492,8 +477,7 @@ describe('TableNG hooks', () => {
}),
columnWidths: [100, 100, 100],
enabled: true,
typographyCtx: { ...typographyCtx, avgCharWidth: 5 },
defaultHeight: 28,
typographyCtx: { ...typographyCtx, avgCharWidth: 5, wrappedCount: jest.fn(() => 2) },
sortColumns: [],
});
});
@@ -504,19 +488,9 @@ describe('TableNG hooks', () => {
it('should calculate the available width for a header cell based on the icons rendered within it', () => {
const countFn = jest.fn(() => 1);
// Simulate 2 lines of text
jest.mocked(varPreLine).mockReturnValue({
count: countFn,
each: jest.fn(),
split: jest.fn(),
test: jest.fn(),
});
const { fields } = setupData();
renderHook(() => {
const typographyCtx = useTypographyCtx();
return useHeaderHeight({
fields: fields.map((field) => {
if (field.name === 'name') {
@@ -536,17 +510,15 @@ describe('TableNG hooks', () => {
}),
columnWidths: [100, 100, 100],
enabled: true,
typographyCtx: { ...typographyCtx, avgCharWidth: 10 },
defaultHeight: 28,
typographyCtx: { ...typographyCtx, wrappedCount: countFn },
sortColumns: [],
showTypeIcons: false,
});
});
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 87);
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 86);
renderHook(() => {
const typographyCtx = useTypographyCtx();
return useHeaderHeight({
fields: fields.map((field) => {
if (field.name === 'name') {
@@ -567,14 +539,233 @@ describe('TableNG hooks', () => {
}),
columnWidths: [100, 100, 100],
enabled: true,
typographyCtx: { ...typographyCtx, avgCharWidth: 10 },
defaultHeight: 28,
typographyCtx: { ...typographyCtx, wrappedCount: countFn },
sortColumns: [{ columnKey: 'Longer name that needs wrapping', direction: 'ASC' }],
showTypeIcons: true,
});
});
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 27);
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 26);
});
});
describe('useRowHeight', () => {
const typographyCtx = createTypographyContext(14, 'sans-serif');
it('returns the default height if there are no wrapped columns or nested frames', () => {
const { fields } = setupData();
const defaultHeight = 40;
expect(
renderHook(() => {
return useRowHeight({
fields,
columnWidths: [100, 100, 100],
defaultHeight,
typographyCtx: typographyCtx,
hasNestedFrames: false,
expandedRows: new Set(),
});
}).result.current
).toBe(defaultHeight);
});
describe('nested frames', () => {
it('returns 0 if the parent row is not expanded', () => {
const { fields } = setupData();
expect(
renderHook(() => {
const rowHeight = useRowHeight({
fields: [
{ name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} },
],
columnWidths: [100, 100, 100],
defaultHeight: 40,
typographyCtx: typographyCtx,
hasNestedFrames: true,
expandedRows: new Set(),
});
if (typeof rowHeight !== 'function') {
throw new Error('Expected rowHeight to be a function');
}
return rowHeight({ __depth: 1, data: createDataFrame({ fields }), __index: 0 });
}).result.current
).toBe(0);
});
it('returns a static height if there are no rows in the nested frame', () => {
const { fields } = setupData();
expect(
renderHook(() => {
const rowHeight = useRowHeight({
fields: [
{ name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} },
],
columnWidths: [100, 100, 100],
defaultHeight: 40,
typographyCtx: typographyCtx,
hasNestedFrames: true,
expandedRows: new Set([0]),
});
if (typeof rowHeight !== 'function') {
throw new Error('Expected rowHeight to be a function');
}
return rowHeight({
__depth: 1,
data: undefined,
__index: 0,
});
}).result.current
).toBe(TABLE.NESTED_NO_DATA_HEIGHT + TABLE.CELL_PADDING * 2);
});
it('calculates the height to return based on the number of rows in the nested frame', () => {
const { fields } = setupData();
const defaultHeight = 40;
expect(
renderHook(() => {
const rowHeight = useRowHeight({
fields: [
{ name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} },
],
columnWidths: [100, 100, 100],
defaultHeight,
typographyCtx: typographyCtx,
hasNestedFrames: true,
expandedRows: new Set([0]),
});
if (typeof rowHeight !== 'function') {
throw new Error('Expected rowHeight to be a function');
}
return rowHeight({
__index: 0,
__depth: 1,
data: createDataFrame({ fields }),
});
}).result.current
).toBe(defaultHeight * 4 + TABLE.CELL_PADDING * 2); // 3 rows + header + padding
});
it('removes the header if configured', () => {
const { fields } = setupData();
const defaultHeight = 40;
expect(
renderHook(() => {
const rowHeight = useRowHeight({
fields: [
{ name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} },
],
columnWidths: [100, 100, 100],
defaultHeight,
typographyCtx: typographyCtx,
hasNestedFrames: true,
expandedRows: new Set([0]),
});
if (typeof rowHeight !== 'function') {
throw new Error('Expected rowHeight to be a function');
}
return rowHeight({
__index: 0,
__depth: 1,
data: createDataFrame({ fields, meta: { custom: { noHeader: true } } }),
});
}).result.current
).toBe(defaultHeight * 3 + TABLE.CELL_PADDING * 2); // 3 rows + padding (no header)
});
});
// we test the lineCounters and getRowHeight directly to check that all of that
// math is working correctly. we mainly want to confirm here that the
// cache is clearing and that the local logic in this hook works.
describe('wrapped columns', () => {
let rows: TableRow[];
let fieldsWithWrappedText: Field[];
beforeEach(() => {
const { fields, rows: _rows } = setupData();
rows = _rows;
fieldsWithWrappedText = fields.map((field) => {
if (field.name === 'name') {
return {
...field,
name: 'Longer name that needs wrapping',
config: {
...field.config,
custom: {
...field.config?.custom,
cellOptions: {
cellType: TableCellDisplayMode.Auto,
wrapText: true,
},
},
},
};
}
return field;
});
});
it('handles changes to default height on re-render', () => {
const { result, rerender } = renderHook(
({ defaultHeight }) => {
const rowHeight = useRowHeight({
fields: fieldsWithWrappedText,
columnWidths: [100, 100, 100],
defaultHeight,
typographyCtx: typographyCtx,
hasNestedFrames: false,
expandedRows: new Set(),
});
if (typeof rowHeight !== 'function') {
throw new Error('Expected rowHeight to be a function');
}
return rowHeight;
},
{
initialProps: { defaultHeight: 40 },
}
);
expect(result.current(rows[0])).toBe(40);
// change the column widths
rerender({ defaultHeight: 50 });
expect(result.current(rows[0])).toBe(50);
});
it('adjusts the width of the columns based on the cell padding and border', () => {
fieldsWithWrappedText[0].values[0] = 'Annie Lennox';
const wrappedCountFn = jest.fn(() => 2);
const estimateLinesFn = jest.fn(() => 2);
const { result } = renderHook(() => {
const rowHeight = useRowHeight({
fields: fieldsWithWrappedText,
columnWidths: [100, 100, 100],
defaultHeight: 40,
typographyCtx: { ...typographyCtx, wrappedCount: wrappedCountFn, estimateLines: estimateLinesFn },
hasNestedFrames: false,
expandedRows: new Set(),
});
if (typeof rowHeight !== 'function') {
throw new Error('Expected rowHeight to be a function');
}
return rowHeight;
});
expect(result.current(rows[0])).toEqual(expect.any(Number));
expect(estimateLinesFn).toHaveBeenCalledWith('Annie Lennox', 100 - TABLE.CELL_PADDING * 2 - TABLE.BORDER_RIGHT);
});
});
});
});
@@ -1,22 +1,20 @@
import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect, RefObject } from 'react';
import { Column, DataGridHandle, DataGridProps, SortColumn } from 'react-data-grid';
import { varPreLine } from 'uwrap';
import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data';
import { useTheme2 } from '../../../themes/ThemeContext';
import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types';
import { TableColumnResizeActionCallback } from '../types';
import { TABLE } from './constants';
import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow } from './types';
import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types';
import {
getDisplayName,
processNestedTableRows,
applySort,
getCellOptions,
getColumnTypes,
GetMaxWrapCellOptions,
getMaxWrapCell,
getRowHeight,
buildHeaderLineCounters,
buildRowLineCounters,
} from './utils';
// Helper function to get displayed value
@@ -314,49 +312,6 @@ export function useFooterCalcs(
}, [fields, enabled, footerOptions, isCountRowsSet, rows]);
}
interface TypographyCtx {
ctx: CanvasRenderingContext2D;
font: string;
avgCharWidth: number;
calcRowHeight: (text: string, cellWidth: number, defaultHeight: number) => number;
}
export function useTypographyCtx(): TypographyCtx {
const theme = useTheme2();
const typographyCtx = useMemo((): TypographyCtx => {
const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
// set in grafana/data in createTypography.ts
const letterSpacing = 0.15;
ctx.letterSpacing = `${letterSpacing}px`;
ctx.font = font;
const txt =
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s";
const txtWidth = ctx.measureText(txt).width;
const avgCharWidth = txtWidth / txt.length + letterSpacing;
const { count } = varPreLine(ctx);
const calcRowHeight = (text: string, cellWidth: number, defaultHeight: number) => {
if (text === '') {
return defaultHeight;
}
const numLines = count(text, cellWidth);
const totalHeight = numLines * TABLE.LINE_HEIGHT + 2 * TABLE.CELL_PADDING;
return Math.max(totalHeight, defaultHeight);
};
return {
calcRowHeight,
ctx,
font,
avgCharWidth,
};
}, [theme.typography.fontSize, theme.typography.fontFamily]);
return typographyCtx;
}
const ICON_WIDTH = 16;
const ICON_GAP = 4;
@@ -364,7 +319,6 @@ interface UseHeaderHeightOptions {
enabled: boolean;
fields: Field[];
columnWidths: number[];
defaultHeight: number;
sortColumns: SortColumn[];
typographyCtx: TypographyCtx;
showTypeIcons?: boolean;
@@ -374,12 +328,14 @@ export function useHeaderHeight({
fields,
enabled,
columnWidths,
defaultHeight,
sortColumns,
typographyCtx: { calcRowHeight, avgCharWidth },
typographyCtx,
showTypeIcons = false,
}: UseHeaderHeightOptions): number {
const perIconSpace = ICON_WIDTH + ICON_GAP;
const lineCounters = useMemo(() => buildHeaderLineCounters(fields, typographyCtx), [fields, typographyCtx]);
const columnAvailableWidths = useMemo(
() =>
columnWidths.map((c, idx) => {
@@ -396,46 +352,26 @@ export function useHeaderHeight({
if (showTypeIcons) {
width -= perIconSpace;
}
return Math.floor(width);
// sadly, the math for this is off by exactly 1 pixel. shrug.
return Math.floor(width) - 1;
}),
[fields, columnWidths, sortColumns, showTypeIcons, perIconSpace]
);
const [wrappedColHeaderIdxs, hasWrappedColHeaders] = useMemo(() => {
let hasWrappedColHeaders = false;
return [
fields.map((field) => {
const wrapText = field.config?.custom?.wrapHeaderText ?? false;
if (wrapText) {
hasWrappedColHeaders = true;
}
return wrapText;
}),
hasWrappedColHeaders,
];
}, [fields]);
const maxWrapCellOptions = useMemo<GetMaxWrapCellOptions>(
() => ({
colWidths: columnAvailableWidths,
avgCharWidth,
wrappedColIdxs: wrappedColHeaderIdxs,
}),
[columnAvailableWidths, avgCharWidth, wrappedColHeaderIdxs]
);
// TODO: is there a less clunky way to subtract the top padding value?
const headerHeight = useMemo(() => {
if (!enabled) {
return 0;
}
if (!hasWrappedColHeaders) {
return defaultHeight - TABLE.CELL_PADDING;
}
const { text: maxLinesText, idx: maxLinesIdx } = getMaxWrapCell(fields, -1, maxWrapCellOptions);
return calcRowHeight(maxLinesText, columnAvailableWidths[maxLinesIdx], defaultHeight) - TABLE.CELL_PADDING;
}, [fields, enabled, hasWrappedColHeaders, maxWrapCellOptions, calcRowHeight, columnAvailableWidths, defaultHeight]);
return getRowHeight(
fields,
-1,
columnAvailableWidths,
TABLE.HEADER_HEIGHT,
lineCounters,
TABLE.LINE_HEIGHT,
TABLE.CELL_PADDING
);
}, [fields, enabled, columnAvailableWidths, lineCounters]);
return headerHeight;
}
@@ -455,42 +391,15 @@ export function useRowHeight({
hasNestedFrames,
defaultHeight,
expandedRows,
typographyCtx: { calcRowHeight, avgCharWidth },
typographyCtx,
}: UseRowHeightOptions): number | ((row: TableRow) => number) {
const [wrappedColIdxs, hasWrappedCols] = useMemo(() => {
let hasWrappedCols = false;
return [
fields.map((field) => {
if (field.type !== FieldType.string) {
return false;
}
const lineCounters = useMemo(() => buildRowLineCounters(fields, typographyCtx), [fields, typographyCtx]);
const hasWrappedCols = useMemo(() => lineCounters?.length ?? 0 > 0, [lineCounters]);
const cellOptions = getCellOptions(field);
const wrapText = 'wrapText' in cellOptions && cellOptions.wrapText;
const type = cellOptions.type;
const result = !!wrapText && type !== TableCellDisplayMode.Image;
if (result === true) {
hasWrappedCols = true;
}
return result;
}),
hasWrappedCols,
];
}, [fields]);
const colWidths = useMemo(
() => columnWidths.map((c) => c - 2 * TABLE.CELL_PADDING - TABLE.BORDER_RIGHT),
[columnWidths]
);
const maxWrapCellOptions = useMemo<GetMaxWrapCellOptions>(
() => ({
colWidths,
avgCharWidth,
wrappedColIdxs,
}),
[colWidths, avgCharWidth, wrappedColIdxs]
);
const colWidths = useMemo(() => {
const columnWidthAffordance = 2 * TABLE.CELL_PADDING + TABLE.BORDER_RIGHT;
return columnWidths.map((c) => c - columnWidthAffordance);
}, [columnWidths]);
const rowHeight = useMemo(() => {
// row height is only complicated when there are nested frames or wrapped columns.
@@ -498,6 +407,9 @@ export function useRowHeight({
return defaultHeight;
}
// this cache should get blown away on resize, data refresh, updated fields, etc.
// caching by __index is ok because sorting does not modify the __index.
const cache: Array<number | undefined> = Array(fields[0].values.length);
return (row: TableRow) => {
// nested rows
if (row.__depth > 0) {
@@ -512,23 +424,25 @@ export function useRowHeight({
}
const nestedHeaderHeight = row.data?.meta?.custom?.noHeader ? 0 : defaultHeight;
return Math.max(defaultHeight, defaultHeight * rowCount + nestedHeaderHeight + TABLE.CELL_PADDING * 2);
return defaultHeight * rowCount + nestedHeaderHeight + TABLE.CELL_PADDING * 2;
}
// regular rows
const { text: maxLinesText, idx: maxLinesIdx } = getMaxWrapCell(fields, row.__index, maxWrapCellOptions);
return calcRowHeight(maxLinesText, colWidths[maxLinesIdx], defaultHeight);
let result = cache[row.__index];
if (!result) {
result = cache[row.__index] = getRowHeight(
fields,
row.__index,
colWidths,
defaultHeight,
lineCounters,
TABLE.LINE_HEIGHT,
TABLE.CELL_PADDING * 2
);
}
return result;
};
}, [
calcRowHeight,
defaultHeight,
expandedRows,
fields,
hasNestedFrames,
hasWrappedCols,
maxWrapCellOptions,
colWidths,
]);
}, [hasNestedFrames, hasWrappedCols, defaultHeight, fields, colWidths, lineCounters, expandedRows]);
return rowHeight;
}
@@ -261,3 +261,29 @@ export interface ScrollPosition {
x: number;
y: number;
}
export interface TypographyCtx {
ctx: CanvasRenderingContext2D;
font: string;
avgCharWidth: number;
estimateLines: LineCounter;
wrappedCount: LineCounter;
}
export type LineCounter = (value: unknown, width: number) => number;
export interface LineCounterEntry {
/**
* given a values and the available width, returns the line count for that value
*/
counter: LineCounter;
/**
* if getting an accurate line count is expensive, you can provide an estimate method
* which will be used when looping over the row. the counter method will only be invoked
* for the cell which is the maximum line count for the row.
*/
estimate?: LineCounter;
/**
* indicates which field indexes of the visible fields this line counter applies to.
*/
fieldIdxs: number[];
}
@@ -16,7 +16,8 @@ import { BarGaugeDisplayMode, TableCellBackgroundDisplayMode, TableCellHeight }
import { TableCellDisplayMode } from '../types';
import { TABLE } from './constants';
import { COLUMN, TABLE } from './constants';
import { LineCounterEntry } from './types';
import {
extractPixelValue,
frameToRecords,
@@ -31,8 +32,14 @@ import {
getJustifyContent,
migrateTableDisplayModeToCellOptions,
getColumnTypes,
getMaxWrapCell,
computeColWidths,
getRowHeight,
buildRowLineCounters,
buildHeaderLineCounters,
getTextLineEstimator,
createTypographyContext,
applySort,
SINGLE_LINE_ESTIMATE_THRESHOLD,
} from './utils';
describe('TableNG utils', () => {
@@ -975,117 +982,345 @@ describe('TableNG utils', () => {
});
});
describe('getMaxWrapCell', () => {
it('should return the maximum wrap cell length from field state', () => {
const field1: Field = {
name: 'field1',
type: FieldType.string,
config: {},
values: ['beep boop', 'foo bar baz', 'lorem ipsum dolor sit amet'],
};
describe('createTypographyCtx', () => {
// we can't test the effectiveness of this typography context in unit tests, only that it
// actually executed the JS correctly. If you called `count` with a sensible value and width,
// it wouldn't give you a very reasonable answer in Jest's DOM environment for some reason.
it('creates the context using uwrap', () => {
const ctx = createTypographyContext(14, 'sans-serif', 0.15);
expect(ctx).toEqual(
expect.objectContaining({
font: '14px sans-serif',
ctx: expect.any(CanvasRenderingContext2D),
wrappedCount: expect.any(Function),
estimateLines: expect.any(Function),
avgCharWidth: expect.any(Number),
})
);
expect(ctx.wrappedCount('the quick brown fox jumps over the lazy dog', 100)).toEqual(expect.any(Number));
expect(ctx.estimateLines('the quick brown fox jumps over the lazy dog', 100)).toEqual(expect.any(Number));
});
});
const field2: Field = {
name: 'field2',
type: FieldType.string,
config: {},
values: ['asdfasdf asdfasdf asdfasdf', 'asdf asdf asdf asdf asdf', ''],
};
describe('getTextLineEstimator', () => {
const counter = getTextLineEstimator(10);
const field3: Field = {
name: 'field3',
type: FieldType.string,
config: {},
values: ['foo', 'bar', 'baz'],
// No alignmentFactors in state
};
const fields = [field1, field2, field3];
const result = getMaxWrapCell(fields, 0, {
colWidths: [30, 50, 100],
avgCharWidth: 5,
wrappedColIdxs: [true, true, true],
});
expect(result).toEqual({
text: 'asdfasdf asdfasdf asdfasdf',
idx: 1,
numLines: 2.6,
});
it('returns -1 if there are no strings or dashes within the string', () => {
expect(counter('asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf', 5)).toBe(-1);
});
it('should take colWidths into account when calculating max wrap cell', () => {
it('calculates an approximate rendered height for the text based on the width and avgCharWidth', () => {
expect(counter('asdfas dfasdfasdf asdfasdfasdfa sdfasdfasdfasdf 23', 200)).toBe(2.5);
});
});
describe('buildHeaderLineCounters', () => {
const ctx = {
font: '14px sans-serif',
ctx: {} as CanvasRenderingContext2D,
count: jest.fn(() => 2),
avgCharWidth: 7,
wrappedCount: jest.fn(() => 2),
estimateLines: jest.fn(() => 2),
};
it('returns an array of line counters for each column', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: { wrapHeaderText: true } } },
{ name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } },
];
const counters = buildHeaderLineCounters(fields, ctx);
expect(counters![0].counter).toEqual(expect.any(Function));
expect(counters![0].fieldIdxs).toEqual([0, 1]);
});
it('does not return the index of columns which are not wrapped', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
{ name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } },
];
const counters = buildHeaderLineCounters(fields, ctx);
expect(counters![0].fieldIdxs).toEqual([1]);
});
it('returns undefined if no columns are wrapped', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
{ name: 'Age', type: FieldType.number, values: [], config: { custom: {} } },
];
const counters = buildHeaderLineCounters(fields, ctx);
expect(counters).toBeUndefined();
});
});
describe('buildRowLineCounters', () => {
const ctx = {
font: '14px sans-serif',
ctx: {} as CanvasRenderingContext2D,
count: jest.fn(() => 2),
wrappedCount: jest.fn(() => 2),
estimateLines: jest.fn(() => 2),
avgCharWidth: 7,
};
it('returns an array of line counters for each column', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: { cellOptions: { wrapText: true } } } },
{
name: 'field',
name: 'Address',
type: FieldType.string,
config: {},
values: ['short', 'a bit longer text'],
values: [],
config: { custom: { cellOptions: { wrapText: true } } },
},
];
const counters = buildRowLineCounters(fields, ctx);
expect(counters![0].counter).toEqual(expect.any(Function));
expect(counters![0].fieldIdxs).toEqual([0, 1]);
});
it('does not return the index of columns which are not wrapped', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
{
name: 'field',
name: 'Address',
type: FieldType.string,
config: {},
values: ['short', 'quite a bit longer text'],
},
{
name: 'field',
type: FieldType.string,
config: {},
values: ['short', 'less text'],
values: [],
config: { custom: { cellOptions: { wrapText: true } } },
},
];
// Simulate a narrow column width that would cause wrapping
const colWidths = [50, 1000, 30]; // 50px width
const avgCharWidth = 5; // Assume average character width is 5px
const result = getMaxWrapCell(fields, 1, { colWidths, avgCharWidth, wrappedColIdxs: [true, true, true] });
// With a 50px width and 5px per character, we can fit 10 characters per line
// "the longest text in this field" has 31 characters, so it should wrap to 4 lines
expect(result).toEqual({
idx: 0,
numLines: 1.7,
text: 'a bit longer text',
});
const counters = buildRowLineCounters(fields, ctx);
expect(counters![0].fieldIdxs).toEqual([1]);
});
it('should use the display name if the rowIdx is -1 (which is used to calc header height in wrapped rows)', () => {
it('does not enable text counting for non-string fields', () => {
const fields: Field[] = [
{
name: 'Field with a very long name',
type: FieldType.string,
config: {},
values: ['short', 'a bit longer text'],
},
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
{ name: 'Age', type: FieldType.number, values: [], config: { custom: { cellOptions: { wrapText: true } } } },
];
const counters = buildRowLineCounters(fields, ctx);
// empty array - we had one column that indicated it wraps, but it was numeric, so we just ignore it
expect(counters).toEqual([]);
});
it('returns an undefined if no columns are wrapped', () => {
const fields: Field[] = [
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
{ name: 'Age', type: FieldType.number, values: [], config: { custom: {} } },
];
const counters = buildRowLineCounters(fields, ctx);
expect(counters).toBeUndefined();
});
});
describe('getRowHeight', () => {
let fields: Field[];
let counters: LineCounterEntry[];
beforeEach(() => {
fields = [
{
name: 'Name',
type: FieldType.string,
config: {},
values: ['short', 'quite a bit longer text'],
values: ['foo', 'bar', 'baz', 'longer one here', 'shorter'],
config: { custom: { cellOptions: { wrapText: true } } },
},
{
name: 'Another field',
type: FieldType.string,
config: {},
values: ['short', 'less text'],
name: 'Age',
type: FieldType.number,
values: [1, 2, 3, 123456, 789122349932],
config: { custom: { cellOptions: { wrapText: true } } },
},
];
// Simulate a narrow column width that would cause wrapping
const colWidths = [50, 1000, 30]; // 50px width
const avgCharWidth = 5; // Assume average character width is 5px
const result = getMaxWrapCell(fields, -1, { colWidths, avgCharWidth, wrappedColIdxs: [true, true, true] });
// With a 50px width and 5px per character, we can fit 10 characters per line
// "the longest text in this field" has 31 characters, so it should wrap to 4 lines
expect(result).toEqual({ idx: 0, numLines: 2.7, text: 'Field with a very long name' });
counters = [
{ counter: jest.fn((value, _length: number) => String(value).split(' ').length), fieldIdxs: [0] }, // Mocked to count words as lines
{ counter: jest.fn((value, _length: number) => Math.ceil(String(value).length / 3)), fieldIdxs: [1] }, // Mocked to return a line for every 3 digits of a number
];
});
it.todo('should ignore columns which are not wrapped');
it('should use the default height for single-line rows', () => {
// 1 line @ 20px, 10px vertical padding = 30, minimum is 36
expect(getRowHeight(fields, 0, [30, 30], 36, counters, 20, 10)).toBe(36);
});
it.todo('should only apply wrapping on idiomatic break characters (space, -, etc)');
it('should use the default height for multi-line rows which are shorter than the default height', () => {
// 3 lines @ 5px, 5px vertical padding = 20, minimum is 36
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 5, 5)).toBe(36);
});
it('should return the row height using line counters for multi-line', () => {
// 3 lines @ 20px ('longer', 'one', 'here'), 10px vertical padding
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(70);
// 4 lines @ 15px (789 122 349 932), 15px vertical padding
expect(getRowHeight(fields, 4, [30, 30], 36, counters, 15, 15)).toBe(75);
});
it('should take colWidths into account when calculating max wrap cell', () => {
getRowHeight(fields, 3, [50, 60], 36, counters, 20, 10);
expect(counters[0].counter).toHaveBeenCalledWith('longer one here', 50);
expect(counters[1].counter).toHaveBeenCalledWith(123456, 60);
});
// this is used to calc wrapped header height
it('should use the display name if the rowIdx is -1', () => {
getRowHeight(fields, -1, [50, 60], 36, counters, 20, 10);
expect(counters[0].counter).toHaveBeenCalledWith('Name', 50);
expect(counters[1].counter).toHaveBeenCalledWith('Age', 60);
});
it('should ignore columns which do not have line counters', () => {
const height = getRowHeight(fields, 3, [30, 30], 36, [counters[1]], 20, 10);
// 2 lines @ 20px, 10px vertical padding (not 3 lines, since we don't line count Name)
expect(height).toBe(50);
});
it('should return the default height if there are no counters to apply', () => {
const height = getRowHeight(fields, 3, [30, 30], 36, [], 20, 10);
expect(height).toBe(36);
});
describe('estimations vs. precise counts', () => {
beforeEach(() => {
counters = [
{ counter: jest.fn((value, _length: number) => String(value).split(' ').length), fieldIdxs: [0] }, // Mocked to count words as lines
{
estimate: jest.fn((value) => String(value).length), // Mocked to return a line for every digits of a number
counter: jest.fn((value, _length: number) => Math.ceil(String(value).length / 3)),
fieldIdxs: [1],
},
];
});
// 2 lines @ 20px (123,456), 10px vertical padding. when we did this before, 'longer one here' would win, making it 70px.
// the `estimate` function is picking `123456` as the longer one now (6 lines), then the `counter` function is used
// to calculate the height (2 lines). this is a very forced case, but we just want to prove that it actually works.
it('uses the estimate value rather than the precise value to select the row height', () => {
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(50);
});
it('returns doesnt bother getting the precise count if the estimates are all below the threshold', () => {
jest.mocked(counters[0].counter).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3);
jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.1);
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(36);
// this is what we really care about - we want to save on performance by not calling the counter in this case.
expect(counters[1].counter).not.toHaveBeenCalled();
});
it('uses the precise count if the estimate is above the threshold, even if its below 1', () => {
// NOTE: if this fails, just change the test to use a different value besides 0.1
expect(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1).toBeLessThan(1);
jest.mocked(counters[0].counter).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3);
jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1);
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(50);
});
});
});
describe('computeColWidths', () => {
it('returns the configured widths if all columns set them', () => {
expect(
computeColWidths(
[
{
name: 'A',
type: FieldType.string,
values: [],
config: { custom: { width: 100 } },
},
{
name: 'B',
type: FieldType.string,
values: [],
config: { custom: { width: 200 } },
},
],
500
)
).toEqual([100, 200]);
});
it('fills the available space if a column has no width set', () => {
expect(
computeColWidths(
[
{
name: 'A',
type: FieldType.string,
values: [],
config: {},
},
{
name: 'B',
type: FieldType.string,
values: [],
config: { custom: { width: 200 } },
},
],
500
)
).toEqual([300, 200]);
});
it('applies minimum width when auto width would dip below it', () => {
expect(
computeColWidths(
[
{
name: 'A',
type: FieldType.string,
values: [],
config: { custom: { minWidth: 100 } },
},
{
name: 'B',
type: FieldType.string,
values: [],
config: { custom: { minWidth: 100 } },
},
],
100
)
).toEqual([100, 100]);
});
it('should use the global column default width when nothing is set', () => {
expect(
computeColWidths(
[
{
name: 'A',
type: FieldType.string,
values: [],
config: {},
},
{
name: 'B',
type: FieldType.string,
values: [],
config: {},
},
],
// we have two columns but have set the table to the width of one default column.
COLUMN.DEFAULT_WIDTH
)
).toEqual([COLUMN.DEFAULT_WIDTH, COLUMN.DEFAULT_WIDTH]);
});
});
describe('displayJsonValue', () => {
it.todo('should parse and then stringify string values');
it.todo('should not throw for non-serializable string values');
it.todo('should stringify non-string values');
it.todo('should not throw for non-serializable non-string values');
});
describe('applySort', () => {
@@ -1,6 +1,7 @@
import { Property } from 'csstype';
import { SortColumn } from 'react-data-grid';
import tinycolor from 'tinycolor2';
import { Count, varPreLine } from 'uwrap';
import {
FieldType,
@@ -25,7 +26,16 @@ import { getTextColorForAlphaBackground } from '../../../utils/colors';
import { TableCellOptions } from '../types';
import { COLUMN, TABLE } from './constants';
import { CellColors, TableRow, ColumnTypes, FrameToRowsConverter, Comparator } from './types';
import {
CellColors,
TableRow,
ColumnTypes,
FrameToRowsConverter,
Comparator,
TypographyCtx,
LineCounter,
LineCounterEntry,
} from './types';
/* ---------------------------- Cell calculations --------------------------- */
export type CellNumLinesCalculator = (text: string, cellWidth: number) => number;
@@ -71,58 +81,190 @@ export function shouldTextWrap(field: Field): boolean {
return Boolean(cellOptions?.wrapText);
}
// matches characters which CSS
const spaceRegex = /[\s-]/;
/**
* @internal creates a typography context based on a font size and family. used to measure text
* and estimate size of text in cells.
*/
export function createTypographyContext(fontSize: number, fontFamily: string, letterSpacing = 0.15): TypographyCtx {
const font = `${fontSize}px ${fontFamily}`;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
export interface GetMaxWrapCellOptions {
colWidths: number[];
avgCharWidth: number;
wrappedColIdxs: boolean[];
ctx.letterSpacing = `${letterSpacing}px`;
ctx.font = font;
const txt =
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s.";
const txtWidth = ctx.measureText(txt).width;
const avgCharWidth = txtWidth / txt.length + letterSpacing;
const { count } = varPreLine(ctx);
return {
ctx,
font,
avgCharWidth,
estimateLines: getTextLineEstimator(avgCharWidth),
wrappedCount: wrapUwrapCount(count),
};
}
/**
* @internal
* loop through the fields and their values, determine which cell is going to determine the
* height of the row based on its content and width, and then return the text, index, and number of lines for that cell.
*/
export function getMaxWrapCell(
export function wrapUwrapCount(count: Count): LineCounter {
return (value, width) => {
if (value == null) {
return 1;
}
return count(String(value), width);
};
}
/**
* @internal returns a line counter which guesstimates a number of lines in a text cell based on the typography context's avgCharWidth.
*/
export function getTextLineEstimator(avgCharWidth: number): LineCounter {
return (value, width) => {
if (!value) {
return -1;
}
// we don't have string breaking enabled in the table,
// so an unbroken string is by definition a single line.
const strValue = String(value);
if (!spaceRegex.test(strValue)) {
return -1;
}
const charsPerLine = width / avgCharWidth;
return strValue.length / charsPerLine;
};
}
/**
* @internal return a text line counter for every field which has wrapHeaderText enabled.
*/
export function buildHeaderLineCounters(fields: Field[], typographyCtx: TypographyCtx): LineCounterEntry[] | undefined {
const wrappedColIdxs = fields.reduce((acc: number[], field, idx) => {
if (field.config?.custom?.wrapHeaderText) {
acc.push(idx);
}
return acc;
}, []);
if (wrappedColIdxs.length === 0) {
return undefined;
}
// don't bother with estimating the line counts for the headers, because it's punishing
// when we get it wrong and there won't be that many compared to how many rows a table might contain.
return [{ counter: typographyCtx.wrappedCount, fieldIdxs: wrappedColIdxs }];
}
const spaceRegex = /[\s-]/;
/**
* @internal return a text line counter for every field which has wrapHeaderText enabled. we do this once as we're rendering
* the table, and then getRowHeight uses the output of this to caluclate the height of each row.
*/
export function buildRowLineCounters(fields: Field[], typographyCtx: TypographyCtx): LineCounterEntry[] | undefined {
const result: Record<string, LineCounterEntry> = {};
let wrappedFields = 0;
for (let fieldIdx = 0; fieldIdx < fields.length; fieldIdx++) {
const field = fields[fieldIdx];
if (shouldTextWrap(field)) {
wrappedFields++;
// TODO: Pills, DataLinks, and JSON will have custom line counters here.
// for string fields, we really want to find the longest field ahead of time to reduce the number of calls to `count`.
// calling `count` is going to get a perfectly accurate line count, but it is expensive, so we'd rather estimate the line
// count and call the counter only for the field which will take up the most space based on its
if (field.type === FieldType.string) {
result.textCounter = result.textCounter ?? {
counter: typographyCtx.wrappedCount,
estimate: typographyCtx.estimateLines,
fieldIdxs: [],
};
result.textCounter.fieldIdxs.push(fieldIdx);
}
}
}
if (wrappedFields === 0) {
return undefined;
}
return Object.values(result);
}
// in some cases, the estimator might return a value that is less than 1, but when measured by the counter, it actually
// realizes that it's a multi-line cell. to avoid this, we want to give a little buffer away from 1 before we fully trust
// the estimator to have told us that a cell is single-line.
export const SINGLE_LINE_ESTIMATE_THRESHOLD = 0.85;
/**
* @internal
* loop through the fields and their values, determine which cell is going to determine the height of the row based
* on its content and width, and return the height in pixels of that row, with vertial padding applied.
*/
export function getRowHeight(
fields: Field[],
rowIdx: number,
{ colWidths, avgCharWidth, wrappedColIdxs }: GetMaxWrapCellOptions
): {
text: string;
idx: number;
numLines: number;
} {
let maxLines = 1;
let maxLinesIdx = -1;
let maxLinesText = '';
columnWidths: number[],
defaultHeight: number,
lineCounters?: LineCounterEntry[],
lineHeight = TABLE.LINE_HEIGHT,
verticalPadding = 0
): number {
if (!lineCounters?.length) {
return defaultHeight;
}
// TODO: consider changing how we store this, using a record by column key instead of an array
for (let i = 0; i < colWidths.length; i++) {
if (wrappedColIdxs[i]) {
const field = fields[i];
let maxLines = -1;
let maxValue = '';
let maxWidth = 0;
let preciseCounter: LineCounter | undefined;
for (const { estimate, counter, fieldIdxs } of lineCounters) {
// for some of the line counters, getting the precise count of the lines is expensive. those line counters
// set both an "estimate" and a "counter" function. if the cell we find to be the max was estimated, we will
// get the "true" value right before calculating the row height by hanging onto a reference to the counter fn.
const count = estimate ?? counter;
const isEstimating = estimate !== undefined;
for (const fieldIdx of fieldIdxs) {
const field = fields[fieldIdx];
// special case: for the header, provide `-1` as the row index.
const cellTextRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx];
if (cellTextRaw != null) {
const cellText = String(cellTextRaw);
if (spaceRegex.test(cellText)) {
const charsPerLine = colWidths[i] / avgCharWidth;
const approxLines = cellText.length / charsPerLine;
if (approxLines > maxLines) {
maxLines = approxLines;
maxLinesIdx = i;
maxLinesText = cellText;
}
const cellValueRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx];
if (cellValueRaw != null) {
const colWidth = columnWidths[fieldIdx];
const approxLines = count(cellValueRaw, colWidth);
if (approxLines > maxLines) {
maxLines = approxLines;
maxValue = cellValueRaw;
maxWidth = colWidth;
preciseCounter = isEstimating ? counter : undefined;
}
}
}
}
return { text: maxLinesText, idx: maxLinesIdx, numLines: maxLines };
// if the value is -1 or the estimate for the max cell was less than the SINGLE_LINE_ESTIMATE_THRESHOLD, we trust
// that the estimator correctly identified that no text wrapping is needed for this row, skipping the preciseCounter.
if (maxLines < SINGLE_LINE_ESTIMATE_THRESHOLD) {
return defaultHeight;
}
// if we finished this row height loop with an estimate, we need to call
// the `preciseCounter` method to get the exact line count.
if (preciseCounter !== undefined) {
maxLines = preciseCounter(maxValue, maxWidth);
}
// we want a round number of lines for rendering
const totalHeight = Math.ceil(maxLines) * lineHeight + verticalPadding;
return Math.max(totalHeight, defaultHeight);
}
/**
@@ -8,10 +8,6 @@ import { TableInputCSV } from './TableInputCSV';
const meta: Meta<typeof TableInputCSV> = {
title: 'Inputs/Deprecated/TableInputCSV',
component: TableInputCSV,
parameters: {
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
};
export const basic: StoryFn<typeof TableInputCSV> = () => {
@@ -118,8 +118,9 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => {
position: 'absolute',
bottom: '15px',
right: '15px',
border: '1px solid #222',
border: `1px solid ${theme.colors.success.border}`,
background: theme.colors.success.main,
color: theme.colors.success.contrastText,
padding: `1px ${theme.spacing(0.5)}`,
fontSize: '80%',
}),

Some files were not shown because too many files have changed in this diff Show More