Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Mariell Hoversholm committed 2025-02-03 13:18:25 +01:00
commit 8109fd0226
79 files changed
+1892 -782

No files matched your search

+4 -20
View File
@@ -538,8 +538,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]
],
"packages/grafana-ui/src/components/Combobox/Combobox.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
@@ -547,6 +546,9 @@ exports[`better eslint`] = {
"packages/grafana-ui/src/components/Combobox/ValuePill.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
],
"packages/grafana-ui/src/components/Combobox/useOptions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
],
@@ -3486,24 +3488,6 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/scene/UnlinkModal.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
],
"public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
],
"public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
],
"public/app/features/dashboard-scene/scene/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
+10
View File
@@ -1,3 +1,13 @@
<!-- 11.5.1 START -->
# 11.5.1 (2025-02-03)
### Bug fixes
- **CodeEditor:** Fix cursor alignment [#99090](https://github.com/grafana/grafana/pull/99090), [@ashharrison90](https://github.com/ashharrison90)
- **TransformationFilter**: Include transformation outputs in transformation filtering options: Include transformation outputs in transformation filtering options [#98323](https://github.com/grafana/grafana/pull/98323), [@Sergej-Vlasov](https://github.com/Sergej-Vlasov)
<!-- 11.5.1 END -->
<!-- 11.5.0 START -->
# 11.5.0 (2025-01-28)
+1
View File
@@ -0,0 +1 @@
plugin/src
+16 -13
View File
@@ -7,7 +7,7 @@ check: {
versions: {
"v0alpha1": {
codegen: {
frontend: false
frontend: true
backend: true
}
validation: {
@@ -17,24 +17,27 @@ check: {
]
}
schema: {
spec: {
#Data: {
// Generic data input that a check can receive
data?: [string]: string
}
status: {
report: {
#ReportError: {
// Severity of the error
severity: "high" | "low"
// Human readable reason for the error
reason: string
// Action to take to resolve the error
action: string
}
#Report: {
// Number of elements analyzed
count: int
// List of errors
errors: [...{
// Severity of the error
severity: "high" | "low"
// Human readable reason for the error
reason: string
// Action to take to resolve the error
action: string
}]
}
errors: [...#ReportError]
}
spec: #Data
status: {
report: #Report
}
}
}
@@ -2,6 +2,21 @@
package v0alpha1
// +k8s:openapi-gen=true
type CheckReportError struct {
// Severity of the error
Severity CheckReportErrorSeverity `json:"severity"`
// Human readable reason for the error
Reason string `json:"reason"`
// Action to take to resolve the error
Action string `json:"action"`
}
// NewCheckReportError creates a new CheckReportError object.
func NewCheckReportError() *CheckReportError {
return &CheckReportError{}
}
// +k8s:openapi-gen=true
type CheckstatusOperatorState struct {
// lastEvaluation is the ResourceVersion last evaluated
@@ -37,6 +52,14 @@ func NewCheckStatus() *CheckStatus {
}
}
// +k8s:openapi-gen=true
type CheckReportErrorSeverity string
const (
CheckReportErrorSeverityHigh CheckReportErrorSeverity = "high"
CheckReportErrorSeverityLow CheckReportErrorSeverity = "low"
)
// +k8s:openapi-gen=true
type CheckStatusOperatorStateState string
@@ -46,35 +69,12 @@ const (
CheckStatusOperatorStateStateFailed CheckStatusOperatorStateState = "failed"
)
// +k8s:openapi-gen=true
type CheckStatusSeverity string
const (
CheckStatusSeverityHigh CheckStatusSeverity = "high"
CheckStatusSeverityLow CheckStatusSeverity = "low"
)
// +k8s:openapi-gen=true
type CheckV0alpha1StatusReportErrors struct {
// Severity of the error
Severity CheckStatusSeverity `json:"severity"`
// Human readable reason for the error
Reason string `json:"reason"`
// Action to take to resolve the error
Action string `json:"action"`
}
// NewCheckV0alpha1StatusReportErrors creates a new CheckV0alpha1StatusReportErrors object.
func NewCheckV0alpha1StatusReportErrors() *CheckV0alpha1StatusReportErrors {
return &CheckV0alpha1StatusReportErrors{}
}
// +k8s:openapi-gen=true
type CheckV0alpha1StatusReport struct {
// Number of elements analyzed
Count int64 `json:"count"`
// List of errors
Errors []CheckV0alpha1StatusReportErrors `json:"errors"`
Errors []CheckReportError `json:"errors"`
}
// NewCheckV0alpha1StatusReport creates a new CheckV0alpha1StatusReport object.
@@ -12,13 +12,13 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return map[string]common.OpenAPIDefinition{
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.Check": schema_pkg_apis_advisor_v0alpha1_Check(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckList": schema_pkg_apis_advisor_v0alpha1_CheckList(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckSpec": schema_pkg_apis_advisor_v0alpha1_CheckSpec(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckStatus": schema_pkg_apis_advisor_v0alpha1_CheckStatus(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckV0alpha1StatusReport": schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReport(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckV0alpha1StatusReportErrors": schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReportErrors(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckstatusOperatorState": schema_pkg_apis_advisor_v0alpha1_CheckstatusOperatorState(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.Check": schema_pkg_apis_advisor_v0alpha1_Check(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckList": schema_pkg_apis_advisor_v0alpha1_CheckList(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportError": schema_pkg_apis_advisor_v0alpha1_CheckReportError(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckSpec": schema_pkg_apis_advisor_v0alpha1_CheckSpec(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckStatus": schema_pkg_apis_advisor_v0alpha1_CheckStatus(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckV0alpha1StatusReport": schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReport(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckstatusOperatorState": schema_pkg_apis_advisor_v0alpha1_CheckstatusOperatorState(ref),
}
}
@@ -117,6 +117,43 @@ func schema_pkg_apis_advisor_v0alpha1_CheckList(ref common.ReferenceCallback) co
}
}
func schema_pkg_apis_advisor_v0alpha1_CheckReportError(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"severity": {
SchemaProps: spec.SchemaProps{
Description: "Severity of the error",
Default: "",
Type: []string{"string"},
Format: "",
},
},
"reason": {
SchemaProps: spec.SchemaProps{
Description: "Human readable reason for the error",
Default: "",
Type: []string{"string"},
Format: "",
},
},
"action": {
SchemaProps: spec.SchemaProps{
Description: "Action to take to resolve the error",
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"severity", "reason", "action"},
},
},
}
}
func schema_pkg_apis_advisor_v0alpha1_CheckSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -218,7 +255,7 @@ func schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReport(ref common.Refer
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckV0alpha1StatusReportErrors"),
Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportError"),
},
},
},
@@ -229,44 +266,7 @@ func schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReport(ref common.Refer
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckV0alpha1StatusReportErrors"},
}
}
func schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReportErrors(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"severity": {
SchemaProps: spec.SchemaProps{
Description: "Severity of the error",
Default: "",
Type: []string{"string"},
Format: "",
},
},
"reason": {
SchemaProps: spec.SchemaProps{
Description: "Human readable reason for the error",
Default: "",
Type: []string{"string"},
Format: "",
},
},
"action": {
SchemaProps: spec.SchemaProps{
Description: "Action to take to resolve the error",
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"severity", "reason", "action"},
},
},
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportError"},
}
}
@@ -49,13 +49,13 @@ func (c *check) Run(ctx context.Context, obj *advisor.CheckSpec) (*advisor.Check
return nil, err
}
dsErrs := []advisor.CheckV0alpha1StatusReportErrors{}
dsErrs := []advisor.CheckReportError{}
for _, ds := range dss {
// Data source UID validation
err := util.ValidateUID(ds.UID)
if err != nil {
dsErrs = append(dsErrs, advisor.CheckV0alpha1StatusReportErrors{
Severity: advisor.CheckStatusSeverityLow,
dsErrs = append(dsErrs, advisor.CheckReportError{
Severity: advisor.CheckReportErrorSeverityLow,
Reason: fmt.Sprintf("Invalid UID '%s' for data source %s", ds.UID, ds.Name),
Action: "Check the <a href='https://grafana.com/docs/grafana/latest/upgrade-guide/upgrade-v11.2/#grafana-data-source-uid-format-enforcement' target=_blank>documentation</a> for more information.",
})
@@ -81,8 +81,8 @@ func (c *check) Run(ctx context.Context, obj *advisor.CheckSpec) (*advisor.Check
continue
}
if resp.Status != backend.HealthStatusOk {
dsErrs = append(dsErrs, advisor.CheckV0alpha1StatusReportErrors{
Severity: advisor.CheckStatusSeverityHigh,
dsErrs = append(dsErrs, advisor.CheckReportError{
Severity: advisor.CheckReportErrorSeverityHigh,
Reason: fmt.Sprintf("Health check failed for %s", ds.Name),
Action: fmt.Sprintf(
"Go to the <a href='/connections/datasources/edit/%s'>data source configuration</a>"+
@@ -43,7 +43,7 @@ func (c *check) Type() string {
func (c *check) Run(ctx context.Context, _ *advisor.CheckSpec) (*advisor.CheckV0alpha1StatusReport, error) {
ps := c.PluginStore.Plugins(ctx)
errs := []advisor.CheckV0alpha1StatusReportErrors{}
errs := []advisor.CheckReportError{}
for _, p := range ps {
// Skip if it's a core plugin
if p.IsCorePlugin() {
@@ -56,8 +56,8 @@ func (c *check) Run(ctx context.Context, _ *advisor.CheckSpec) (*advisor.CheckV0
continue
}
if i.Status == "deprecated" {
errs = append(errs, advisor.CheckV0alpha1StatusReportErrors{
Severity: advisor.CheckStatusSeverityHigh,
errs = append(errs, advisor.CheckReportError{
Severity: advisor.CheckReportErrorSeverityHigh,
Reason: fmt.Sprintf("Plugin deprecated: %s", p.ID),
Action: "Check the <a href='https://grafana.com/legal/plugin-deprecation/#a-plugin-i-use-is-deprecated-what-should-i-do' target=_blank>documentation</a> for recommended steps.",
})
@@ -73,8 +73,8 @@ func (c *check) Run(ctx context.Context, _ *advisor.CheckSpec) (*advisor.CheckV0
continue
}
if hasUpdate(p, info) {
errs = append(errs, advisor.CheckV0alpha1StatusReportErrors{
Severity: advisor.CheckStatusSeverityLow,
errs = append(errs, advisor.CheckReportError{
Severity: advisor.CheckReportErrorSeverityLow,
Reason: fmt.Sprintf("New version available for %s", p.ID),
Action: fmt.Sprintf(
"Go to the <a href='/plugins/%s?page=version-history'>plugin admin page</a>"+
@@ -21,12 +21,12 @@ func TestRun(t *testing.T) {
pluginArchives map[string]*repo.PluginArchiveInfo
pluginPreinstalled []string
pluginManaged []string
expectedErrors []advisor.CheckV0alpha1StatusReportErrors
expectedErrors []advisor.CheckReportError
}{
{
name: "No plugins",
plugins: []pluginstore.Plugin{},
expectedErrors: []advisor.CheckV0alpha1StatusReportErrors{},
expectedErrors: []advisor.CheckReportError{},
},
{
name: "Deprecated plugin",
@@ -39,9 +39,9 @@ func TestRun(t *testing.T) {
pluginArchives: map[string]*repo.PluginArchiveInfo{
"plugin1": {Version: "1.0.0"},
},
expectedErrors: []advisor.CheckV0alpha1StatusReportErrors{
expectedErrors: []advisor.CheckReportError{
{
Severity: advisor.CheckStatusSeverityHigh,
Severity: advisor.CheckReportErrorSeverityHigh,
Reason: "Plugin deprecated: plugin1",
Action: "Check the <a href='https://grafana.com/legal/plugin-deprecation/#a-plugin-i-use-is-deprecated-what-should-i-do' target=_blank>documentation</a> for recommended steps.",
},
@@ -58,9 +58,9 @@ func TestRun(t *testing.T) {
pluginArchives: map[string]*repo.PluginArchiveInfo{
"plugin2": {Version: "1.1.0"},
},
expectedErrors: []advisor.CheckV0alpha1StatusReportErrors{
expectedErrors: []advisor.CheckReportError{
{
Severity: advisor.CheckStatusSeverityLow,
Severity: advisor.CheckReportErrorSeverityLow,
Reason: "New version available for plugin2",
Action: "Go to the <a href='/plugins/plugin2?page=version-history'>plugin admin page</a> and upgrade to the latest version.",
},
@@ -77,9 +77,9 @@ func TestRun(t *testing.T) {
pluginArchives: map[string]*repo.PluginArchiveInfo{
"plugin2": {Version: "beta"},
},
expectedErrors: []advisor.CheckV0alpha1StatusReportErrors{
expectedErrors: []advisor.CheckReportError{
{
Severity: advisor.CheckStatusSeverityLow,
Severity: advisor.CheckReportErrorSeverityLow,
Reason: "New version available for plugin2",
Action: "Go to the <a href='/plugins/plugin2?page=version-history'>plugin admin page</a> and upgrade to the latest version.",
},
@@ -97,7 +97,7 @@ func TestRun(t *testing.T) {
"plugin3": {Version: "1.1.0"},
},
pluginPreinstalled: []string{"plugin3"},
expectedErrors: []advisor.CheckV0alpha1StatusReportErrors{},
expectedErrors: []advisor.CheckReportError{},
},
{
name: "Managed plugin",
@@ -111,7 +111,7 @@ func TestRun(t *testing.T) {
"plugin4": {Version: "1.1.0"},
},
pluginManaged: []string{"plugin4"},
expectedErrors: []advisor.CheckV0alpha1StatusReportErrors{},
expectedErrors: []advisor.CheckReportError{},
},
}
+7
View File
@@ -0,0 +1,7 @@
This folder contains the automatically generated types for the frontend that are used in the app plugin.
To update the types:
1. Make any necessary changes in the `kinds` directory
2. Run `make generate`
3. Copy the `plugin` directory to the frontend app: https://github.com/grafana/grafana-advisor-app/tree/main/src/plugin
@@ -5,7 +5,7 @@ describe('Solo Route', () => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('Can view panels with shared queries in fullsceen', () => {
it('Can view panels with shared queries in fullscreen', () => {
// open Panel Tests - Bar Gauge
e2e.pages.SoloPanel.visit('ZqZnVvFZz/datasource-tests-shared-queries?orgId=1&panelId=4');
@@ -25,20 +25,20 @@ describe('Solo Route', () => {
it('Can view solo repeated panel in scenes', () => {
// open Panel Tests - Graph NG
e2e.pages.SoloPanel.visit(
'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true'
'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server=B').should('exist');
e2e.components.Panels.Panel.title('server=A').should('exist');
cy.contains('uplot-main-div').should('not.exist');
});
it('Can view solo in repeaterd row and panel in scenes', () => {
it('Can view solo in repeated row and panel in scenes', () => {
// open Panel Tests - Graph NG
e2e.pages.SoloPanel.visit(
'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-clone-D-clone-2&__feature.dashboardSceneSolo=true'
'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist');
e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist');
cy.contains('uplot-main-div').should('not.exist');
});
});
+6 -6
View File
@@ -5,7 +5,7 @@ describe('Solo Route', () => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('Can view panels with shared queries in fullsceen', () => {
it('Can view panels with shared queries in fullscreen', () => {
// open Panel Tests - Bar Gauge
e2e.pages.SoloPanel.visit('ZqZnVvFZz/datasource-tests-shared-queries?orgId=1&panelId=4');
@@ -25,20 +25,20 @@ describe('Solo Route', () => {
it('Can view solo repeated panel in scenes', () => {
// open Panel Tests - Graph NG
e2e.pages.SoloPanel.visit(
'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true'
'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server=B').should('exist');
e2e.components.Panels.Panel.title('server=A').should('exist');
cy.contains('uplot-main-div').should('not.exist');
});
it('Can view solo in repeaterd row and panel in scenes', () => {
it('Can view solo in repeated row and panel in scenes', () => {
// open Panel Tests - Graph NG
e2e.pages.SoloPanel.visit(
'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-clone-D-clone-2&__feature.dashboardSceneSolo=true'
'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist');
e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist');
cy.contains('uplot-main-div').should('not.exist');
});
});
@@ -237,11 +237,11 @@ describe('Combobox', () => {
const onChangeHandler = jest.fn();
render(<Combobox options={options} value={null} onChange={onChangeHandler} createCustomValue />);
const input = screen.getByRole('combobox');
await userEvent.type(input, 'custom value');
await userEvent.type(input, 'Use custom value');
await userEvent.keyboard('{Enter}');
expect(screen.getByDisplayValue('custom value')).toBeInTheDocument();
expect(onChangeHandler).toHaveBeenCalledWith(expect.objectContaining({ value: 'custom value' }));
expect(screen.getByDisplayValue('Use custom value')).toBeInTheDocument();
expect(onChangeHandler).toHaveBeenCalledWith(expect.objectContaining({ value: 'Use custom value' }));
});
it('should provide custom string when all options are numbers', async () => {
@@ -256,10 +256,10 @@ describe('Combobox', () => {
render(<Combobox options={options} value={null} onChange={onChangeHandler} createCustomValue />);
const input = screen.getByRole('combobox');
await userEvent.type(input, 'custom value');
await userEvent.type(input, 'Use custom value');
await userEvent.keyboard('{Enter}');
expect(screen.getByDisplayValue('custom value')).toBeInTheDocument();
expect(screen.getByDisplayValue('Use custom value')).toBeInTheDocument();
expect(typeof onChangeHandler.mock.calls[0][0].value === 'string').toBeTruthy();
expect(typeof onChangeHandler.mock.calls[0][0].value === 'number').toBeFalsy();
@@ -411,9 +411,12 @@ describe('Combobox', () => {
jest.advanceTimersByTime(500); // Custom value while typing
});
const customItem = screen.queryByRole('option', { name: 'Custom value: fir' });
const customItem = screen.getByRole('option');
const customValue = customItem.getElementsByTagName('span')[0].textContent;
const customDescription = customItem.getElementsByTagName('span')[1].textContent;
expect(customItem).toBeInTheDocument();
expect(customValue).toBe('fir');
expect(customDescription).toBe('Use custom value');
});
it('should display message when there is an error loading async options', async () => {
@@ -135,12 +135,10 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
if (!optionMatchingInput) {
const customValueOption = {
label: t('combobox.custom-value.label', 'Custom value: ') + inputValue,
label: inputValue,
// Type casting needed to make this work when T is a number
value: inputValue as unknown as T,
/* TODO: Add this back when we do support descriptions and have need for it
description: t('combobox.custom-value.create', 'Create custom value'),
*/
value: inputValue as T,
description: t('combobox.custom-value.description', 'Use custom value'),
};
itemsToSet = items.slice(0);
@@ -128,6 +128,40 @@ describe('MultiCombobox', () => {
expect(await screen.findByText('d')).toBeInTheDocument();
});
it('should be able to set custom value', async () => {
const options = [
{ label: 'A', value: 'a' },
{ label: 'B', value: 'b' },
{ label: 'C', value: 'c' },
];
const onChange = jest.fn();
render(<MultiCombobox options={options} value={[]} onChange={onChange} createCustomValue />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'D');
await user.keyboard('{arrowdown}{enter}');
expect(onChange).toHaveBeenCalledWith([{ label: 'D', value: 'D', description: 'Use custom value' }]);
});
it('should be able to add custom value to the selected options', async () => {
const options = [
{ label: 'A', value: 'a' },
{ label: 'B', value: 'b' },
{ label: 'C', value: 'c' },
];
const onChange = jest.fn();
render(<MultiCombobox options={options} value={['a', 'c']} onChange={onChange} createCustomValue />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'D');
await user.keyboard('{arrowdown}{enter}');
expect(onChange).toHaveBeenCalledWith([
{ value: 'a' },
{ value: 'c' },
{ label: 'D', value: 'D', description: 'Use custom value' },
]);
});
it('should remove value when clicking on the close icon of the pill', async () => {
const options = [
{ label: 'A', value: 'a' },
@@ -37,8 +37,19 @@ interface MultiComboboxBaseProps<T extends string | number> extends Omit<Combobo
export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
const { placeholder, onChange, value, width, enableAllOption, invalid, disabled, minWidth, maxWidth, isClearable } =
props;
const {
placeholder,
onChange,
value,
width,
enableAllOption,
invalid,
disabled,
minWidth,
maxWidth,
isClearable,
createCustomValue = false,
} = props;
const styles = useStyles2(getComboboxStyles);
const [inputValue, setInputValue] = useState('');
@@ -55,7 +66,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
}, [inputValue]);
// Handle async options and the 'All' option
const { options: baseOptions, updateOptions, asyncLoading } = useOptions(props.options);
const { options: baseOptions, updateOptions, asyncLoading } = useOptions(props.options, createCustomValue);
const options = useMemo(() => {
// Only add the 'All' option if there's more than 1 option
const addAllOption = enableAllOption && baseOptions.length > 1;
@@ -202,14 +213,12 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
const filteredSet = new Set(realOptions.map((item) => item.value));
newSelectedItems = selectedItems.filter((item) => !filteredSet.has(item.value));
}
setSelectedItems(newSelectedItems);
} else if (newSelectedItem && isOptionSelected(newSelectedItem)) {
removeSelectedItem(newSelectedItem);
} else if (newSelectedItem) {
addSelectedItem(newSelectedItem);
}
break;
case useCombobox.stateChangeTypes.InputChange:
setInputValue(newInputValue ?? '');
@@ -20,9 +20,6 @@ export function itemToString<T extends string | number>(item?: ComboboxOption<T>
if (item == null) {
return '';
}
if (item.label?.startsWith('Custom value: ')) {
return item.value.toString();
}
return item.label ?? item.value.toString();
}
@@ -1,6 +1,8 @@
import { debounce } from 'lodash';
import { useState, useCallback, useMemo } from 'react';
import { t } from '../../utils/i18n';
import { itemFilter } from './filter';
import { ComboboxOption } from './types';
import { StaleResultError, useLatestAsyncCall } from './useLatestAsyncCall';
@@ -20,7 +22,7 @@ const asyncNoop = () => Promise.resolve([]);
* - function to call when user types (to filter, or call async fn)
* - loading and error states
*/
export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T>) {
export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T>, createCustomValue: boolean) {
const isAsync = typeof rawOptions === 'function';
const loadOptions = useLatestAsyncCall(isAsync ? rawOptions : asyncNoop);
@@ -56,6 +58,27 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
// told it for async options loading anyway.
const [userTypedSearch, setUserTypedSearch] = useState('');
const addCustomValue = useCallback(
(opts: Array<ComboboxOption<T>>) => {
let currentOptions: Array<ComboboxOption<T>> = opts;
if (createCustomValue && userTypedSearch) {
const customValueExists = opts.some((opt) => opt.value === userTypedSearch);
if (!customValueExists) {
currentOptions = [
{
label: userTypedSearch,
value: userTypedSearch as T,
description: t('combobox.custom-value.description', 'Use custom value'),
},
...currentOptions,
];
}
}
return currentOptions;
},
[createCustomValue, userTypedSearch]
);
const updateOptions = useCallback(
(inputValue: string) => {
if (!isAsync) {
@@ -71,12 +94,15 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
);
const finalOptions = useMemo(() => {
let currentOptions = [];
if (isAsync) {
return asyncOptions;
currentOptions = addCustomValue(asyncOptions);
} else {
return rawOptions.filter(itemFilter(userTypedSearch));
currentOptions = addCustomValue(rawOptions.filter(itemFilter(userTypedSearch)));
}
}, [rawOptions, asyncOptions, isAsync, userTypedSearch]);
return currentOptions;
}, [isAsync, addCustomValue, asyncOptions, rawOptions, userTypedSearch]);
return { options: finalOptions, updateOptions, asyncLoading, asyncError };
}
+4 -3
View File
@@ -33,16 +33,17 @@ type Display struct {
// AvatarURL is the url where we can get the avatar for identity
AvatarURL string `json:"avatarURL,omitempty"`
// InternalID is the legacy numreric id for identity, this is deprecated and should be phased out
// InternalID is the legacy numeric id for identity,
// Deprecated: use the identityRef where possible
InternalID int64 `json:"internalId,omitempty"`
}
type IdentityRef struct {
// Type of identity e.g. "user".
// For a full list see https://github.com/grafana/authlib/blob/2f8d13a83ca3e82da08b53726de1697ee5b5b4cc/claims/type.go#L15-L24
// For a full list see https://github.com/grafana/authlib/blob/d6737a7dc8f55e9d42834adb83b5da607ceed293/types/type.go#L15
Type claims.IdentityType `json:"type"`
// Name is the unique identifier for identity, guaranteed jo be a unique value for the type within a namespace.
// Name is the unique identifier for identity, guaranteed to be a unique value for the type within a namespace.
Name string `json:"name"`
}
@@ -72,7 +72,7 @@ func schema_pkg_apis_iam_v0alpha1_Display(ref common.ReferenceCallback) common.O
},
"internalId": {
SchemaProps: spec.SchemaProps{
Description: "InternalID is the legacy numreric id for identity, this is deprecated and should be phased out",
Description: "InternalID is the legacy numeric id for identity, Deprecated: use the identityRef where possible",
Type: []string{"integer"},
Format: "int64",
},
@@ -188,7 +188,7 @@ func schema_pkg_apis_iam_v0alpha1_IdentityRef(ref common.ReferenceCallback) comm
Properties: map[string]spec.Schema{
"type": {
SchemaProps: spec.SchemaProps{
Description: "Type of identity e.g. \"user\". For a full list see https://github.com/grafana/authlib/blob/2f8d13a83ca3e82da08b53726de1697ee5b5b4cc/claims/type.go#L15-L24",
Description: "Type of identity e.g. \"user\". For a full list see https://github.com/grafana/authlib/blob/d6737a7dc8f55e9d42834adb83b5da607ceed293/types/type.go#L15",
Default: "",
Type: []string{"string"},
Format: "",
@@ -196,7 +196,7 @@ func schema_pkg_apis_iam_v0alpha1_IdentityRef(ref common.ReferenceCallback) comm
},
"name": {
SchemaProps: spec.SchemaProps{
Description: "Name is the unique identifier for identity, guaranteed jo be a unique value for the type within a namespace.",
Description: "Name is the unique identifier for identity, guaranteed to be a unique value for the type within a namespace.",
Default: "",
Type: []string{"string"},
Format: "",
@@ -763,7 +763,7 @@ func schema_pkg_apis_iam_v0alpha1_TeamMember(ref common.ReferenceCallback) commo
},
"internalId": {
SchemaProps: spec.SchemaProps{
Description: "InternalID is the legacy numreric id for identity, this is deprecated and should be phased out",
Description: "InternalID is the legacy numeric id for identity, Deprecated: use the identityRef where possible",
Type: []string{"integer"},
Format: "int64",
},
+12 -4
View File
@@ -10,6 +10,7 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
common "k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/validation/spec"
"github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
@@ -34,6 +35,9 @@ type IdentityAccessManagementAPIBuilder struct {
authorizer authorizer.Authorizer
accessClient types.AccessClient
// non-k8s api route
display *user.LegacyDisplayREST
// Not set for multi-tenant deployment for now
sso ssosettings.Service
}
@@ -52,6 +56,7 @@ func RegisterAPIService(
sso: ssoService,
authorizer: authorizer,
accessClient: client,
display: user.NewLegacyDisplayREST(store),
}
apiregistration.RegisterAPI(builder)
@@ -60,7 +65,8 @@ func RegisterAPIService(
func NewAPIService(store legacy.LegacyIdentityStore) *IdentityAccessManagementAPIBuilder {
return &IdentityAccessManagementAPIBuilder{
store: store,
store: store,
display: user.NewLegacyDisplayREST(store),
authorizer: authorizer.AuthorizerFunc(
func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
user, err := identity.GetRequester(ctx)
@@ -114,9 +120,6 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
storage[ssoResource.StoragePath()] = sso.NewLegacyStore(b.sso)
}
// The display endpoint -- NOTE, this uses a rewrite hack to allow requests without a name parameter
storage["display"] = user.NewLegacyDisplayREST(b.store)
apiGroupInfo.VersionedResourcesStorageMap[iamv0.VERSION] = storage
return nil
}
@@ -125,6 +128,11 @@ func (b *IdentityAccessManagementAPIBuilder) GetOpenAPIDefinitions() common.GetO
return iamv0.GetOpenAPIDefinitions
}
func (b *IdentityAccessManagementAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
return b.display.GetAPIRoutes(defs)
}
func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authorizer {
return b.authorizer
}
+132 -101
View File
@@ -1,123 +1,154 @@
package user
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
errorsK8s "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/rest"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
claims "github.com/grafana/authlib/types"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/api/dtos"
iamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1"
iam "github.com/grafana/grafana/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/errhttp"
)
type LegacyDisplayREST struct {
store legacy.LegacyIdentityStore
}
var (
_ rest.Storage = (*LegacyDisplayREST)(nil)
_ rest.SingularNameProvider = (*LegacyDisplayREST)(nil)
_ rest.Connecter = (*LegacyDisplayREST)(nil)
_ rest.Scoper = (*LegacyDisplayREST)(nil)
_ rest.StorageMetadata = (*LegacyDisplayREST)(nil)
)
func NewLegacyDisplayREST(store legacy.LegacyIdentityStore) *LegacyDisplayREST {
return &LegacyDisplayREST{store}
}
func (r *LegacyDisplayREST) New() runtime.Object {
return &iamv0.DisplayList{}
}
func (r *LegacyDisplayREST) Destroy() {}
func (r *LegacyDisplayREST) NamespaceScoped() bool {
return true
}
func (r *LegacyDisplayREST) GetSingularName() string {
return "display"
}
func (r *LegacyDisplayREST) ProducesMIMETypes(verb string) []string {
return []string{"application/json"}
}
func (r *LegacyDisplayREST) ProducesObject(verb string) any {
return &iamv0.DisplayList{}
}
func (r *LegacyDisplayREST) ConnectMethods() []string {
return []string{http.MethodGet}
}
func (r *LegacyDisplayREST) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, "" // true means you can use the trailing path as a variable
func (r *LegacyDisplayREST) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *builder.APIRoutes {
listSchema := defs["github.com/grafana/grafana/pkg/apis/iam/v0alpha1.DisplayList"].Schema
displaySchema := defs["github.com/grafana/grafana/pkg/apis/iam/v0alpha1.Display"].Schema
identitySchema := defs["github.com/grafana/grafana/pkg/apis/iam/v0alpha1.IdentityRef"].Schema
listSchema.Properties["display"].Items.Schema = &displaySchema // not sure why this is lost
displaySchema.Properties["identity"] = identitySchema // not sure why this is lost
return &builder.APIRoutes{
Namespace: []builder.APIRouteHandler{
{
Path: "display",
Spec: &spec3.PathProps{
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
OperationId: "getDisplayMapping", // This is used by RTK client generator
Tags: []string{"Display"},
Description: "Show user display information",
Parameters: []*spec3.Parameter{
{
ParameterProps: spec3.ParameterProps{
Name: "namespace",
In: "path",
Required: true,
Example: "default",
Description: "workspace",
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "key",
In: "query",
Description: "Display keys",
Required: true,
Example: "user:u000000001",
Schema: spec.ArrayProperty(spec.StringProperty()),
// Style: "form",
Explode: true,
},
},
},
Responses: &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
StatusCodeResponses: map[int]*spec3.Response{
200: {
ResponseProps: spec3.ResponseProps{
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: &listSchema,
},
},
},
},
},
},
},
},
},
},
},
Handler: r.handleDisplay,
},
},
}
}
// This will always have an empty app url
var fakeCfgForGravatar = &setting.Cfg{}
func (r *LegacyDisplayREST) Connect(ctx context.Context, name string, _ runtime.Object, responder rest.Responder) (http.Handler, error) {
// See: /pkg/services/apiserver/builder/helper.go#L34
// The name is set with a rewriter hack
if name != "name" {
return nil, errorsK8s.NewNotFound(schema.GroupResource{}, name)
func (r *LegacyDisplayREST) handleDisplay(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
user, ok := authlib.AuthInfoFrom(ctx)
if !ok {
errhttp.Write(ctx, apierrors.NewUnauthorized("missing auth info"), w)
return
}
ns, err := request.NamespaceInfoFrom(ctx, true)
ns, err := authlib.ParseNamespace(user.GetNamespace())
if err != nil {
return nil, err
errhttp.Write(ctx, err, w)
return
}
keys := parseKeys(req.URL.Query()["key"])
users, err := r.store.ListDisplay(ctx, ns, legacy.ListDisplayQuery{
OrgID: ns.OrgID,
UIDs: keys.uids,
IDs: keys.ids,
})
if err != nil {
errhttp.Write(ctx, err, w)
return
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
keys := parseKeys(req.URL.Query()["key"])
users, err := r.store.ListDisplay(ctx, ns, legacy.ListDisplayQuery{
OrgID: ns.OrgID,
UIDs: keys.uids,
IDs: keys.ids,
})
if err != nil {
responder.Error(err)
return
rsp := &iam.DisplayList{
Keys: keys.keys,
InvalidKeys: keys.invalid,
Items: make([]iam.Display, 0, len(users.Users)+len(keys.disp)+1),
}
for _, user := range users.Users {
disp := iam.Display{
Identity: iam.IdentityRef{
Type: authlib.TypeUser,
Name: user.UID,
},
DisplayName: user.NameOrFallback(),
InternalID: user.ID, // nolint:staticcheck
}
if user.IsServiceAccount {
disp.Identity.Type = authlib.TypeServiceAccount
}
disp.AvatarURL = dtos.GetGravatarUrlWithDefault(fakeCfgForGravatar, user.Email, disp.DisplayName)
rsp.Items = append(rsp.Items, disp)
}
rsp := &iamv0.DisplayList{
Keys: keys.keys,
InvalidKeys: keys.invalid,
Items: make([]iamv0.Display, 0, len(users.Users)+len(keys.disp)+1),
}
for _, user := range users.Users {
disp := iamv0.Display{
Identity: iamv0.IdentityRef{
Type: claims.TypeUser,
Name: user.UID,
},
DisplayName: user.NameOrFallback(),
InternalID: user.ID,
}
if user.IsServiceAccount {
disp.Identity.Type = claims.TypeServiceAccount
}
disp.AvatarURL = dtos.GetGravatarUrlWithDefault(fakeCfgForGravatar, user.Email, disp.DisplayName)
rsp.Items = append(rsp.Items, disp)
}
// Append the constants here
if len(keys.disp) > 0 {
rsp.Items = append(rsp.Items, keys.disp...)
}
// Append the constants here
if len(keys.disp) > 0 {
rsp.Items = append(rsp.Items, keys.disp...)
}
responder.Object(200, rsp)
}), nil
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(rsp)
}
type dispKeys struct {
@@ -127,7 +158,7 @@ type dispKeys struct {
invalid []string
// For terminal keys, this is a constant
disp []iamv0.Display
disp []iam.Display
}
func parseKeys(req []string) dispKeys {
@@ -139,7 +170,7 @@ func parseKeys(req []string) dispKeys {
for _, key := range req {
idx := strings.Index(key, ":")
if idx > 0 {
t, err := claims.ParseType(key[0:idx])
t, err := authlib.ParseType(key[0:idx])
if err != nil {
keys.invalid = append(keys.invalid, key)
continue
@@ -147,18 +178,18 @@ func parseKeys(req []string) dispKeys {
key = key[idx+1:]
switch t {
case claims.TypeAnonymous:
keys.disp = append(keys.disp, iamv0.Display{
Identity: iamv0.IdentityRef{
case authlib.TypeAnonymous:
keys.disp = append(keys.disp, iam.Display{
Identity: iam.IdentityRef{
Type: t,
},
DisplayName: "Anonymous",
AvatarURL: dtos.GetGravatarUrl(fakeCfgForGravatar, string(t)),
})
continue
case claims.TypeAPIKey:
keys.disp = append(keys.disp, iamv0.Display{
Identity: iamv0.IdentityRef{
case authlib.TypeAPIKey:
keys.disp = append(keys.disp, iam.Display{
Identity: iam.IdentityRef{
Type: t,
Name: key,
},
@@ -166,9 +197,9 @@ func parseKeys(req []string) dispKeys {
AvatarURL: dtos.GetGravatarUrl(fakeCfgForGravatar, string(t)),
})
continue
case claims.TypeProvisioning:
keys.disp = append(keys.disp, iamv0.Display{
Identity: iamv0.IdentityRef{
case authlib.TypeProvisioning:
keys.disp = append(keys.disp, iam.Display{
Identity: iam.IdentityRef{
Type: t,
},
DisplayName: "Provisioning",
@@ -184,9 +215,9 @@ func parseKeys(req []string) dispKeys {
id, err := strconv.ParseInt(key, 10, 64)
if err == nil {
if id == 0 {
keys.disp = append(keys.disp, iamv0.Display{
Identity: iamv0.IdentityRef{
Type: claims.TypeUser,
keys.disp = append(keys.disp, iam.Display{
Identity: iam.IdentityRef{
Type: authlib.TypeUser,
Name: key,
},
DisplayName: "System admin",
@@ -15,7 +15,6 @@ import (
var (
_ rest.Storage = (*LegacyUserTeamREST)(nil)
_ rest.Scoper = (*LegacyUserTeamREST)(nil)
_ rest.StorageMetadata = (*LegacyUserTeamREST)(nil)
_ rest.Connecter = (*LegacyUserTeamREST)(nil)
)
@@ -36,11 +35,6 @@ func (s *LegacyUserTeamREST) New() runtime.Object {
// Destroy implements rest.Storage.
func (s *LegacyUserTeamREST) Destroy() {}
// NamespaceScoped implements rest.Scoper.
func (s *LegacyUserTeamREST) NamespaceScoped() bool {
return true
}
// ProducesMIMETypes implements rest.StorageMetadata.
func (s *LegacyUserTeamREST) ProducesMIMETypes(verb string) []string {
return []string{"application/json"}
+3 -8
View File
@@ -4,18 +4,13 @@ import (
"context"
data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/query/clientapi"
)
// The query runner interface
type DataSourceClientSupplier interface {
// Get a client for a given datasource
GetDataSourceClient(ctx context.Context, ref data.DataSourceRef, headers map[string]string) (data.QueryDataClient, error)
}
type CommonDataSourceClientSupplier struct {
Client data.QueryDataClient
Client clientapi.QueryDataClient
}
func (s *CommonDataSourceClientSupplier) GetDataSourceClient(_ context.Context, _ data.DataSourceRef, _ map[string]string) (data.QueryDataClient, error) {
func (s *CommonDataSourceClientSupplier) GetDataSourceClient(_ context.Context, _ data.DataSourceRef, _ map[string]string) (clientapi.QueryDataClient, error) {
return s.Client, nil
}
+10 -10
View File
@@ -3,7 +3,6 @@ package client
import (
"context"
"fmt"
"net/http"
"sync"
"time"
@@ -14,6 +13,7 @@ import (
query "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry/apis/query/clientapi"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
@@ -36,11 +36,11 @@ type pluginRegistry struct {
dataSourcesService datasources.DataSourceService
}
var _ data.QueryDataClient = (*pluginClient)(nil)
var _ clientapi.QueryDataClient = (*pluginClient)(nil)
var _ query.DataSourceApiServerRegistry = (*pluginRegistry)(nil)
// NewQueryClientForPluginClient creates a client that delegates to the internal plugins.Client stack
func NewQueryClientForPluginClient(p plugins.Client, ctx *plugincontext.Provider) data.QueryDataClient {
func NewQueryClientForPluginClient(p plugins.Client, ctx *plugincontext.Provider) clientapi.QueryDataClient {
return &pluginClient{
pluginClient: p,
pCtxProvider: ctx,
@@ -57,19 +57,19 @@ func NewDataSourceRegistryFromStore(pluginStore pluginstore.Store,
}
// ExecuteQueryData implements QueryHelper.
func (d *pluginClient) QueryData(ctx context.Context, req data.QueryDataRequest) (int, *backend.QueryDataResponse, error) {
func (d *pluginClient) QueryData(ctx context.Context, req data.QueryDataRequest) (*backend.QueryDataResponse, error) {
queries, dsRef, err := data.ToDataSourceQueries(req)
if err != nil {
return http.StatusBadRequest, nil, err
return nil, err
}
if dsRef == nil {
return http.StatusBadRequest, nil, fmt.Errorf("expected single datasource request")
return nil, fmt.Errorf("expected single datasource request")
}
// NOTE: this depends on uid unique across datasources
settings, err := d.pCtxProvider.GetDataSourceInstanceSettings(ctx, dsRef.UID)
if err != nil {
return http.StatusBadRequest, nil, err
return nil, err
}
qdr := &backend.QueryDataRequest{
@@ -77,14 +77,14 @@ func (d *pluginClient) QueryData(ctx context.Context, req data.QueryDataRequest)
}
qdr.PluginContext, err = d.pCtxProvider.PluginContextForDataSource(ctx, settings)
if err != nil {
return http.StatusBadRequest, nil, err
return nil, err
}
rsp, err := d.pluginClient.QueryData(ctx, qdr)
if err != nil {
return http.StatusInternalServerError, rsp, err
return rsp, err
}
return query.GetResponseCode(rsp), rsp, err
return rsp, err
}
// GetDatasourceAPI implements DataSourceRegistry.
@@ -3,45 +3,23 @@ package client
import (
"context"
"fmt"
"net/http"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
query "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
testdata "github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource"
)
type testdataDummy struct{}
var _ data.QueryDataClient = (*testdataDummy)(nil)
var _ query.DataSourceApiServerRegistry = (*testdataDummy)(nil)
// NewTestDataClient creates a runner that only works with testdata
func NewTestDataClient() data.QueryDataClient {
return &testdataDummy{}
}
// NewTestDataRegistry returns a registry that only knows about testdata
func NewTestDataRegistry() query.DataSourceApiServerRegistry {
return &testdataDummy{}
}
// ExecuteQueryData implements QueryHelper.
func (d *testdataDummy) QueryData(ctx context.Context, req data.QueryDataRequest) (int, *backend.QueryDataResponse, error) {
queries, _, err := data.ToDataSourceQueries(req)
if err != nil {
return http.StatusBadRequest, nil, err
}
qdr := &backend.QueryDataRequest{Queries: queries}
rsp, err := testdata.ProvideService().QueryData(ctx, qdr)
return query.GetResponseCode(rsp), rsp, err
}
// GetDatasourceAPI implements DataSourceRegistry.
func (*testdataDummy) GetDatasourceGroupVersion(pluginId string) (schema.GroupVersion, error) {
if pluginId == "testdata" || pluginId == "grafana-testdata-datasource" {
@@ -0,0 +1,18 @@
package clientapi
import (
"context"
"github.com/grafana/grafana-plugin-sdk-go/backend"
data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
)
type QueryDataClient interface {
QueryData(ctx context.Context, req data.QueryDataRequest) (*backend.QueryDataResponse, error)
}
// The query runner interface
type DataSourceClientSupplier interface {
// Get a client for a given datasource
GetDataSourceClient(ctx context.Context, ref data.DataSourceRef, headers map[string]string) (QueryDataClient, error)
}
+1 -11
View File
@@ -242,7 +242,7 @@ func (b *QueryAPIBuilder) handleQuerySingleDatasource(ctx context.Context, req d
return qdr, err
}
code, rsp, err := client.QueryData(ctx, *req.Request)
rsp, err := client.QueryData(ctx, *req.Request)
if err == nil && rsp != nil {
for _, q := range req.Request.Queries {
if q.ResultAssertions != nil {
@@ -259,16 +259,6 @@ func (b *QueryAPIBuilder) handleQuerySingleDatasource(ctx context.Context, req d
}
}
// Create a response object with the error when missing (happens for client errors like 404)
if rsp == nil && err != nil {
rsp = &backend.QueryDataResponse{Responses: make(backend.Responses)}
for _, q := range req.Request.Queries {
rsp.Responses[q.RefID] = backend.DataResponse{
Status: backend.Status(code),
Error: err,
}
}
}
return rsp, err
}
+2 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/expr"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/query/clientapi"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/stretchr/testify/require"
@@ -95,7 +96,7 @@ type mockClient struct {
lastCalledWithHeaders *map[string]string
}
func (m mockClient) GetDataSourceClient(ctx context.Context, ref data.DataSourceRef, headers map[string]string) (data.QueryDataClient, error) {
func (m mockClient) GetDataSourceClient(ctx context.Context, ref data.DataSourceRef, headers map[string]string) (clientapi.QueryDataClient, error) {
*m.lastCalledWithHeaders = headers
return nil, fmt.Errorf("mock error")
+3 -2
View File
@@ -19,6 +19,7 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry/apis/query/client"
"github.com/grafana/grafana/pkg/registry/apis/query/clientapi"
"github.com/grafana/grafana/pkg/registry/apis/query/queryschema"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
@@ -40,14 +41,14 @@ type QueryAPIBuilder struct {
tracer tracing.Tracer
metrics *queryMetrics
parser *queryParser
client DataSourceClientSupplier
client clientapi.DataSourceClientSupplier
registry query.DataSourceApiServerRegistry
converter *expr.ResultConverter
queryTypes *query.QueryTypeDefinitionList
}
func NewQueryAPIBuilder(features featuremgmt.FeatureToggles,
client DataSourceClientSupplier,
client clientapi.DataSourceClientSupplier,
registry query.DataSourceApiServerRegistry,
legacy service.LegacyDataSourceLookup,
registerer prometheus.Registerer,
-6
View File
@@ -52,12 +52,6 @@ var PathRewriters = []filters.PathRewriter{
return matches[1] + "/name" // connector requires a name
},
},
{
Pattern: regexp.MustCompile(`(/apis/iam.grafana.app/v0alpha1/namespaces/.*/display$)`),
ReplaceFunc: func(matches []string) string {
return matches[1] + "/name" // connector requires a name
},
},
{
Pattern: regexp.MustCompile(`(/apis/.*/v0alpha1/namespaces/.*/queryconvert$)`),
ReplaceFunc: func(matches []string) string {
+49 -21
View File
@@ -619,27 +619,28 @@ func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool,
}
}
folderSet := make(map[string]struct{}, len(scopeMap))
prefix := t.prefix()
itemSet := make(map[string]struct{}, len(scopeMap))
for scope := range scopeMap {
if strings.HasPrefix(scope, "folders:uid:") {
identifier := strings.TrimPrefix(scope, "folders:uid:")
if _, ok := folderSet[identifier]; ok {
continue
}
folderSet[identifier] = struct{}{}
getChildren(folderMap, identifier, folderSet)
} else {
identifier := strings.TrimPrefix(scope, prefix)
itemSet[identifier] = struct{}{}
}
var res *authzv1.ListResponse
if strings.HasPrefix(req.Action, "folders:") {
res = buildFolderList(scopeMap, folderMap)
} else {
res = buildItemList(scopeMap, folderMap, t.prefix())
}
folderList := make([]string, 0, len(folderSet))
for folder := range folderSet {
folderList = append(folderList, folder)
span.SetAttributes(attribute.Int("num_folders", len(res.Folders)), attribute.Int("num_items", len(res.Items)))
return res, nil
}
func buildFolderList(scopes map[string]bool, tree map[string]FolderNode) *authzv1.ListResponse {
itemSet := make(map[string]struct{}, len(scopes))
for scope := range scopes {
identifier := strings.TrimPrefix(scope, "folders:uid:")
if _, ok := itemSet[identifier]; ok {
continue
}
itemSet[identifier] = struct{}{}
getChildren(tree, identifier, itemSet)
}
itemList := make([]string, 0, len(itemSet))
@@ -647,8 +648,35 @@ func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool,
itemList = append(itemList, item)
}
span.SetAttributes(attribute.Int("num_folders", len(folderList)), attribute.Int("num_items", len(itemList)))
return &authzv1.ListResponse{Folders: folderList, Items: itemList}, nil
return &authzv1.ListResponse{Items: itemList}
}
func buildItemList(scopes map[string]bool, tree map[string]FolderNode, prefix string) *authzv1.ListResponse {
folderSet := make(map[string]struct{}, len(scopes))
itemSet := make(map[string]struct{}, len(scopes))
for scope := range scopes {
if identifier, ok := strings.CutPrefix(scope, "folders:uid:"); ok {
if _, ok := folderSet[identifier]; ok {
continue
}
folderSet[identifier] = struct{}{}
getChildren(tree, identifier, folderSet)
} else {
identifier := strings.TrimPrefix(scope, prefix)
itemSet[identifier] = struct{}{}
}
}
folderList := make([]string, 0, len(folderSet))
for folder := range folderSet {
folderList = append(folderList, folder)
}
itemList := make([]string, 0, len(itemSet))
for item := range itemSet {
itemList = append(itemList, item)
}
return &authzv1.ListResponse{Folders: folderList, Items: itemList}
}
func getChildren(folderMap map[string]FolderNode, folderUID string, folderSet map[string]struct{}) {
+34 -12
View File
@@ -452,13 +452,13 @@ func TestService_buildFolderTree(t *testing.T) {
func TestService_listPermission(t *testing.T) {
type testCase struct {
name string
permissions []accesscontrol.Permission
folderTree map[string]FolderNode
list ListRequest
expectedDashboards []string
expectedFolders []string
expectedAll bool
name string
permissions []accesscontrol.Permission
folderTree map[string]FolderNode
list ListRequest
expectedItems []string
expectedFolders []string
expectedAll bool
}
testCases := []testCase{
@@ -512,8 +512,8 @@ func TestService_listPermission(t *testing.T) {
Group: "dashboard.grafana.app",
Resource: "dashboards",
},
expectedDashboards: []string{"some_dashboard"},
expectedFolders: []string{"some_folder_1", "some_folder_2"},
expectedItems: []string{"some_dashboard"},
expectedFolders: []string{"some_folder_1", "some_folder_2"},
},
{
name: "should return folders that user has inherited access to",
@@ -568,8 +568,8 @@ func TestService_listPermission(t *testing.T) {
Group: "dashboard.grafana.app",
Resource: "dashboards",
},
expectedDashboards: []string{"some_dashboard"},
expectedFolders: []string{"some_folder_parent", "some_folder_child"},
expectedItems: []string{"some_dashboard"},
expectedFolders: []string{"some_folder_parent", "some_folder_child"},
},
{
name: "should deduplicate folders that user has inherited as well as direct access to",
@@ -613,6 +613,28 @@ func TestService_listPermission(t *testing.T) {
Resource: "dashboards",
},
},
{
name: "should collect folder permissions into items",
permissions: []accesscontrol.Permission{
{
Action: "folders:read",
Scope: "folders:uid:some_folder_parent",
Kind: "folders",
Attribute: "uid",
Identifier: "some_folder_parent",
},
},
folderTree: map[string]FolderNode{
"some_folder_parent": {UID: "some_folder_parent", ChildrenUIDs: []string{"some_folder_child"}},
"some_folder_child": {UID: "some_folder_child", ParentUID: strPtr("some_folder_parent")},
},
list: ListRequest{
Action: "folders:read",
Group: "folder.grafana.app",
Resource: "folders",
},
expectedItems: []string{"some_folder_parent", "some_folder_child"},
},
}
for _, tc := range testCases {
@@ -626,7 +648,7 @@ func TestService_listPermission(t *testing.T) {
got, err := s.listPermission(context.Background(), getScopeMap(tc.permissions), &tc.list)
require.NoError(t, err)
assert.Equal(t, tc.expectedAll, got.All)
assert.ElementsMatch(t, tc.expectedDashboards, got.Items)
assert.ElementsMatch(t, tc.expectedItems, got.Items)
assert.ElementsMatch(t, tc.expectedFolders, got.Folders)
})
}
@@ -11,14 +11,45 @@ import (
"gopkg.in/yaml.v3"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/provisioning/utils"
)
type configReader struct {
path string
log log.Logger
orgService org.Service
path string
log log.Logger
orgExists utils.OrgExists
}
func ReadDashboardConfig(dir string) ([]*DashboardProvisioning, error) {
var cfg []*DashboardProvisioning
cr := &configReader{
path: dir,
}
files, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
for _, file := range files {
if !strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml") {
continue
}
parsedDashboards, err := cr.parseConfigs(file)
if err != nil {
return nil, fmt.Errorf("could not parse provisioning config file: %s error: %v", file.Name(), err)
}
for _, config := range parsedDashboards {
cfg = append(cfg, &DashboardProvisioning{
config: *config,
})
}
}
return cfg, nil
}
func (cr *configReader) parseConfigs(file fs.DirEntry) ([]*config, error) {
@@ -96,7 +127,7 @@ func (cr *configReader) readConfig(ctx context.Context) ([]*config, error) {
dashboard.OrgID = 1
}
if err := utils.CheckOrgExists(ctx, cr.orgService, dashboard.OrgID); err != nil {
if err := cr.orgExists(ctx, dashboard.OrgID); err != nil {
return nil, fmt.Errorf("failed to provision dashboards with %q reader: %w", dashboard.Name, err)
}
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/org/orgtest"
"github.com/grafana/grafana/pkg/services/provisioning/utils"
)
var (
@@ -25,10 +26,11 @@ func TestDashboardsAsConfig(t *testing.T) {
logger := log.New("test-logger")
// store := db.InitTestDB(t)
orgFake := orgtest.NewOrgServiceFake()
orgExists := utils.NewOrgExistsChecker(orgFake)
t.Run("Should fail if orgs don't exist in the database", func(t *testing.T) {
orgFake.ExpectedError = org.ErrOrgNotFound
cfgProvider := configReader{path: appliedDefaults, log: logger, orgService: orgFake}
cfgProvider := configReader{path: appliedDefaults, log: logger, orgExists: orgExists}
_, err := cfgProvider.readConfig(context.Background())
require.Error(t, err)
assert.True(t, errors.Is(err, org.ErrOrgNotFound))
@@ -36,7 +38,7 @@ func TestDashboardsAsConfig(t *testing.T) {
})
t.Run("default values should be applied", func(t *testing.T) {
cfgProvider := configReader{path: appliedDefaults, log: logger, orgService: orgFake}
cfgProvider := configReader{path: appliedDefaults, log: logger, orgExists: orgExists}
cfg, err := cfgProvider.readConfig(context.Background())
require.NoError(t, err)
@@ -47,7 +49,7 @@ func TestDashboardsAsConfig(t *testing.T) {
t.Run("Can read config file version 1 format", func(t *testing.T) {
t.Setenv("TEST_VAR", "general")
cfgProvider := configReader{path: simpleDashboardConfig, log: logger, orgService: orgFake}
cfgProvider := configReader{path: simpleDashboardConfig, log: logger, orgExists: orgExists}
cfg, err := cfgProvider.readConfig(context.Background())
require.NoError(t, err)
@@ -55,7 +57,7 @@ func TestDashboardsAsConfig(t *testing.T) {
})
t.Run("Can read config file in version 0 format", func(t *testing.T) {
cfgProvider := configReader{path: oldVersion, log: logger, orgService: orgFake}
cfgProvider := configReader{path: oldVersion, log: logger, orgExists: orgExists}
cfg, err := cfgProvider.readConfig(context.Background())
require.NoError(t, err)
@@ -63,7 +65,7 @@ func TestDashboardsAsConfig(t *testing.T) {
})
t.Run("Should skip invalid path", func(t *testing.T) {
cfgProvider := configReader{path: "/invalid-directory", log: logger, orgService: orgFake}
cfgProvider := configReader{path: "/invalid-directory", log: logger, orgExists: orgExists}
cfg, err := cfgProvider.readConfig(context.Background())
if err != nil {
t.Fatalf("readConfig return an error %v", err)
@@ -73,7 +75,7 @@ func TestDashboardsAsConfig(t *testing.T) {
})
t.Run("Should skip broken config files", func(t *testing.T) {
cfgProvider := configReader{path: brokenConfigs, log: logger, orgService: orgFake}
cfgProvider := configReader{path: brokenConfigs, log: logger, orgExists: orgExists}
cfg, err := cfgProvider.readConfig(context.Background())
if err != nil {
t.Fatalf("readConfig return an error %v", err)
@@ -42,7 +42,7 @@ func (provider *Provisioner) HasDashboardSources() bool {
// New returns a new DashboardProvisioner
func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgService org.Service, dashboardStore utils.DashboardStore, folderService folder.Service) (DashboardProvisioner, error) {
logger := log.New("provisioning.dashboard")
cfgReader := &configReader{path: configDirectory, log: logger, orgService: orgService}
cfgReader := &configReader{path: configDirectory, log: logger, orgExists: utils.NewOrgExistsChecker(orgService)}
configs, err := cfgReader.readConfig(ctx)
if err != nil {
return nil, fmt.Errorf("%v: %w", "Failed to read dashboards config", err)
@@ -36,6 +36,13 @@ type configV0 struct {
AllowUIUpdates bool `json:"allowUiUpdates" yaml:"allowUiUpdates"`
}
// Access to dashboard provisioning config
// Exposes the internal config outside this package with as few changes as possible
// NOTE: these provisioning configs will eventually be replaced with: /apis/provisioning.grafana.app/
type DashboardProvisioning struct {
config
}
type configVersion struct {
APIVersion int64 `json:"apiVersion" yaml:"apiVersion"`
}
@@ -131,7 +131,8 @@ func (cr *configReader) validateDefaultUniqueness(ctx context.Context, datasourc
}
func (cr *configReader) validateAccessAndOrgID(ctx context.Context, ds *upsertDataSourceFromConfig) error {
if err := utils.CheckOrgExists(ctx, cr.orgService, ds.OrgID); err != nil {
checker := utils.NewOrgExistsChecker(cr.orgService)
if err := checker(ctx, ds.OrgID); err != nil {
return err
}
+14 -8
View File
@@ -13,14 +13,20 @@ type DashboardStore interface {
GetDashboard(context.Context, *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error)
}
func CheckOrgExists(ctx context.Context, orgService org.Service, orgID int64) error {
query := org.GetOrgByIDQuery{ID: orgID}
_, err := orgService.GetByID(ctx, &query)
if err != nil {
if errors.Is(err, org.ErrOrgNotFound) {
return err
// Throw an error if the org does not exist
type OrgExists = func(ctx context.Context, orgID int64) error
// Use the org service to check if an org exists
func NewOrgExistsChecker(orgService org.Service) OrgExists {
return func(ctx context.Context, orgID int64) error {
query := org.GetOrgByIDQuery{ID: orgID}
_, err := orgService.GetByID(ctx, &query)
if err != nil {
if errors.Is(err, org.ErrOrgNotFound) {
return err
}
return fmt.Errorf("failed to check whether org. with the given ID exists: %w", err)
}
return fmt.Errorf("failed to check whether org. with the given ID exists: %w", err)
return nil
}
return nil
}
@@ -35,54 +35,133 @@
}
}
},
"/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/display/{name}": {
"/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/display": {
"get": {
"tags": [
"DisplayList"
"Display"
],
"description": "Show user display information",
"operationId": "getDisplayMapping",
"parameters": [
{
"name": "namespace",
"in": "path",
"description": "workspace",
"required": true,
"schema": {
"type": "string"
},
"example": "default"
},
{
"name": "key",
"in": "query",
"description": "Display keys",
"required": true,
"explode": true,
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"example": "user:u000000001"
}
],
"description": "connect GET requests to DisplayList",
"operationId": "getDisplayList",
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.iam.v0alpha1.DisplayList"
"type": "object",
"required": [
"keys",
"display"
],
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
"type": "string"
},
"display": {
"description": "Matching items (the caller may need to remap from keys to results)",
"type": "array",
"items": {
"type": "object",
"required": [
"identity",
"displayName"
],
"properties": {
"avatarURL": {
"description": "AvatarURL is the url where we can get the avatar for identity",
"type": "string"
},
"displayName": {
"description": "Display name for identity.",
"type": "string",
"default": ""
},
"identity": {
"type": "object",
"required": [
"type",
"name"
],
"properties": {
"name": {
"description": "Name is the unique identifier for identity, guaranteed to be a unique value for the type within a namespace.",
"type": "string",
"default": ""
},
"type": {
"description": "Type of identity e.g. \"user\". For a full list see https://github.com/grafana/authlib/blob/d6737a7dc8f55e9d42834adb83b5da607ceed293/types/type.go#L15",
"type": "string",
"default": ""
}
}
},
"internalId": {
"description": "InternalID is the legacy numeric id for identity, Deprecated: use the identityRef where possible",
"type": "integer",
"format": "int64"
}
}
},
"x-kubernetes-list-type": "atomic"
},
"invalidKeys": {
"description": "Input keys that were not useable",
"type": "array",
"items": {
"type": "string",
"default": ""
},
"x-kubernetes-list-type": "set"
},
"keys": {
"description": "Request keys used to lookup the display value",
"type": "array",
"items": {
"type": "string",
"default": ""
},
"x-kubernetes-list-type": "set"
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
"type": "string"
},
"metadata": {
"default": {}
}
}
}
}
}
}
},
"x-kubernetes-action": "connect",
"x-kubernetes-group-version-kind": {
"group": "iam.grafana.app",
"version": "v0alpha1",
"kind": "DisplayList"
}
},
"parameters": [
{
"name": "name",
"in": "path",
"description": "name of the DisplayList",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
},
{
"name": "namespace",
"in": "path",
"description": "object name and auth scope, such as for teams and projects",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
}
]
}
},
"/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/serviceaccounts": {
"get": {
@@ -2432,105 +2511,6 @@
"additionalProperties": true,
"x-kubernetes-preserve-unknown-fields": true
},
"com.github.grafana.grafana.pkg.apis.iam.v0alpha1.Display": {
"type": "object",
"required": [
"identity",
"displayName"
],
"properties": {
"avatarURL": {
"description": "AvatarURL is the url where we can get the avatar for identity",
"type": "string"
},
"displayName": {
"description": "Display name for identity.",
"type": "string",
"default": ""
},
"identity": {
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.iam.v0alpha1.IdentityRef"
}
]
},
"internalId": {
"description": "InternalID is the legacy numreric id for identity, this is deprecated and should be phased out",
"type": "integer",
"format": "int64"
}
}
},
"com.github.grafana.grafana.pkg.apis.iam.v0alpha1.DisplayList": {
"type": "object",
"required": [
"keys",
"display"
],
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
"type": "string"
},
"display": {
"description": "Matching items (the caller may need to remap from keys to results)",
"type": "array",
"items": {
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.iam.v0alpha1.Display"
}
]
},
"x-kubernetes-list-type": "atomic"
},
"invalidKeys": {
"description": "Input keys that were not useable",
"type": "array",
"items": {
"type": "string",
"default": ""
},
"x-kubernetes-list-type": "set"
},
"keys": {
"description": "Request keys used to lookup the display value",
"type": "array",
"items": {
"type": "string",
"default": ""
},
"x-kubernetes-list-type": "set"
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
"type": "string"
},
"metadata": {
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta"
}
]
}
},
"x-kubernetes-group-version-kind": [
{
"group": "iam.grafana.app",
"kind": "DisplayList",
"version": "__internal"
},
{
"group": "iam.grafana.app",
"kind": "DisplayList",
"version": "v0alpha1"
}
]
},
"com.github.grafana.grafana.pkg.apis.iam.v0alpha1.IdentityRef": {
"type": "object",
"required": [
@@ -2539,12 +2519,12 @@
],
"properties": {
"name": {
"description": "Name is the unique identifier for identity, guaranteed jo be a unique value for the type within a namespace.",
"description": "Name is the unique identifier for identity, guaranteed to be a unique value for the type within a namespace.",
"type": "string",
"default": ""
},
"type": {
"description": "Type of identity e.g. \"user\". For a full list see https://github.com/grafana/authlib/blob/2f8d13a83ca3e82da08b53726de1697ee5b5b4cc/claims/type.go#L15-L24",
"description": "Type of identity e.g. \"user\". For a full list see https://github.com/grafana/authlib/blob/d6737a7dc8f55e9d42834adb83b5da607ceed293/types/type.go#L15",
"type": "string",
"default": ""
}
@@ -2977,7 +2957,7 @@
]
},
"internalId": {
"description": "InternalID is the legacy numreric id for identity, this is deprecated and should be phased out",
"description": "InternalID is the legacy numeric id for identity, Deprecated: use the identityRef where possible",
"type": "integer",
"format": "int64"
},
@@ -12,6 +12,7 @@ import {
} from '@grafana/scenes';
import { ElementSelectionContextItem, ElementSelectionContextState, ToolbarButton, useStyles2 } from '@grafana/ui';
import { isInCloneChain } from '../utils/clone';
import { getDashboardSceneFor } from '../utils/utils';
import { ElementEditPane } from './ElementEditPane';
@@ -46,6 +47,16 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
}
private selectElement(element: ElementSelectionContextItem, multi?: boolean) {
// We should not select clones
if (isInCloneChain(element.id)) {
if (multi) {
return;
}
this.clearSelection();
return;
}
const obj = sceneGraph.findByKey(this, element.id);
if (obj) {
this.selectObject(obj, element.id, multi);
@@ -23,6 +23,7 @@ import { createWorker } from '../saving/createDetectChangesWorker';
import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { DecoratedRevisionModel } from '../settings/VersionsEditView';
import { historySrv } from '../settings/version-history/HistorySrv';
import { getCloneKey } from '../utils/clone';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { djb2Hash } from '../utils/djb2Hash';
import { findVizPanelByKey, getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils';
@@ -33,7 +34,7 @@ import { LibraryPanelBehavior } from './LibraryPanelBehavior';
import { PanelTimeRange } from './PanelTimeRange';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { RowActions } from './row-actions/RowActions';
import { RowActions } from './layout-default/row-actions/RowActions';
jest.mock('../settings/version-history/HistorySrv');
jest.mock('../serialization/transformSaveModelToScene');
@@ -642,7 +643,7 @@ describe('DashboardScene', () => {
it('Should hash the key of the cloned panels and set it as panelId', () => {
const queryRunner = sceneGraph.findObject(scene, (o) => o.state.key === 'data-query-runner2')!;
const expectedPanelId = djb2Hash('panel-2-clone-1');
const expectedPanelId = djb2Hash(getCloneKey('panel-2', 1));
expect(scene.enrichDataRequest(queryRunner).panelId).toEqual(expectedPanelId);
});
});
@@ -915,7 +916,7 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
new DashboardGridItem({
body: new VizPanel({
title: 'Panel B',
key: 'panel-2-clone-1',
key: getCloneKey('panel-2', 1),
pluginId: 'table',
$data: new SceneQueryRunner({ key: 'data-query-runner2', queries: [{ refId: 'A' }] }),
}),
@@ -53,17 +53,12 @@ import { DecoratedRevisionModel } from '../settings/VersionsEditView';
import { DashboardEditView } from '../settings/utils';
import { historySrv } from '../settings/version-history';
import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper';
import { isInCloneChain } from '../utils/clone';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { djb2Hash } from '../utils/djb2Hash';
import { getDashboardUrl } from '../utils/getDashboardUrl';
import { getViewPanelUrl } from '../utils/urlBuilders';
import {
getClosestVizPanel,
getDashboardSceneFor,
getDefaultVizPanel,
getPanelIdForVizPanel,
isPanelClone,
} from '../utils/utils';
import { getClosestVizPanel, getDashboardSceneFor, getDefaultVizPanel, getPanelIdForVizPanel } from '../utils/utils';
import { SchemaV2EditorDrawer } from '../v2schema/SchemaV2EditorDrawer';
import { AddLibraryPanelDrawer } from './AddLibraryPanelDrawer';
@@ -489,6 +484,10 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
return this._initialState;
}
public getNextPanelId(): number {
return this.state.body.getMaxPanelId() + 1;
}
public addPanel(vizPanel: VizPanel): void {
if (!this.state.isEditing) {
this.onEnterEditMode();
@@ -615,10 +614,11 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
public switchLayout(layout: DashboardLayoutManager) {
this.setState({ body: layout });
layout.activateRepeaters?.();
}
/**
* Called by the SceneQueryRunner to privide contextural parameters (tracking) props for the request
* Called by the SceneQueryRunner to provide contextual parameters (tracking) props for the request
*/
public enrichDataRequest(sceneObject: SceneObject): Partial<DataQueryRequest> {
const dashboard = getDashboardSceneFor(sceneObject);
@@ -632,9 +632,12 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
let panelId = 0;
if (panel && panel.state.key) {
if (isPanelClone(panel.state.key)) {
if (isInCloneChain(panel.state.key)) {
// We check if any of the panel ancestors are clones because we can't use the original panel ID in this case
panelId = djb2Hash(panel?.state.key);
} else {
// Otherwise, it's the absolute original panel, and we can use the key directly
// getPanelIdForVizPanel extracts the panel ID from the key so we don't need to do it manually
panelId = getPanelIdForVizPanel(panel);
}
}
@@ -3,6 +3,8 @@ import { SceneQueryRunner, VizPanel } from '@grafana/scenes';
import appEvents from 'app/core/app_events';
import { KioskMode } from 'app/types';
import { getCloneKey } from '../utils/clone';
import { DashboardScene } from './DashboardScene';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
@@ -82,7 +84,7 @@ describe('DashboardSceneUrlSync', () => {
let errorNotice = 0;
appEvents.on(AppEvents.alertError, (evt) => errorNotice++);
scene.urlSync?.updateFromUrl({ viewPanel: 'panel-1-clone-1' });
scene.urlSync?.updateFromUrl({ viewPanel: getCloneKey('panel-1', 1) });
expect(scene.state.viewPanelScene).toBeUndefined();
// Verify no error notice was shown
@@ -98,7 +100,7 @@ describe('DashboardSceneUrlSync', () => {
x: 0,
body: new VizPanel({
title: 'Clone Panel A',
key: 'panel-1-clone-1',
key: getCloneKey('panel-1', 1),
pluginId: 'table',
}),
}),
@@ -107,7 +109,7 @@ describe('DashboardSceneUrlSync', () => {
// Verify it subscribes to DashboardRepeatsProcessedEvent
scene.publishEvent(new DashboardRepeatsProcessedEvent({ source: scene }));
expect(scene.state.viewPanelScene?.getUrlKey()).toBe('panel-1-clone-1');
expect(scene.state.viewPanelScene?.getUrlKey()).toBe(getCloneKey('panel-1', 1));
});
});
@@ -12,7 +12,8 @@ import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { createDashboardEditViewFor } from '../settings/utils';
import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer';
import { ShareModal } from '../sharing/ShareModal';
import { findVizPanelByKey, getLibraryPanelBehavior, isPanelClone } from '../utils/utils';
import { containsCloneKey } from '../utils/clone';
import { findVizPanelByKey, getLibraryPanelBehavior } from '../utils/utils';
import { DashboardScene, DashboardSceneState } from './DashboardScene';
import { LibraryPanelBehavior } from './LibraryPanelBehavior';
@@ -94,8 +95,10 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
const panel = findVizPanelByKey(this._scene, values.viewPanel);
if (!panel) {
// // If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet
if (isPanelClone(values.viewPanel)) {
// If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet
// Here we check if the key contains the clone key so we force the repeat processing
// It doesn't matter if the element or the ancestors are clones or not, just that the key contains the clone key
if (containsCloneKey(values.viewPanel)) {
this._handleViewRepeatClone(values.viewPanel);
return;
}
@@ -30,15 +30,10 @@ import { ShowConfirmModalEvent } from 'app/types/events';
import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer';
import { ShareModal } from '../sharing/ShareModal';
import { isInCloneChain } from '../utils/clone';
import { DashboardInteractions } from '../utils/interactions';
import { getEditPanelUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders';
import {
getDashboardSceneFor,
getPanelIdForVizPanel,
getQueryRunnerFor,
isLibraryPanel,
isReadOnlyClone,
} from '../utils/utils';
import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor, isLibraryPanel } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks';
@@ -59,7 +54,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
const dashboard = getDashboardSceneFor(panel);
const { isEmbedded } = dashboard.state.meta;
const exploreMenuItem = await getExploreMenuItem(panel);
const isReadOnlyRepeat = isReadOnlyClone(panel);
const isReadOnlyRepeat = isInCloneChain(panel.state.key!);
// For embedded dashboards we only have explore action for now
if (isEmbedded) {
@@ -3,8 +3,8 @@ import { setPluginImportUtils } from '@grafana/runtime';
import { SceneGridLayout, SceneVariableSet, TestVariable, VizPanel } from '@grafana/scenes';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { isInCloneChain } from '../../utils/clone';
import { activateFullSceneTree, buildPanelRepeaterScene } from '../../utils/test-utils';
import { isReadOnlyClone } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { DashboardGridItem, DashboardGridItemState } from './DashboardGridItem';
@@ -42,8 +42,8 @@ describe('PanelRepeaterGridItem', () => {
expect(panel1.state.$variables?.state.variables[0].getValueText?.()).toBe('A');
expect(panel2.state.$variables?.state.variables[0].getValue()).toBe('2');
expect(isReadOnlyClone(panel1)).toBe(false);
expect(isReadOnlyClone(panel2)).toBe(true);
expect(isInCloneChain(panel1.state.key!)).toBe(false);
expect(isInCloneChain(panel2.state.key!)).toBe(true);
});
it('Should wait for variable to load', async () => {
@@ -23,6 +23,7 @@ import {
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { getCloneKey } from '../../utils/clone';
import { getMultiVariableValues, getQueryRunnerFor } from '../../utils/utils';
import { DashboardLayoutItem, DashboardRepeatsProcessedEvent } from '../types';
@@ -138,7 +139,7 @@ export class DashboardGridItem
}),
],
}),
key: `${panelToRepeat.state.key}-clone-${index}`,
key: getCloneKey(panelToRepeat.state.key!, index),
};
const clone = panelToRepeat.clone(cloneState);
repeatedPanels.push(clone);
@@ -1,6 +1,7 @@
import { SceneGridItemLike, SceneGridLayout, SceneGridRow, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { findVizPanelByKey } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { DashboardGridItem } from './DashboardGridItem';
import { DefaultGridLayoutManager } from './DefaultGridLayoutManager';
@@ -277,5 +278,7 @@ function setup(options?: TestOptions) {
const grid = new SceneGridLayout({ children: gridItems });
const manager = new DefaultGridLayoutManager({ grid: grid });
new DashboardScene({ body: manager });
return { manager, grid };
}
@@ -12,18 +12,21 @@ import {
} from '@grafana/scenes';
import { GRID_COLUMN_COUNT } from 'app/core/constants';
import { isClonedKey, joinCloneKeys } from '../../utils/clone';
import {
forceRenderChildren,
getPanelIdForVizPanel,
NEW_PANEL_HEIGHT,
NEW_PANEL_WIDTH,
getVizPanelKeyForPanelId,
getGridItemKeyForPanelId,
getDashboardSceneFor,
} from '../../utils/utils';
import { RowRepeaterBehavior } from '../RowRepeaterBehavior';
import { RowActions } from '../row-actions/RowActions';
import { DashboardLayoutManager, LayoutRegistryItem } from '../types';
import { DashboardGridItem } from './DashboardGridItem';
import { RowRepeaterBehavior } from './RowRepeaterBehavior';
import { RowActions } from './row-actions/RowActions';
interface DefaultGridLayoutManagerState extends SceneObjectState {
grid: SceneGridLayout;
@@ -66,7 +69,7 @@ export class DefaultGridLayoutManager
x: 0,
y: 0,
body: vizPanel,
key: `grid-item-${panelId}`,
key: getGridItemKeyForPanelId(panelId),
});
this.state.grid.setState({
@@ -75,7 +78,7 @@ export class DefaultGridLayoutManager
}
/**
* Adds a new emtpy row
* Adds a new empty row
*/
public addNewRow(): SceneGridRow {
const id = this.getNextPanelId();
@@ -127,7 +130,7 @@ export class DefaultGridLayoutManager
/**
* Removes a panel
*/
public removePanel(panel: VizPanel) {
public removePanel(panel: VizPanel): void {
const gridItem = panel.parent!;
if (!(gridItem instanceof DashboardGridItem)) {
@@ -231,7 +234,7 @@ export class DefaultGridLayoutManager
return panels;
}
public getNextPanelId(): number {
public getMaxPanelId(): number {
let max = 0;
for (const child of this.state.grid.state.children) {
@@ -271,10 +274,14 @@ export class DefaultGridLayoutManager
}
}
return max + 1;
return max;
}
public collapseAllRows() {
public getNextPanelId(): number {
return getDashboardSceneFor(this).getNextPanelId();
}
public collapseAllRows(): void {
this.state.grid.state.children.forEach((child) => {
if (!(child instanceof SceneGridRow)) {
return;
@@ -285,7 +292,7 @@ export class DefaultGridLayoutManager
});
}
public expandAllRows() {
public expandAllRows(): void {
this.state.grid.state.children.forEach((child) => {
if (!(child instanceof SceneGridRow)) {
return;
@@ -296,7 +303,7 @@ export class DefaultGridLayoutManager
});
}
activateRepeaters(): void {
public activateRepeaters(): void {
this.state.grid.forEachChild((child) => {
if (child instanceof DashboardGridItem && !child.isActive) {
child.activate();
@@ -325,6 +332,76 @@ export class DefaultGridLayoutManager
return DefaultGridLayoutManager.getDescriptor();
}
public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
return this.clone({
grid: this.state.grid.clone({
isResizable: isSource && this.state.grid.state.isResizable,
isDraggable: isSource && this.state.grid.state.isDraggable,
children: this.state.grid.state.children.reduce<{ panelId: number; children: SceneGridItemLike[] }>(
(childrenAcc, child) => {
if (child instanceof DashboardGridItem) {
const gridItemKey = joinCloneKeys(ancestorKey, getGridItemKeyForPanelId(childrenAcc.panelId));
const gridItem = child.clone({
key: gridItemKey,
body: child.state.body.clone({
key: joinCloneKeys(gridItemKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)),
}),
isDraggable: isSource && child.state.isDraggable,
isResizable: isSource && child.state.isResizable,
});
childrenAcc.children.push(gridItem);
return childrenAcc;
}
if (child instanceof SceneGridRow) {
const rowKey = joinCloneKeys(ancestorKey, getVizPanelKeyForPanelId(childrenAcc.panelId++));
const row = child.clone({
key: rowKey,
children: child.state.children.reduce<SceneGridItemLike[]>((rowAcc, rowChild) => {
if (isClonedKey(rowChild.state.key!)) {
return rowAcc;
}
if (!(rowChild instanceof DashboardGridItem)) {
rowAcc.push(rowChild.clone());
return rowAcc;
}
const gridItemKey = joinCloneKeys(rowKey, getGridItemKeyForPanelId(childrenAcc.panelId));
const gridItem = rowChild.clone({
key: gridItemKey,
isDraggable: isSource && rowChild.state.isDraggable,
isResizable: isSource && rowChild.state.isResizable,
body: rowChild.state.body.clone({
key: joinCloneKeys(gridItemKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)),
}),
});
rowAcc.push(gridItem);
return rowAcc;
}, []),
isDraggable: isSource && child.state.isDraggable,
isResizable: isSource && child.state.isResizable,
});
childrenAcc.children.push(row);
return childrenAcc;
}
childrenAcc.children.push(child.clone());
return childrenAcc;
},
{ panelId: 0, children: [] }
).children,
}),
});
}
public static getDescriptor(): LayoutRegistryItem {
return {
name: 'Default grid',
@@ -389,9 +466,15 @@ export class DefaultGridLayoutManager
/**
* Useful for preserving items positioning when switching layouts
* @param gridItems
* @param isDraggable
* @param isResizable
* @returns
*/
public static fromGridItems(gridItems: SceneGridItemLike[]): DefaultGridLayoutManager {
public static fromGridItems(
gridItems: SceneGridItemLike[],
isDraggable?: boolean,
isResizable?: boolean
): DefaultGridLayoutManager {
const children = gridItems.reduce<SceneGridItemLike[]>((acc, gridItem) => {
gridItem.clearParent();
acc.push(gridItem);
@@ -402,8 +485,8 @@ export class DefaultGridLayoutManager
return new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children,
isDraggable: true,
isResizable: true,
isDraggable,
isResizable,
}),
});
}
@@ -13,13 +13,13 @@ import {
} from '@grafana/scenes';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { activateFullSceneTree } from '../utils/test-utils';
import { isReadOnlyClone } from '../utils/utils';
import { getCloneKey, isInCloneChain, joinCloneKeys } from '../../utils/clone';
import { activateFullSceneTree } from '../../utils/test-utils';
import { DashboardScene } from '../DashboardScene';
import { DashboardScene } from './DashboardScene';
import { RepeatDirection } from './DashboardGridItem';
import { DefaultGridLayoutManager } from './DefaultGridLayoutManager';
import { RowRepeaterBehavior } from './RowRepeaterBehavior';
import { RepeatDirection } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { RowActions } from './row-actions/RowActions';
jest.mock('@grafana/runtime', () => ({
@@ -54,24 +54,31 @@ describe('RowRepeaterBehavior', () => {
// Verify that first row still has repeat behavior
const row1 = grid.state.children[1] as SceneGridRow;
expect(row1.state.key).toBe(getCloneKey('row-1', 0));
expect(row1.state.$behaviors?.[0]).toBeInstanceOf(RowRepeaterBehavior);
expect(row1.state.$variables!.state.variables[0].getValue()).toBe('A1');
expect(row1.state.actions).toBeDefined();
const gridItemRow1 = row1.state.children[0] as SceneGridItem;
expect(gridItemRow1.state.key!).toBe(joinCloneKeys(row1.state.key!, 'griditem-1'));
expect(gridItemRow1.state.body?.state.key).toBe(joinCloneKeys(gridItemRow1.state.key!, 'canvas-1'));
const row2 = grid.state.children[2] as SceneGridRow;
expect(row2.state.key).toBe(getCloneKey('row-1', 1));
expect(row2.state.$behaviors).toEqual([]);
expect(row2.state.$variables!.state.variables[0].getValueText?.()).toBe('B');
expect(row2.state.actions).toBeUndefined();
// Should give repeated panels unique keys
const gridItem = row2.state.children[0] as SceneGridItem;
expect(gridItem.state.body?.state.key).toBe('canvas-1-clone-B1');
const gridItemRow2 = row2.state.children[0] as SceneGridItem;
expect(gridItemRow2.state.key!).toBe(joinCloneKeys(row2.state.key!, 'griditem-1'));
expect(gridItemRow2.state.body?.state.key).toBe(joinCloneKeys(gridItemRow2.state.key!, 'canvas-1'));
});
it('Repeated rows should be read only', () => {
const row1 = grid.state.children[1] as SceneGridRow;
const row2 = grid.state.children[2] as SceneGridRow;
expect(isReadOnlyClone(row1)).toBe(false);
expect(isReadOnlyClone(row2)).toBe(true);
expect(isInCloneChain(row1.state.key!)).toBe(false);
expect(isInCloneChain(row2.state.key!)).toBe(true);
});
it('Should update all rows when a panel is added to a clone', async () => {
@@ -209,6 +216,7 @@ function buildScene(
const grid = new SceneGridLayout({
children: [
new SceneGridItem({
key: 'griditem-no-row',
x: 0,
y: 0,
width: 24,
@@ -218,6 +226,7 @@ function buildScene(
}),
}),
new SceneGridRow({
key: 'row-1',
x: 0,
y: 10,
width: 24,
@@ -226,6 +235,7 @@ function buildScene(
$behaviors: [repeatBehavior],
children: [
new SceneGridItem({
key: 'griditem-1',
x: 0,
y: 11,
width: 24,
@@ -238,12 +248,12 @@ function buildScene(
],
}),
new SceneGridRow({
key: 'row-2',
x: 0,
y: 16,
width: 24,
height: 5,
title: 'Row at the bottom',
children: [
new SceneGridItem({
key: 'griditem-2',
@@ -14,10 +14,18 @@ import {
VariableValueSingle,
} from '@grafana/scenes';
import { getMultiVariableValues } from '../utils/utils';
import {
containsCloneKey,
getLastKeyFromClone,
isClonedKeyOf,
joinCloneKeys,
getCloneKey,
isClonedKey,
} from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { DashboardRepeatsProcessedEvent } from '../types';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DashboardRepeatsProcessedEvent } from './types';
import { DashboardGridItem } from './DashboardGridItem';
interface RowRepeaterBehaviorState extends SceneObjectState {
variableName: string;
@@ -47,11 +55,11 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
const layout = this._getLayout();
const originalRow = this._getRow();
const filterKey = originalRow.state.key + '-clone-';
const originalRowNonClonedPanels = originalRow.state.children.filter((child) => !isClonedKey(child.state.key!));
const sub = layout.subscribeToState(() => {
const repeatedRows = layout.state.children.filter(
(child) => child instanceof SceneGridRow && child.state.key?.includes(filterKey)
const repeatedRows = layout.state.children.filter((child) =>
isClonedKeyOf(child.state.key!, originalRow.state.key!)
);
// go through cloned rows, search for panels that are not clones
@@ -60,24 +68,29 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
continue;
}
const rowNonClonedPanels = row.state.children.filter((child) => !isClonedKey(child.state.key!));
// if no differences in row children compared to original, then no new panel added to clone
if (row.state.children.length === originalRow.state.children.length) {
if (rowNonClonedPanels.length === originalRowNonClonedPanels.length) {
continue;
}
//if there are differences, find the new panel, move it to the original and perform re peat
const gridItem = row.state.children.find((gridItem) => !gridItem.state.key?.includes('clone'));
// if there are differences, find the new panel, move it to the original and perform repeat
const gridItem = rowNonClonedPanels.find((gridItem) => !containsCloneKey(gridItem.state.key!));
if (gridItem) {
const newGridItem = gridItem.clone();
row.setState({ children: row.state.children.filter((item) => item !== gridItem) });
// if we are moving a panel from the origin row to a clone row, we just return
// this means we are modifying the origin row, retriggering the repeat and losing that panel
// this means we are modifying the origin row, re-triggering the repeat and losing that panel
if (originalRow.state.children.find((item) => item.state.key === newGridItem.state.key)) {
return;
}
originalRow.setState({ children: [...originalRow.state.children, newGridItem] });
this.performRepeat(true);
}
}
@@ -135,13 +148,14 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
this._prevRepeatValues = values;
this._clonedRows = [];
const rowContent = rowToRepeat.state.children;
const rowContentHeight = getRowContentHeight(rowContent);
let maxYOfRows = 0;
// when variable has no options (due to error or similar) it will not render any panels at all
// adding a placeholder in this case so that there is at least empty panel that can display error
// adding a placeholder in this case so that there is at least empty panel that can display error
const emptyVariablePlaceholderOption = {
values: [''],
texts: variable.hasAllValue() ? ['All'] : ['None'],
@@ -150,108 +164,73 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
const variableValues = values.length ? values : emptyVariablePlaceholderOption.values;
const variableTexts = texts.length ? texts : emptyVariablePlaceholderOption.texts;
// Loop through variable values and create repeates
for (let index = 0; index < variableValues.length; index++) {
const children: SceneGridItemLike[] = [];
const localValue = variableValues[index];
for (let rowIndex = 0; rowIndex < variableValues.length; rowIndex++) {
const isSourceRow = rowIndex === 0;
const rowClone = isSourceRow
? rowToRepeat
: rowToRepeat.clone({
y: (rowToRepeat.state.y ?? 0) + rowContentHeight * rowIndex + rowIndex,
$behaviors: [],
actions: undefined,
});
// Loop through panels inside row
for (const source of rowContent) {
const sourceItemY = source.state.y ?? 0;
const itemY = sourceItemY + (rowContentHeight + 1) * index;
const itemKey = index > 0 ? `${source.state.key}-clone-${localValue}` : source.state.key;
const itemClone = source.clone({ key: itemKey, y: itemY });
const rowCloneKey = getCloneKey(rowToRepeat.state.key!, rowIndex);
// Make sure all the child scene objects have unique keys
// and add proper menu to the repeated panel
if (index > 0) {
ensureUniqueKeys(itemClone, localValue);
//disallow clones to be dragged around or out of the row
if (itemClone instanceof DashboardGridItem) {
itemClone.setState({ isDraggable: false });
}
}
children.push(itemClone);
if (maxYOfRows < itemY + itemClone.state.height!) {
maxYOfRows = itemY + itemClone.state.height!;
}
}
const rowClone = this.getRowClone(
rowToRepeat,
index,
localValue,
variableTexts[index],
rowContentHeight,
children,
variable
);
this._clonedRows.push(rowClone);
}
updateLayout(layout, this._clonedRows, maxYOfRows, rowToRepeat);
// Used from dashboard url sync
this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true);
}
getRowClone(
rowToRepeat: SceneGridRow,
index: number,
value: VariableValueSingle,
text: VariableValueSingle,
rowContentHeight: number,
children: SceneGridItemLike[],
variable: MultiValueVariable
): SceneGridRow {
if (index === 0) {
rowToRepeat.setState({
// not activated
rowClone.setState({
key: rowCloneKey,
$variables: new SceneVariableSet({
variables: [
new LocalValueVariable({
name: this.state.variableName,
value,
text: String(text),
value: variableValues[rowIndex],
text: String(variableTexts[rowIndex]),
isMulti: variable.state.isMulti,
includeAll: variable.state.includeAll,
}),
],
}),
children,
children: [],
});
return rowToRepeat;
const children: SceneGridItemLike[] = [];
for (const sourceItem of rowContent) {
const sourceItemY = sourceItem.state.y ?? 0;
const cloneItemKey = joinCloneKeys(rowCloneKey, getLastKeyFromClone(sourceItem.state.key!));
const cloneItemY = sourceItemY + (rowContentHeight + 1) * rowIndex;
const cloneItem = sourceItem.clone({
key: cloneItemKey,
y: cloneItemY,
isDraggable: !isSourceRow && sourceItem instanceof DashboardGridItem ? false : sourceItem.state.isDraggable,
isResizable: !isSourceRow && sourceItem instanceof DashboardGridItem ? false : sourceItem.state.isResizable,
});
ensureUniqueKeys(cloneItem, cloneItemKey);
children.push(cloneItem);
if (maxYOfRows < cloneItemY + cloneItem.state.height!) {
maxYOfRows = cloneItemY + cloneItem.state.height!;
}
}
rowClone.setState({ children });
this._clonedRows.push(rowClone);
}
const sourceRowY = rowToRepeat.state.y ?? 0;
updateLayout(layout, this._clonedRows, maxYOfRows, rowToRepeat.state.key!);
return rowToRepeat.clone({
key: `${rowToRepeat.state.key}-clone-${value}`,
$variables: new SceneVariableSet({
variables: [
new LocalValueVariable({
name: this.state.variableName,
value,
text: String(text),
isMulti: variable.state.isMulti,
includeAll: variable.state.includeAll,
}),
],
}),
$behaviors: [],
children,
y: sourceRowY + rowContentHeight * index + index,
actions: undefined,
});
// Used from dashboard url sync
this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true);
}
public removeBehavior() {
const row = this._getRow();
const layout = this._getLayout();
const children = getLayoutChildrenFilterOutRepeatClones(this._getLayout(), this._getRow());
const children = getLayoutChildrenFilterOutRepeatClones(layout, row.state.key!);
layout.setState({ children: children });
@@ -280,9 +259,9 @@ function getRowContentHeight(panels: SceneGridItemLike[]): number {
return maxY - minY;
}
function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: number, rowToRepeat: SceneGridRow) {
const allChildren = getLayoutChildrenFilterOutRepeatClones(layout, rowToRepeat);
const index = allChildren.indexOf(rowToRepeat);
function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: number, rowKey: string) {
const allChildren = getLayoutChildrenFilterOutRepeatClones(layout, rowKey);
const index = allChildren.findIndex((child) => child.state.key!.includes(rowKey));
if (index === -1) {
throw new Error('RowRepeaterBehavior: Parent row not found in layout children');
@@ -310,19 +289,14 @@ function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows:
layout.setState({ children: newChildren });
}
function getLayoutChildrenFilterOutRepeatClones(layout: SceneGridLayout, rowToRepeat: SceneGridRow) {
return layout.state.children.filter((child) => {
if (child.state.key?.startsWith(`${rowToRepeat.state.key}-clone-`)) {
return false;
}
return true;
});
function getLayoutChildrenFilterOutRepeatClones(layout: SceneGridLayout, rowKey: string) {
return layout.state.children.filter((child) => !isClonedKeyOf(child.state.key!, rowKey));
}
function ensureUniqueKeys(item: SceneGridItemLike, localValue: VariableValueSingle) {
function ensureUniqueKeys(item: SceneGridItemLike, ancestors: string) {
item.forEachChild((child) => {
child.setState({ key: `${child.state.key}-clone-${localValue}` });
ensureUniqueKeys(child, localValue);
const key = joinCloneKeys(ancestors, child.state.key!);
child.setState({ key });
ensureUniqueKeys(child, key);
});
}
@@ -11,14 +11,16 @@ import {
} from '@grafana/scenes';
import { Icon, TextLink, useStyles2 } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { t, Trans } from 'app/core/internationalization';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { ShowConfirmModalEvent } from 'app/types/events';
import { getDashboardSceneFor, getQueryRunnerFor } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { getDashboardSceneFor, getQueryRunnerFor } from '../../../utils/utils';
import { DashboardScene } from '../../DashboardScene';
import { DashboardGridItem } from '../DashboardGridItem';
import { DefaultGridLayoutManager } from '../DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../RowRepeaterBehavior';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { RowOptionsButton } from './RowOptionsButton';
@@ -75,9 +77,12 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
public onDelete = () => {
appEvents.publish(
new ShowConfirmModalEvent({
title: 'Delete row',
text: 'Are you sure you want to remove this row and all its panels?',
altActionText: 'Delete row only',
title: t('dashboard.default-layout.row-actions.modal.title', 'Delete row'),
text: t(
'dashboard.default-layout.row-actions.modal.text',
'Are you sure you want to remove this row and all its panels?'
),
altActionText: t('dashboard.default-layout.row-actions.modal.alt-action', 'Delete row only'),
icon: 'trash-alt',
onConfirm: () => this.removeRow(true),
onAltAction: () => this.removeRow(),
@@ -97,7 +102,11 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
const vizPanel = gridItem.state.body;
if (vizPanel instanceof VizPanel) {
const runner = getQueryRunnerFor(vizPanel);
return runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY;
return (
runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY ||
(runner?.state.datasource?.uid === MIXED_DATASOURCE_NAME &&
runner?.state.queries.some((query) => query.datasource?.uid === SHARED_DASHBOARD_QUERY))
);
}
return false;
@@ -107,8 +116,10 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
return (
<div>
<p>
Panels in this row use the {SHARED_DASHBOARD_QUERY} data source. These panels will reference the panel in
the original row, not the ones in the repeated rows.
<Trans i18nKey="dashboard.default-layout.row-actions.repeat.warning.text">
Panels in this row use the {{ SHARED_DASHBOARD_QUERY }} data source. These panels will reference the panel
in the original row, not the ones in the repeated rows.
</Trans>
</p>
<TextLink
external
@@ -116,7 +127,7 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
}
>
Learn more
<Trans i18nKey="dashboard.default-layout.row-actions.repeat.warning.learn-more">Learn more</Trans>
</TextLink>
</div>
);
@@ -146,7 +157,11 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
onUpdate={model.onUpdate}
warning={model.getWarning()}
/>
<button type="button" onClick={model.onDelete} aria-label="Delete row">
<button
type="button"
onClick={model.onDelete}
aria-label={t('dashboard.default-layout.row-actions.delete', 'Delete row')}
>
<Icon name="trash-alt" />
</button>
</div>
@@ -2,6 +2,7 @@ import * as React from 'react';
import { SceneObject } from '@grafana/scenes';
import { Icon, ModalsController } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { OnRowOptionsUpdate } from './RowOptionsForm';
import { RowOptionsModal } from './RowOptionsModal';
@@ -27,7 +28,7 @@ export const RowOptionsButton = ({ repeat, title, parent, onUpdate, warning }: R
<button
type="button"
className="pointer"
aria-label="Row options"
aria-label={t('dashboard.default-layout.row-options.button.label', 'Row options')}
onClick={() => {
showModal(RowOptionsModal, {
title,
@@ -3,7 +3,7 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { selectors } from '@grafana/e2e-selectors';
import { DashboardScene } from '../DashboardScene';
import { DashboardScene } from '../../DashboardScene';
import { RowOptionsForm } from './RowOptionsForm';
@@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
import { selectors } from '@grafana/e2e-selectors';
import { SceneObject } from '@grafana/scenes';
import { Button, Field, Modal, Input, Alert } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
export type OnRowOptionsUpdate = (title: string, repeat?: string | null) => void;
@@ -34,10 +35,10 @@ export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate,
return (
<form onSubmit={handleSubmit(submit)}>
<Field label="Title">
<Field label={t('dashboard.default-layout.row-options.form.title', 'Title')}>
<Input {...register('title')} type="text" />
</Field>
<Field label="Repeat for">
<Field label={t('dashboard.default-layout.row-options.form.repeat-for', 'Repeat for')}>
<RepeatRowSelect2 sceneContext={sceneContext} repeat={newRepeat} onChange={onChangeRepeat} />
</Field>
{warning && (
@@ -53,9 +54,11 @@ export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate,
)}
<Modal.ButtonRow>
<Button type="button" variant="secondary" onClick={onCancel} fill="outline">
Cancel
<Trans i18nKey="dashboard.default-layout.row-options.form.cancel">Cancel</Trans>
</Button>
<Button type="submit">
<Trans i18nKey="dashboard.default-layout.row-options.form.update">Update</Trans>
</Button>
<Button type="submit">Update</Button>
</Modal.ButtonRow>
</form>
);
@@ -3,6 +3,7 @@ import * as React from 'react';
import { SceneObject } from '@grafana/scenes';
import { Modal, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { OnRowOptionsUpdate, RowOptionsForm } from './RowOptionsForm';
@@ -19,7 +20,12 @@ export const RowOptionsModal = ({ repeat, title, parent, onDismiss, onUpdate, wa
const styles = useStyles2(getStyles);
return (
<Modal isOpen={true} title="Row options" onDismiss={onDismiss} className={styles.modal}>
<Modal
isOpen={true}
title={t('dashboard.default-layout.row-options.modal.title', 'Row options')}
onDismiss={onDismiss}
className={styles.modal}
>
<RowOptionsForm
sceneContext={parent}
repeat={repeat}
@@ -38,7 +38,7 @@ export class ResponsiveGridLayoutManager
getDashboardSceneFor(this).switchLayout(rowsLayout);
}
public getNextPanelId(): number {
public getMaxPanelId(): number {
let max = 0;
for (const child of this.state.layout.state.children) {
@@ -54,6 +54,10 @@ export class ResponsiveGridLayoutManager
return max;
}
public getNextPanelId(): number {
return getDashboardSceneFor(this).getNextPanelId();
}
public removePanel(panel: VizPanel) {
const element = panel.parent;
this.state.layout.setState({ children: this.state.layout.state.children.filter((child) => child !== element) });
@@ -126,6 +130,7 @@ export class ResponsiveGridLayoutManager
activateRepeaters?(): void {
throw new Error('Method not implemented.');
}
public static Component = ({ model }: SceneComponentProps<ResponsiveGridLayoutManager>) => {
return <model.state.layout.Component model={model.state.layout} />;
};
@@ -1,17 +1,40 @@
import { css, cx } from '@emotion/css';
import { useMemo, useRef } from 'react';
import { ReactNode, useMemo, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { SceneObjectState, SceneObjectBase, SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { Button, Icon, Input, RadioButtonGroup, Switch, useElementSelection, useStyles2 } from '@grafana/ui';
import {
SceneObjectState,
SceneObjectBase,
SceneComponentProps,
sceneGraph,
VariableDependencyConfig,
} from '@grafana/scenes';
import {
Alert,
Button,
Icon,
Input,
RadioButtonGroup,
Switch,
TextLink,
useElementSelection,
useStyles2,
} from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { getDashboardSceneFor, getDefaultVizPanel } from '../../utils/utils';
import { isClonedKey } from '../../utils/clone';
import { getDashboardSceneFor, getDefaultVizPanel, getQueryRunnerFor } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
import { DashboardLayoutManager, EditableDashboardElement, LayoutParent } from '../types';
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
import { RowsLayoutManager } from './RowsLayoutManager';
export interface RowItemState extends SceneObjectState {
@@ -23,6 +46,10 @@ export interface RowItemState extends SceneObjectState {
}
export class RowItem extends SceneObjectBase<RowItemState> implements LayoutParent, EditableDashboardElement {
protected _variableDependency = new VariableDependencyConfig(this, {
statePaths: ['title'],
});
public isEditableDashboardElement: true = true;
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
@@ -54,10 +81,25 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
);
}, [row]);
const rowRepeatOptions = useMemo(() => {
const dashboard = getDashboardSceneFor(row);
return new OptionsPaneCategoryDescriptor({
title: 'Repeat options',
id: 'row-repeat-options',
isOpenDefault: true,
}).addItem(
new OptionsPaneItemDescriptor({
title: 'Variable',
render: () => <RowRepeatSelect row={row} dashboard={dashboard} />,
})
);
}, [row]);
const { layout } = this.useState();
const layoutOptions = useLayoutCategory(layout);
return [rowOptions, layoutOptions];
return [rowOptions, rowRepeatOptions, layoutOptions];
}
public getTypeName(): string {
@@ -69,7 +111,7 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
layout.removeRow(this);
};
public renderActions(): React.ReactNode {
public renderActions(): ReactNode {
return (
<>
<Button size="sm" variant="secondary" icon="copy" />
@@ -91,12 +133,14 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
};
public onAddPanel = (vizPanel = getDefaultVizPanel()) => {
this.state.layout.addPanel(vizPanel);
this.getLayout().addPanel(vizPanel);
};
public static Component = ({ model }: SceneComponentProps<RowItem>) => {
const { layout, title, isCollapsed, height = 'expand', isHeaderHidden, key } = model.useState();
const { isEditing, showHiddenElements } = getDashboardSceneFor(model).useState();
const isClone = useMemo(() => isClonedKey(key!), [key]);
const dashboard = getDashboardSceneFor(model);
const { isEditing, showHiddenElements } = dashboard.useState();
const styles = useStyles2(getStyles);
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
const ref = useRef<HTMLDivElement>(null);
@@ -109,7 +153,7 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
styles.wrapper,
isCollapsed && styles.wrapperCollapsed,
shouldGrow && styles.wrapperGrow,
isSelected && 'dashboard-selected-element'
!isClone && isSelected && 'dashboard-selected-element'
)}
ref={ref}
>
@@ -119,14 +163,14 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
onClick={model.onCollapseToggle}
className={styles.rowTitleButton}
aria-label={isCollapsed ? 'Expand row' : 'Collapse row'}
data-testid={selectors.components.DashboardRow.title(titleInterpolated)}
data-testid={selectors.components.DashboardRow.title(titleInterpolated!)}
>
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />
<span className={styles.rowTitle} role="heading">
{titleInterpolated}
</span>
</button>
{isEditing && (
{!isClone && isEditing && (
<Button icon="pen" variant="secondary" size="sm" fill="text" onPointerDown={(evt) => onSelect?.(evt)} />
)}
</div>
@@ -181,6 +225,7 @@ function getStyles(theme: GrafanaTheme2) {
display: 'flex',
flexDirection: 'column',
width: '100%',
minHeight: '100px',
}),
wrapperGrow: css({
flexGrow: 1,
@@ -188,6 +233,7 @@ function getStyles(theme: GrafanaTheme2) {
wrapperCollapsed: css({
flexGrow: 0,
borderBottom: `1px solid ${theme.colors.border.weak}`,
minHeight: 'unset',
}),
rowActions: css({
display: 'flex',
@@ -237,3 +283,68 @@ export function RowHeightSelect({ row }: { row: RowItem }) {
/>
);
}
export function RowRepeatSelect({ row, dashboard }: { row: RowItem; dashboard: DashboardScene }) {
const { layout, $behaviors } = row.useState();
let repeatBehavior: RowItemRepeaterBehavior | undefined = $behaviors?.find(
(b) => b instanceof RowItemRepeaterBehavior
);
const { variableName } = repeatBehavior?.state ?? {};
const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => {
const runner = getQueryRunnerFor(vizPanel);
return (
runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY ||
(runner?.state.datasource?.uid === MIXED_DATASOURCE_NAME &&
runner?.state.queries.some((query) => query.datasource?.uid === SHARED_DASHBOARD_QUERY))
);
});
return (
<>
<RepeatRowSelect2
sceneContext={dashboard}
repeat={variableName}
onChange={(repeat) => {
if (repeat) {
// Remove repeat behavior if it exists to trigger repeat when adding new one
if (repeatBehavior) {
repeatBehavior.removeBehavior();
}
repeatBehavior = new RowItemRepeaterBehavior({ variableName: repeat });
row.setState({ $behaviors: [...(row.state.$behaviors ?? []), repeatBehavior] });
repeatBehavior.activate();
} else {
repeatBehavior?.removeBehavior();
}
}}
/>
{isAnyPanelUsingDashboardDS ? (
<Alert
data-testid={selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage}
severity="warning"
title=""
topSpacing={3}
bottomSpacing={0}
>
<p>
<Trans i18nKey="dashboard.rows-layout.row.repeat.warning">
Panels in this row use the {{ SHARED_DASHBOARD_QUERY }} data source. These panels will reference the panel
in the original row, not the ones in the repeated rows.
</Trans>
</p>
<TextLink
external
href={
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
}
>
<Trans i18nKey="dashboard.rows-layout.row.repeat.learn-more">Learn more</Trans>
</TextLink>
</Alert>
) : undefined}
</>
);
}
@@ -0,0 +1,227 @@
import { VariableRefresh } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils } from '@grafana/runtime';
import {
SceneGridRow,
SceneTimeRange,
SceneVariableSet,
TestVariable,
VariableValueOption,
PanelBuilders,
} from '@grafana/scenes';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { TextMode } from 'app/plugins/panel/text/panelcfg.gen';
import { getCloneKey, isInCloneChain, joinCloneKeys } from '../../utils/clone';
import { activateFullSceneTree } from '../../utils/test-utils';
import { DashboardScene } from '../DashboardScene';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { RowItem } from './RowItem';
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
import { RowsLayoutManager } from './RowsLayoutManager';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
}));
setPluginImportUtils({
importPanelPlugin: () => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: () => undefined,
});
describe('RowItemRepeaterBehavior', () => {
describe('Given scene with variable with 5 values', () => {
let scene: DashboardScene, layout: RowsLayoutManager, repeatBehavior: RowItemRepeaterBehavior;
let layoutStateUpdates: unknown[];
beforeEach(async () => {
({ scene, layout, repeatBehavior } = buildScene({ variableQueryTime: 0 }));
layoutStateUpdates = [];
layout.subscribeToState((state) => layoutStateUpdates.push(state));
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
});
it('Should repeat row', () => {
// Verify that first row still has repeat behavior
const row1 = layout.state.rows[0];
expect(row1.state.key).toBe(getCloneKey('row-1', 0));
expect(row1.state.$behaviors?.[0]).toBeInstanceOf(RowItemRepeaterBehavior);
expect(row1.state.$variables!.state.variables[0].getValue()).toBe('A1');
const row1Children = getRowChildren(row1);
expect(row1Children[0].state.key!).toBe(joinCloneKeys(row1.state.key!, 'grid-item-0'));
expect(row1Children[0].state.body?.state.key).toBe(joinCloneKeys(row1Children[0].state.key!, 'panel-0'));
const row2 = layout.state.rows[1];
expect(row2.state.key).toBe(getCloneKey('row-1', 1));
expect(row2.state.$behaviors).toEqual([]);
expect(row2.state.$variables!.state.variables[0].getValueText?.()).toBe('B');
const row2Children = getRowChildren(row2);
expect(row2Children[0].state.key!).toBe(joinCloneKeys(row2.state.key!, 'grid-item-0'));
expect(row2Children[0].state.body?.state.key).toBe(joinCloneKeys(row2Children[0].state.key!, 'panel-0'));
});
it('Repeated rows should be read only', () => {
const row1 = layout.state.rows[0];
expect(isInCloneChain(row1.state.key!)).toBe(false);
const row2 = layout.state.rows[1];
expect(isInCloneChain(row2.state.key!)).toBe(true);
});
it('Should push row at the bottom down', () => {
// Should push row at the bottom down
const rowAtTheBottom = layout.state.rows[5];
expect(rowAtTheBottom.state.title).toBe('Row at the bottom');
});
it('Should handle second repeat cycle and update remove old repeats', async () => {
// trigger another repeat cycle by changing the variable
const variable = scene.state.$variables!.state.variables[0] as TestVariable;
variable.changeValueTo(['B1', 'C1']);
await new Promise((r) => setTimeout(r, 1));
// should now only have 2 repeated rows (and the panel above + the row at the bottom)
expect(layout.state.rows.length).toBe(3);
});
it('Should ignore repeat process if variable values are the same', async () => {
// trigger another repeat cycle by changing the variable
repeatBehavior.performRepeat();
await new Promise((r) => setTimeout(r, 1));
expect(layoutStateUpdates.length).toBe(1);
});
});
describe('Given a scene with empty variable', () => {
it('Should preserve repeat row', async () => {
const { scene, layout } = buildScene({ variableQueryTime: 0 }, []);
activateFullSceneTree(scene);
await new Promise((r) => setTimeout(r, 1));
// Should have 2 rows, one without repeat and one with the dummy row
expect(layout.state.rows.length).toBe(2);
expect(layout.state.rows[0].state.$behaviors?.[0]).toBeInstanceOf(RowItemRepeaterBehavior);
});
});
});
interface SceneOptions {
variableQueryTime: number;
variableRefresh?: VariableRefresh;
}
function buildTextPanel(key: string, content: string) {
const panel = PanelBuilders.text().setOption('content', content).setOption('mode', TextMode.Markdown).build();
panel.setState({ key });
return panel;
}
function buildScene(
options: SceneOptions,
variableOptions?: VariableValueOption[],
variableStateOverrides?: { isMulti: boolean }
) {
const repeatBehavior = new RowItemRepeaterBehavior({ variableName: 'server' });
const rows = [
new RowItem({
key: 'row-1',
$behaviors: [repeatBehavior],
layout: DefaultGridLayoutManager.fromGridItems([
new DashboardGridItem({
key: 'griditem-1',
x: 0,
y: 11,
width: 24,
height: 5,
body: buildTextPanel('text-1', 'Panel inside repeated row, server = $server'),
}),
]),
}),
new RowItem({
key: 'row-2',
title: 'Row at the bottom',
layout: DefaultGridLayoutManager.fromGridItems([
new DashboardGridItem({
key: 'griditem-2',
x: 0,
y: 17,
body: buildTextPanel('text-2', 'Panel inside row, server = $server'),
}),
new DashboardGridItem({
key: 'griditem-3',
x: 0,
y: 25,
body: buildTextPanel('text-3', 'Panel inside row, server = $server'),
}),
]),
}),
];
const layout = new RowsLayoutManager({ rows });
const scene = new DashboardScene({
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
$variables: new SceneVariableSet({
variables: [
new TestVariable({
name: 'server',
query: 'A.*',
value: ALL_VARIABLE_VALUE,
text: ALL_VARIABLE_TEXT,
isMulti: true,
includeAll: true,
delayMs: options.variableQueryTime,
refresh: options.variableRefresh,
optionsToReturn: variableOptions ?? [
{ label: 'A', value: 'A1' },
{ label: 'B', value: 'B1' },
{ label: 'C', value: 'C1' },
{ label: 'D', value: 'D1' },
{ label: 'E', value: 'E1' },
],
...variableStateOverrides,
}),
],
}),
body: layout,
});
const rowToRepeat = repeatBehavior.parent as SceneGridRow;
return { scene, layout, rows, repeatBehavior, rowToRepeat };
}
function getRowLayout(row: RowItem): DefaultGridLayoutManager {
const layout = row.getLayout();
if (!(layout instanceof DefaultGridLayoutManager)) {
throw new Error('Invalid layout');
}
return layout;
}
function getRowChildren(row: RowItem): DashboardGridItem[] {
const layout = getRowLayout(row);
const filteredChildren = layout.state.grid.state.children.filter((child) => child instanceof DashboardGridItem);
if (filteredChildren.length !== layout.state.grid.state.children.length) {
throw new Error('Invalid layout');
}
return filteredChildren;
}
@@ -0,0 +1,165 @@
import { isEqual } from 'lodash';
import {
LocalValueVariable,
MultiValueVariable,
sceneGraph,
SceneObjectBase,
SceneObjectState,
SceneVariableSet,
VariableDependencyConfig,
VariableValueSingle,
} from '@grafana/scenes';
import { isClonedKeyOf, getCloneKey } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { DashboardRepeatsProcessedEvent } from '../types';
import { RowItem } from './RowItem';
import { RowsLayoutManager } from './RowsLayoutManager';
interface RowItemRepeaterBehaviorState extends SceneObjectState {
variableName: string;
}
/**
* This behavior will run an effect function when specified variables change
*/
export class RowItemRepeaterBehavior extends SceneObjectBase<RowItemRepeaterBehaviorState> {
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [this.state.variableName],
onVariableUpdateCompleted: () => this.performRepeat(),
});
private _prevRepeatValues?: VariableValueSingle[];
private _clonedRows?: RowItem[];
public constructor(state: RowItemRepeaterBehaviorState) {
super(state);
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
this.performRepeat();
}
private _getRow(): RowItem {
if (!(this.parent instanceof RowItem)) {
throw new Error('RepeatedRowItemBehavior: Parent is not a RowItem');
}
return this.parent;
}
private _getLayout(): RowsLayoutManager {
const layout = this._getRow().parent;
if (!(layout instanceof RowsLayoutManager)) {
throw new Error('RepeatedRowItemBehavior: Layout is not a RowsLayoutManager');
}
return layout;
}
public performRepeat(force = false) {
if (this._variableDependency.hasDependencyInLoadingState()) {
return;
}
const variable = sceneGraph.lookupVariable(this.state.variableName, this.parent?.parent!);
if (!variable) {
console.error('RepeatedRowItemBehavior: Variable not found');
return;
}
if (!(variable instanceof MultiValueVariable)) {
console.error('RepeatedRowItemBehavior: Variable is not a MultiValueVariable');
return;
}
const rowToRepeat = this._getRow();
const layout = this._getLayout();
const { values, texts } = getMultiVariableValues(variable);
// Do nothing if values are the same
if (isEqual(this._prevRepeatValues, values) && !force) {
return;
}
this._prevRepeatValues = values;
this._clonedRows = [];
const rowContent = rowToRepeat.getLayout();
// when variable has no options (due to error or similar) it will not render any panels at all
// adding a placeholder in this case so that there is at least empty panel that can display error
const emptyVariablePlaceholderOption = {
values: [''],
texts: variable.hasAllValue() ? ['All'] : ['None'],
};
const variableValues = values.length ? values : emptyVariablePlaceholderOption.values;
const variableTexts = texts.length ? texts : emptyVariablePlaceholderOption.texts;
// Loop through variable values and create repeats
for (let rowIndex = 0; rowIndex < variableValues.length; rowIndex++) {
const isSourceRow = rowIndex === 0;
const rowClone = isSourceRow ? rowToRepeat : rowToRepeat.clone({ $behaviors: [] });
const rowCloneKey = getCloneKey(rowToRepeat.state.key!, rowIndex);
rowClone.setState({
key: rowCloneKey,
$variables: new SceneVariableSet({
variables: [
new LocalValueVariable({
name: this.state.variableName,
value: variableValues[rowIndex],
text: String(variableTexts[rowIndex]),
isMulti: variable.state.isMulti,
includeAll: variable.state.includeAll,
}),
],
}),
layout: rowContent.cloneLayout?.(rowCloneKey, isSourceRow),
});
this._clonedRows.push(rowClone);
}
updateLayout(layout, this._clonedRows, rowToRepeat.state.key!);
// Used from dashboard url sync
this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true);
}
public removeBehavior() {
const row = this._getRow();
const layout = this._getLayout();
const rows = getRowsFilterOutRepeatClones(layout, row.state.key!);
layout.setState({ rows });
// Remove behavior and the scoped local variable
row.setState({ $behaviors: row.state.$behaviors!.filter((b) => b !== this), $variables: undefined });
}
}
function updateLayout(layout: RowsLayoutManager, rows: RowItem[], rowKey: string) {
const allRows = getRowsFilterOutRepeatClones(layout, rowKey);
const index = allRows.findIndex((row) => row.state.key!.includes(rowKey));
if (index === -1) {
throw new Error('RowItemRepeaterBehavior: Row not found in layout');
}
layout.setState({ rows: [...allRows.slice(0, index), ...rows, ...allRows.slice(index + 1)] });
}
function getRowsFilterOutRepeatClones(layout: RowsLayoutManager, rowKey: string) {
return layout.state.rows.filter((rows) => !isClonedKeyOf(rows.state.key!, rowKey));
}
@@ -12,13 +12,16 @@ import {
} from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { isClonedKey } from '../../utils/clone';
import { DashboardScene } from '../DashboardScene';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior';
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { DashboardLayoutManager, LayoutRegistryItem } from '../types';
import { RowItem } from './RowItem';
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
interface RowsLayoutManagerState extends SceneObjectState {
rows: RowItem[];
@@ -58,6 +61,10 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
});
}
public getMaxPanelId(): number {
return Math.max(...this.state.rows.map((row) => row.getLayout().getMaxPanelId()));
}
public getNextPanelId(): number {
return 0;
}
@@ -78,7 +85,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
const panels: VizPanel[] = [];
for (const row of this.state.rows) {
const innerPanels = row.state.layout.getVizPanels();
const innerPanels = row.getLayout().getVizPanels();
panels.push(...innerPanels);
}
@@ -89,6 +96,23 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
return [];
}
public activateRepeaters() {
this.state.rows.forEach((row) => {
if (row.state.$behaviors) {
for (const behavior of row.state.$behaviors) {
if (behavior instanceof RowItemRepeaterBehavior && !row.isActive) {
row.activate();
break;
}
}
if (!row.getLayout().isActive) {
row.getLayout().activate();
}
}
});
}
public getDescriptor(): LayoutRegistryItem {
return RowsLayoutManager.getDescriptor();
}
@@ -111,11 +135,16 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
}
public static createFromLayout(layout: DashboardLayoutManager): RowsLayoutManager {
let rows: RowItem[];
if (layout instanceof DefaultGridLayoutManager) {
const config: Array<{
title?: string;
isCollapsed?: boolean;
isDraggable?: boolean;
isResizable?: boolean;
children: SceneGridItemLike[];
repeat?: string;
}> = [];
let children: SceneGridItemLike[] | undefined;
@@ -125,12 +154,19 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
}
if (child instanceof SceneGridRow) {
if (!child.state.key?.includes('-clone-')) {
if (!isClonedKey(child.state.key!)) {
const behaviour = child.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior);
config.push({
title: child.state.title,
isCollapsed: !!child.state.isCollapsed,
isDraggable: child.state.isDraggable ?? layout.state.grid.state.isDraggable,
isResizable: child.state.isResizable ?? layout.state.grid.state.isResizable,
children: child.state.children,
repeat: behaviour?.state.variableName,
});
// Since we encountered a row item, any subsequent panels should be added to a new row
children = undefined;
}
} else {
@@ -143,21 +179,24 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
}
});
const rows = config.map(
rows = config.map(
(rowConfig) =>
new RowItem({
title: rowConfig.title ?? 'Row title',
isCollapsed: !!rowConfig.isCollapsed,
layout: DefaultGridLayoutManager.fromGridItems(rowConfig.children),
layout: DefaultGridLayoutManager.fromGridItems(
rowConfig.children,
rowConfig.isDraggable,
rowConfig.isResizable
),
$behaviors: rowConfig.repeat ? [new RowItemRepeaterBehavior({ variableName: rowConfig.repeat })] : [],
})
);
return new RowsLayoutManager({ rows });
} else {
rows = [new RowItem({ layout: layout.clone(), title: 'Row title' })];
}
const row = new RowItem({ layout: layout.clone(), title: 'Row title' });
return new RowsLayoutManager({ rows: [row] });
return new RowsLayoutManager({ rows });
}
public static Component = ({ model }: SceneComponentProps<RowsLayoutManager>) => {
@@ -167,7 +206,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
return (
<div className={styles.wrapper}>
{rows.map((row) => (
<RowItem.Component model={row} key={row.state.key!} />
<row.Component model={row} key={row.state.key!} />
))}
</div>
);
@@ -10,50 +10,72 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan
export interface DashboardLayoutManager extends SceneObject {
/** Marks it as a DashboardLayoutManager */
isDashboardLayoutManager: true;
/**
* Notify the layout manager that the edit mode has changed
* @param isEditing
*/
editModeChanged(isEditing: boolean): void;
/**
* Remove an element / panel
* @param element
*/
removePanel(panel: VizPanel): void;
/**
* Creates a copy of an existing element and adds it to the layout
* @param element
*/
duplicatePanel(panel: VizPanel): void;
/**
* Adds a new panel to the layout
*/
addPanel(panel: VizPanel): void;
/**
* Add row
*/
addNewRow(): void;
/**
* getVizPanels
*/
getVizPanels(): VizPanel[];
/**
* Turn into a save model
* @param saveModel
*/
toSaveModel?(): any;
/**
* For dynamic panels that need to be viewed in isolation (SoloRoute)
*/
activateRepeaters?(): void;
/**
* Get's the layout descriptor (which has the name and id)
* Gets the layout descriptor (which has the name and id)
*/
getDescriptor(): LayoutRegistryItem;
/**
* Renders options and layout actions
*/
getOptions?(): OptionsPaneItemDescriptor[];
/**
* Create a clone of the layout manager given an ancestor key
* @param ancestorKey
* @param isSource
*/
cloneLayout?(ancestorKey: string, isSource: boolean): DashboardLayoutManager;
/**
* Returns the highest panel id in the layout
*/
getMaxPanelId(): number;
}
export function isDashboardLayoutManager(obj: SceneObject): obj is DashboardLayoutManager {
@@ -81,11 +81,11 @@ import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { PanelNotices } from '../scene/PanelNotices';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { AngularDeprecation } from '../scene/angular/AngularDeprecation';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowActions } from '../scene/row-actions/RowActions';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { RowActions } from '../scene/layout-default/row-actions/RowActions';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState';
import { getDashboardSceneFor, getIntervalsFromQueryString, getVizPanelKeyForPanelId } from '../utils/utils';
@@ -30,9 +30,9 @@ import { DashboardDataDTO } from 'app/types';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { NEW_LINK } from '../settings/links/utils';
import { getQueryRunnerFor } from '../utils/utils';
@@ -41,11 +41,11 @@ import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { PanelNotices } from '../scene/PanelNotices';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { AngularDeprecation } from '../scene/angular/AngularDeprecation';
import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowActions } from '../scene/row-actions/RowActions';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { RowActions } from '../scene/layout-default/row-actions/RowActions';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { createPanelDataProvider } from '../utils/createPanelDataProvider';
import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState';
@@ -25,9 +25,9 @@ import { DashboardDataDTO } from 'app/types';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { NEW_LINK } from '../settings/links/utils';
import { activateFullSceneTree, buildPanelRepeaterScene } from '../utils/test-utils';
import { getVizPanelKeyForPanelId } from '../utils/utils';
@@ -33,9 +33,10 @@ import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardScene } from '../scene/DashboardScene';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { isClonedKey } from '../utils/clone';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import {
calculateGridItemDimensions,
@@ -72,7 +73,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
if (child instanceof SceneGridRow) {
// Skip repeat clones or when generating a snapshot
if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) {
if (isClonedKey(child.state.key!) && !isSnapshot) {
continue;
}
gridRowToSaveModel(child, panels, isSnapshot);
@@ -30,9 +30,9 @@ import { DashboardControls } from '../scene/DashboardControls';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2';
@@ -47,9 +47,10 @@ import {
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { isClonedKey } from '../utils/clone';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import {
getLibraryPanelBehavior,
@@ -174,7 +175,7 @@ function getGridLayoutItems(
elements.push(gridItemToGridLayoutItemKind(child, isSnapshot));
}
} else if (child instanceof SceneGridRow) {
if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) {
if (isClonedKey(child.state.key!) && !isSnapshot) {
// Skip repeat rows
continue;
}
@@ -46,7 +46,7 @@ describe('useSoloPanel', () => {
it('should return the cloned panel when panel is found', () => {
const { dashboard } = setup();
const { result } = renderHook(() => useSoloPanel(dashboard, 'panel-1_clone'));
const { result } = renderHook(() => useSoloPanel(dashboard, 'panel-1-clone-1'));
const panel = findVizPanelByKey(dashboard, 'panel-1');
expect(result.current[0]).not.toBe(panel);
@@ -4,7 +4,8 @@ import { VizPanel, UrlSyncManager } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardRepeatsProcessedEvent } from '../scene/types';
import { findVizPanelByKey, isPanelClone } from '../utils/utils';
import { containsCloneKey } from '../utils/clone';
import { findVizPanelByKey } from '../utils/utils';
export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPanel | undefined, string | undefined] {
const [panel, setPanel] = useState<VizPanel>();
@@ -26,7 +27,7 @@ export function useSoloPanel(dashboard: DashboardScene, panelId: string): [VizPa
if (panel) {
activateParents(panel);
setPanel(panel);
} else if (isPanelClone(panelId)) {
} else if (containsCloneKey(panelId)) {
findRepeatClone(dashboard, panelId).then((panel) => {
if (panel) {
setPanel(panel);
@@ -0,0 +1,118 @@
import {
getCloneKey,
getOriginalKey,
isInCloneChain,
isClonedKey,
joinCloneKeys,
containsCloneKey,
getLastKeyFromClone,
isClonedKeyOf,
} from './clone';
describe('clone', () => {
describe('getCloneKey', () => {
it('should return the clone key', () => {
expect(getCloneKey('panel', 1)).toBe('panel-clone-1');
expect(getCloneKey('panel-clone-2', 1)).toBe('panel-clone-1');
});
it('should not alter ancestors', () => {
expect(getCloneKey('row-clone-1/panel', 2)).toBe('row-clone-1/panel-clone-2');
expect(getCloneKey('tab-clone-0/row-clone-1/panel', 2)).toBe('tab-clone-0/row-clone-1/panel-clone-2');
expect(getCloneKey('row-clone-1/panel-clone-3', 2)).toBe('row-clone-1/panel-clone-2');
expect(getCloneKey('tab-clone-0/row-clone-1/panel-clone-3', 2)).toBe('tab-clone-0/row-clone-1/panel-clone-2');
});
});
describe('getOriginalKey', () => {
it('should return the original key', () => {
expect(getOriginalKey('panel')).toBe('panel');
expect(getOriginalKey('panel-clone-1')).toBe('panel');
expect(getOriginalKey('row-clone-1/panel-clone-2')).toBe('panel');
expect(getOriginalKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe('panel');
});
});
describe('isClonedKey', () => {
it('should return true for cloned keys', () => {
expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe(true);
expect(isClonedKey('row-clone-0/panel-clone-1')).toBe(true);
expect(isClonedKey('panel-clone-1')).toBe(true);
});
it('should return false for non-cloned keys', () => {
expect(isClonedKey('panel-clone-0')).toBe(false);
expect(isClonedKey('tab-clone-1/row-clone-2/panel-clone-0')).toBe(false);
expect(isClonedKey('row-clone-1/panel-clone-0')).toBe(false);
expect(isClonedKey('panel')).toBe(false);
expect(isClonedKey('tab-clone-1/row-clone-2/panel')).toBe(false);
expect(isClonedKey('row-clone-1/panel')).toBe(false);
});
});
describe('isClonedKeyOf', () => {
it('should return true for cloned keys', () => {
expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel-clone-2')).toBe(true);
expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel')).toBe(true);
expect(isClonedKeyOf('panel-clone-2', 'panel-clone-2')).toBe(true);
expect(isClonedKeyOf('panel-clone-2', 'panel')).toBe(true);
});
it('should return false for non-cloned keys', () => {
expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel2-clone-2')).toBe(false);
expect(isClonedKeyOf('tab-clone-0/row-clone-1/panel-clone-2', 'panel2')).toBe(false);
expect(isClonedKeyOf('panel-clone-2', 'panel2-clone-2')).toBe(false);
expect(isClonedKeyOf('panel-clone-2', 'panel2')).toBe(false);
});
});
describe('isInCloneChain', () => {
it('should return true for keys with cloned ancestors', () => {
expect(isInCloneChain('tab-clone-1/row-clone-0/panel-clone-0')).toBe(true);
expect(isInCloneChain('row-clone-0/row-clone-1/panel-clone-0')).toBe(true);
expect(isInCloneChain('row-clone-0/row-clone-0/panel-clone-1')).toBe(true);
expect(isInCloneChain('panel-clone-1')).toBe(true);
});
it('should return false for keys without cloned ancestors', () => {
expect(isInCloneChain('panel-clone-0')).toBe(false);
expect(isInCloneChain('row-clone-0/panel-clone-0')).toBe(false);
expect(isInCloneChain('tab-clone-0/row-clone-0/panel-clone-0')).toBe(false);
expect(isInCloneChain('panel')).toBe(false);
expect(isInCloneChain('tab-clone-0/row-clone-0/panel')).toBe(false);
expect(isInCloneChain('tab-clone-0/row/panel')).toBe(false);
expect(isInCloneChain('tab-clone-0/row/panel-0')).toBe(false);
expect(isInCloneChain('tab/row-clone-0/panel-0')).toBe(false);
expect(isInCloneChain('row-clone-0/panel')).toBe(false);
});
});
describe('getLastKeyFromClone', () => {
it('should return the last key', () => {
expect(getLastKeyFromClone('tab-clone-1/row-clone-2/panel-clone-3')).toBe('panel-clone-3');
expect(getLastKeyFromClone('row-clone-1/panel-clone-2')).toBe('panel-clone-2');
expect(getLastKeyFromClone('row-clone-1/panel')).toBe('panel');
expect(getLastKeyFromClone('panel')).toBe('panel');
});
});
describe('joinCloneKeys', () => {
it('should join keys with a separator', () => {
expect(joinCloneKeys('row', 'panel-clone-1')).toBe('row/panel-clone-1');
});
});
describe('containsCloneKey', () => {
it('should return true for keys with clone key', () => {
expect(containsCloneKey('row-clone-0/panel-clone-1')).toBe(true);
expect(containsCloneKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe(true);
expect(containsCloneKey('panel-clone-1')).toBe(true);
});
it('should return false for keys without clone key', () => {
expect(containsCloneKey('panel')).toBe(false);
expect(containsCloneKey('tab-0/row-1/panel-2')).toBe(false);
expect(containsCloneKey('row-1/panel-2')).toBe(false);
});
});
});
@@ -0,0 +1,73 @@
const CLONE_KEY = '-clone-';
const CLONE_SEPARATOR = '/';
const CLONED_KEY_REGEX = new RegExp(`${CLONE_KEY}[1-9]+$`);
const ORIGINAL_REGEX = new RegExp(`${CLONE_KEY}\\d+$`);
/**
* Create or alter the last key for a key
* @param key
* @param index
*/
export function getCloneKey(key: string, index: number): string {
const parts = key.split(CLONE_SEPARATOR).slice(0, -1);
const lastKey = getOriginalKey(getLastKeyFromClone(key));
return [...parts, `${lastKey}${CLONE_KEY}${index}`].join(CLONE_SEPARATOR);
}
/**
* Get the original key from a clone key
* @param key
*/
export function getOriginalKey(key: string): string {
return getLastKeyFromClone(key).replace(ORIGINAL_REGEX, '');
}
/**
* Checks if the last key is a clone key
* @param key
*/
export function isClonedKey(key: string): boolean {
return CLONED_KEY_REGEX.test(getLastKeyFromClone(key));
}
/**
* Checks if key1 is a clone of key2
* @param key1
* @param key2
*/
export function isClonedKeyOf(key1: string, key2: string): boolean {
return isClonedKey(key1) && getOriginalKey(key1) === getOriginalKey(key2);
}
/**
* Checks if the key or any of its ancestors are cloned
* @param key
*/
export function isInCloneChain(key: string): boolean {
return key.split(CLONE_SEPARATOR).some(isClonedKey);
}
/**
* Get the last key from a clone key
* @param key
*/
export function getLastKeyFromClone(key: string): string {
return key.split(CLONE_SEPARATOR).pop() ?? '';
}
/**
* Join clone keys
* @param keys
*/
export function joinCloneKeys(...keys: string[]): string {
return keys.filter(Boolean).join(CLONE_SEPARATOR);
}
/**
* Checks if a key contains the '-clone-' string
* @param key
*/
export function containsCloneKey(key: string): boolean {
return key.includes(CLONE_KEY);
}
@@ -17,9 +17,9 @@ import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/co
import { DashboardDTO } from 'app/types';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
export function setupLoadDashboardMock(rsp: DeepPartial<DashboardDTO>, spy?: jest.Mock) {
const loadDashboardMock = (spy || jest.fn()).mockResolvedValue(rsp);
@@ -21,6 +21,8 @@ import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types';
import { getLastKeyFromClone, getOriginalKey } from './clone';
export const NEW_PANEL_HEIGHT = 8;
export const NEW_PANEL_WIDTH = 12;
@@ -29,7 +31,7 @@ export function getVizPanelKeyForPanelId(panelId: number) {
}
export function getPanelIdForVizPanel(panel: SceneObject): number {
return parseInt(panel.state.key!.replace('panel-', ''), 10);
return parseInt(getOriginalKey(panel.state.key!).replace('panel-', ''), 10);
}
/**
@@ -62,7 +64,7 @@ function findVizPanelInternal(scene: SceneObject, key: string | undefined): VizP
const panel = sceneGraph.findObject(scene, (obj) => {
const objKey = obj.state.key!;
if (objKey === key) {
if (objKey === key || getLastKeyFromClone(objKey) === getLastKeyFromClone(key) || getOriginalKey(objKey) === key) {
return true;
}
@@ -212,30 +214,6 @@ export function getClosestVizPanel(sceneObject: SceneObject): VizPanel | null {
return null;
}
export function isPanelClone(key: string) {
return key.includes('clone');
}
/**
* Recursivly check the scene graph up until it finds a read only clone.
* If the key contains clone-0 it is the reference object and can be edited
*/
export function isReadOnlyClone(sceneObject: SceneObject): boolean {
const key = sceneObject.state.key!;
// Regular expression to match 'clone-' followed by a number, but not 'clone-0' as the is the reference object
const pattern = /clone-(?!0)/;
if (pattern.test(key)) {
return true;
}
if (sceneObject.parent) {
return isReadOnlyClone(sceneObject.parent);
}
return false;
}
export function getDefaultVizPanel(): VizPanel {
return new VizPanel({
title: 'Panel Title',
@@ -351,3 +329,7 @@ export function getLayoutManagerFor(sceneObject: SceneObject): DashboardLayoutMa
throw new Error('Could not find layout manager for scene object');
}
export function getGridItemKeyForPanelId(panelId: number): string {
return `grid-item-${panelId}`;
}
+39 -1
View File
@@ -709,7 +709,7 @@
"title": "Clear value"
},
"custom-value": {
"label": "Custom value: "
"description": "Use custom value"
},
"options": {
"no-found": "No options found."
@@ -882,6 +882,36 @@
"redirect-link": "List in Grafana Alerting",
"subtitle": "Alert rules related to this dashboard"
},
"default-layout": {
"row-actions": {
"delete": "Delete row",
"modal": {
"alt-action": "Delete row only",
"text": "Are you sure you want to remove this row and all its panels?",
"title": "Delete row"
},
"repeat": {
"warning": {
"learn-more": "Learn more",
"text": "Panels in this row use the {{SHARED_DASHBOARD_QUERY}} data source. These panels will reference the panel in the original row, not the ones in the repeated rows."
}
}
},
"row-options": {
"button": {
"label": "Row options"
},
"form": {
"cancel": "Cancel",
"repeat-for": "Repeat for",
"title": "Title",
"update": "Update"
},
"modal": {
"title": "Row options"
}
}
},
"empty": {
"add-library-panel-body": "Add visualizations that are shared with other dashboards.",
"add-library-panel-button": "Add library panel",
@@ -954,6 +984,14 @@
"no-rules": "There are no alert rules linked to this panel."
}
},
"rows-layout": {
"row": {
"repeat": {
"learn-more": "Learn more",
"warning": "Panels in this row use the {{SHARED_DASHBOARD_QUERY}} data source. These panels will reference the panel in the original row, not the ones in the repeated rows."
}
}
},
"toolbar": {
"add": "Add",
"alert-rules": "Alert rules",
+39 -1
View File
@@ -709,7 +709,7 @@
"title": "Cľęäř väľūę"
},
"custom-value": {
"label": "Cūşŧőm väľūę: "
"description": "Ůşę čūşŧőm väľūę"
},
"options": {
"no-found": "Ńő őpŧįőʼnş ƒőūʼnđ."
@@ -882,6 +882,36 @@
"redirect-link": "Ŀįşŧ įʼn Ğřäƒäʼnä Åľęřŧįʼnģ",
"subtitle": "Åľęřŧ řūľęş řęľäŧęđ ŧő ŧĥįş đäşĥþőäřđ"
},
"default-layout": {
"row-actions": {
"delete": "Đęľęŧę řőŵ",
"modal": {
"alt-action": "Đęľęŧę řőŵ őʼnľy",
"text": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő řęmővę ŧĥįş řőŵ äʼnđ äľľ įŧş päʼnęľş?",
"title": "Đęľęŧę řőŵ"
},
"repeat": {
"warning": {
"learn-more": "Ŀęäřʼn mőřę",
"text": "Päʼnęľş įʼn ŧĥįş řőŵ ūşę ŧĥę {{SHARED_DASHBOARD_QUERY}} đäŧä şőūřčę. Ŧĥęşę päʼnęľş ŵįľľ řęƒęřęʼnčę ŧĥę päʼnęľ įʼn ŧĥę őřįģįʼnäľ řőŵ, ʼnőŧ ŧĥę őʼnęş įʼn ŧĥę řępęäŧęđ řőŵş."
}
}
},
"row-options": {
"button": {
"label": "Ŗőŵ őpŧįőʼnş"
},
"form": {
"cancel": "Cäʼnčęľ",
"repeat-for": "Ŗępęäŧ ƒőř",
"title": "Ŧįŧľę",
"update": "Ůpđäŧę"
},
"modal": {
"title": "Ŗőŵ őpŧįőʼnş"
}
}
},
"empty": {
"add-library-panel-body": "Åđđ vįşūäľįžäŧįőʼnş ŧĥäŧ äřę şĥäřęđ ŵįŧĥ őŧĥęř đäşĥþőäřđş.",
"add-library-panel-button": "Åđđ ľįþřäřy päʼnęľ",
@@ -954,6 +984,14 @@
"no-rules": "Ŧĥęřę äřę ʼnő äľęřŧ řūľęş ľįʼnĸęđ ŧő ŧĥįş päʼnęľ."
}
},
"rows-layout": {
"row": {
"repeat": {
"learn-more": "Ŀęäřʼn mőřę",
"warning": "Päʼnęľş įʼn ŧĥįş řőŵ ūşę ŧĥę {{SHARED_DASHBOARD_QUERY}} đäŧä şőūřčę. Ŧĥęşę päʼnęľş ŵįľľ řęƒęřęʼnčę ŧĥę päʼnęľ įʼn ŧĥę őřįģįʼnäľ řőŵ, ʼnőŧ ŧĥę őʼnęş įʼn ŧĥę řępęäŧęđ řőŵş."
}
}
},
"toolbar": {
"add": "Åđđ",
"alert-rules": "Åľęřŧ řūľęş",
+25 -7
View File
@@ -18,8 +18,8 @@ function processOpenAPISpec(spec: OpenAPIV3.Document) {
// Process 'paths' property
const newPaths: Record<string, unknown> = {};
for (const [path, pathItem] of Object.entries<OpenAPIV3.PathItemObject>(newSpec.paths)) {
// Remove 'watch' paths as they're deprecated / remove empty path items
if (path.includes('/watch/') || !pathItem) {
// Remove empty path items
if (!pathItem) {
continue;
}
// Remove the specified part from the path key
@@ -27,12 +27,13 @@ function processOpenAPISpec(spec: OpenAPIV3.Document) {
// Process each method in the path (e.g., get, post)
const newPathItem: Record<string, unknown> = {};
for (const method of Object.keys(pathItem)) {
// Filter out the 'namespace' param
if (method === 'parameters' && Array.isArray(pathItem.parameters)) {
pathItem.parameters = pathItem.parameters?.filter((param) => 'name' in param && param.name !== 'namespace');
}
// Filter out namespace parameter at path level
if (Array.isArray(pathItem.parameters)) {
pathItem.parameters = filterNamespaceParameters(pathItem.parameters);
}
for (const method of Object.keys(pathItem)) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const operation = pathItem[method as keyof OpenAPIV3.PathItemObject];
@@ -45,6 +46,16 @@ function processOpenAPISpec(spec: OpenAPIV3.Document) {
continue;
}
// Filter out namespace parameter at operation level
if (
operation &&
typeof operation === 'object' &&
'parameters' in operation &&
Array.isArray(operation.parameters)
) {
operation.parameters = filterNamespaceParameters(operation.parameters);
}
updateRefs(operation);
newPathItem[method] = operation;
@@ -69,6 +80,13 @@ function processOpenAPISpec(spec: OpenAPIV3.Document) {
return newSpec;
}
/**
* Filter out namespace parameters from an array of parameters
*/
function filterNamespaceParameters(parameters: Array<OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject>) {
return parameters.filter((param) => 'name' in param && param.name !== 'namespace');
}
/**
* Recursively update all $ref fields to remove k8s metadata from names
*/