mirror of https://github.com/grafana/grafana.git
Git Sync UI: Delete Provisioned Dashboard Flow (#106593)
Actionlint / Lint GitHub Actions files (push) Waiting to run
Details
Backend Code Checks / Validate Backend Configs (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Backend Unit Tests / All backend unit tests complete (push) Blocked by required conditions
Details
CodeQL checks / Analyze (actions) (push) Waiting to run
Details
CodeQL checks / Analyze (go) (push) Waiting to run
Details
CodeQL checks / Analyze (javascript) (push) Waiting to run
Details
Lint Frontend / Verify i18n (push) Waiting to run
Details
Lint Frontend / Lint (push) Waiting to run
Details
Lint Frontend / Typecheck (push) Waiting to run
Details
Lint Frontend / Betterer (push) Waiting to run
Details
golangci-lint / lint-go (push) Waiting to run
Details
End-to-end tests / Build & Package Grafana (push) Waiting to run
Details
End-to-end tests / Build E2E test runner (push) Waiting to run
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/dashboards-suite, dashboards-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/panels-suite, panels-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/smoke-tests-suite, smoke-tests-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/various-suite, various-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/dashboards-suite, dashboards-suite) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/panels-suite, panels-suite) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/smoke-tests-suite, smoke-tests-suite) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/various-suite, various-suite) (push) Blocked by required conditions
Details
End-to-end tests / All E2E tests complete (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (1) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (2) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (3) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (4) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (5) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (6) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (7) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (8) (push) Waiting to run
Details
Frontend tests / All frontend unit tests complete (push) Blocked by required conditions
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Integration Tests / All backend integration tests complete (push) Blocked by required conditions
Details
Reject GitHub secrets / reject-gh-secrets (push) Waiting to run
Details
Build Release Packages / setup (push) Waiting to run
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:darwin/amd64, darwin-amd64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:darwin/arm64, darwin-arm64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/amd64,deb:grafana:linux/amd64,rpm:grafana:linux/amd64,docker:grafana:linux/amd64,docker:grafana:linux/amd64:ubuntu,npm:grafana,storybook, linux-amd64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm/v6,deb:grafana:linux/arm/v6, linux-armv6) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm/v7,deb:grafana:linux/arm/v7,docker:grafana:linux/arm/v7,docker:grafana:linux/arm/v7:ubuntu, linux-armv7) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm64,deb:grafana:linux/arm64,rpm:grafana:linux/arm64,docker:grafana:linux/arm64,docker:grafana:linux/arm64:ubuntu, linux-arm64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/s390x,deb:grafana:linux/s390x,rpm:grafana:linux/s390x,docker:grafana:linux/s390x,docker:grafana:linux/s390x:ubuntu, linux-s390x) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:windows/amd64,zip:grafana:windows/amd64,msi:grafana:windows/amd64, windows-amd64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:windows/arm64,zip:grafana:windows/arm64, windows-arm64) (push) Blocked by required conditions
Details
Run dashboard schema v2 e2e / dashboard-schema-v2-e2e (push) Waiting to run
Details
Shellcheck / Shellcheck scripts (push) Waiting to run
Details
Verify Storybook / Verify Storybook (push) Waiting to run
Details
Swagger generated code / Verify committed API specs match (push) Waiting to run
Details
Dispatch sync to mirror / dispatch-job (push) Waiting to run
Details
Trivy Scan / trivy-scan (push) Waiting to run
Details
Crowdin Upload Action / upload-sources-to-crowdin (push) Has been cancelled
Details
publish-kinds-next / main (push) Has been cancelled
Details
trigger-dashboard-search-e2e / trigger-search-e2e (push) Has been cancelled
Details
Actionlint / Lint GitHub Actions files (push) Waiting to run
Details
Backend Code Checks / Validate Backend Configs (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Backend Unit Tests / All backend unit tests complete (push) Blocked by required conditions
Details
CodeQL checks / Analyze (actions) (push) Waiting to run
Details
CodeQL checks / Analyze (go) (push) Waiting to run
Details
CodeQL checks / Analyze (javascript) (push) Waiting to run
Details
Lint Frontend / Verify i18n (push) Waiting to run
Details
Lint Frontend / Lint (push) Waiting to run
Details
Lint Frontend / Typecheck (push) Waiting to run
Details
Lint Frontend / Betterer (push) Waiting to run
Details
golangci-lint / lint-go (push) Waiting to run
Details
End-to-end tests / Build & Package Grafana (push) Waiting to run
Details
End-to-end tests / Build E2E test runner (push) Waiting to run
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/dashboards-suite, dashboards-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/panels-suite, panels-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/smoke-tests-suite, smoke-tests-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (--flags="--env dashboardScene=false", e2e/old-arch/various-suite, various-suite (old arch)) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/dashboards-suite, dashboards-suite) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/panels-suite, panels-suite) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/smoke-tests-suite, smoke-tests-suite) (push) Blocked by required conditions
Details
End-to-end tests / ${{ matrix.suite }} (e2e/various-suite, various-suite) (push) Blocked by required conditions
Details
End-to-end tests / All E2E tests complete (push) Blocked by required conditions
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (1) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (2) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (3) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (4) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (5) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (6) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (7) (push) Waiting to run
Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (8) (push) Waiting to run
Details
Frontend tests / All frontend unit tests complete (push) Blocked by required conditions
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Integration Tests / Sqlite (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Integration Tests / MySQL (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (1/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (2/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (3/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (4/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (5/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (6/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (7/8) (push) Waiting to run
Details
Integration Tests / Postgres (${{ matrix.shard }}) (8/8) (push) Waiting to run
Details
Integration Tests / All backend integration tests complete (push) Blocked by required conditions
Details
Reject GitHub secrets / reject-gh-secrets (push) Waiting to run
Details
Build Release Packages / setup (push) Waiting to run
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:darwin/amd64, darwin-amd64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:darwin/arm64, darwin-arm64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/amd64,deb:grafana:linux/amd64,rpm:grafana:linux/amd64,docker:grafana:linux/amd64,docker:grafana:linux/amd64:ubuntu,npm:grafana,storybook, linux-amd64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm/v6,deb:grafana:linux/arm/v6, linux-armv6) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm/v7,deb:grafana:linux/arm/v7,docker:grafana:linux/arm/v7,docker:grafana:linux/arm/v7:ubuntu, linux-armv7) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/arm64,deb:grafana:linux/arm64,rpm:grafana:linux/arm64,docker:grafana:linux/arm64,docker:grafana:linux/arm64:ubuntu, linux-arm64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:linux/s390x,deb:grafana:linux/s390x,rpm:grafana:linux/s390x,docker:grafana:linux/s390x,docker:grafana:linux/s390x:ubuntu, linux-s390x) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:windows/amd64,zip:grafana:windows/amd64,msi:grafana:windows/amd64, windows-amd64) (push) Blocked by required conditions
Details
Build Release Packages / ${{ needs.setup.outputs.version }} / ${{ matrix.name }} (targz:grafana:windows/arm64,zip:grafana:windows/arm64, windows-arm64) (push) Blocked by required conditions
Details
Run dashboard schema v2 e2e / dashboard-schema-v2-e2e (push) Waiting to run
Details
Shellcheck / Shellcheck scripts (push) Waiting to run
Details
Verify Storybook / Verify Storybook (push) Waiting to run
Details
Swagger generated code / Verify committed API specs match (push) Waiting to run
Details
Dispatch sync to mirror / dispatch-job (push) Waiting to run
Details
Trivy Scan / trivy-scan (push) Waiting to run
Details
Crowdin Upload Action / upload-sources-to-crowdin (push) Has been cancelled
Details
publish-kinds-next / main (push) Has been cancelled
Details
trigger-dashboard-search-e2e / trigger-search-e2e (push) Has been cancelled
Details
* DeleteProvisionedDashboardDrawer: delete provisioned dashboard flow set up with drawer * clean up * add tests * more test and clean up * revert endpoint change * adjust tests * remove unuse codes * fix type, fix test, add read only message * small changes * fix test, i18n fix * comments * Fix bug for file deletion using a branch * PR comments update * Use the provided ref for parser so that URLs work * call useDeleteRepositoryFilesWithPathMutation in delete drawer component directly * remove console log * Update public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> * PR comments * use string for fields * extract handle request logic from save form and delete form and put into one hook * Add test for useProvisionedRequestHandler * Update public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> * Update public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> * Update public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> * Update public/app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields.test.tsx Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> * Update public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.test.tsx Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> * Update public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.tsx Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> * use drawer close in both save form and delete form * Add back panelEditor onDiscard * add panelEditor onDiscard to delete flow * Update public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.tsx Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> --------- Co-authored-by: Roberto Jimenez Sanchez <roberto.jimenez@grafana.com> Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
This commit is contained in:
parent
8598fa213a
commit
fe4abf2221
|
@ -81,11 +81,17 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
|
|||
return nil, fmt.Errorf("folder delete not supported")
|
||||
}
|
||||
|
||||
file, err := r.repo.Read(ctx, opts.Path, opts.Ref)
|
||||
// Read the file from the default branch as it won't exist in the possibly new branch
|
||||
file, err := r.repo.Read(ctx, opts.Path, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
|
||||
// HACK: manual set to the provided branch so that the parser can possible read the file
|
||||
if opts.Ref != "" {
|
||||
file.Ref = opts.Ref
|
||||
}
|
||||
|
||||
// TODO: document in API specification
|
||||
// We can only delete parsable things
|
||||
parsed, err := r.parser.Parse(ctx, file)
|
||||
|
|
|
@ -0,0 +1,244 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ReactNode } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { ProvisionedDashboardFormData } from '../../saving/shared';
|
||||
|
||||
import { DashboardEditFormSharedFields } from './DashboardEditFormSharedFields';
|
||||
|
||||
// Mock the i18n hook since it's used in the component
|
||||
jest.mock('@grafana/i18n', () => ({
|
||||
t: (_: string, defaultValue: string) => defaultValue,
|
||||
Trans: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
interface SetupOptions {
|
||||
formDefaultValues?: Partial<ProvisionedDashboardFormData>;
|
||||
workflowOptions?: Array<{ label: string; value: string }>;
|
||||
isNew?: boolean;
|
||||
readOnly?: boolean;
|
||||
workflow?: 'write' | 'branch';
|
||||
isGitHub?: boolean;
|
||||
}
|
||||
|
||||
function setup(options: SetupOptions = {}) {
|
||||
const {
|
||||
formDefaultValues = {},
|
||||
workflowOptions = [
|
||||
{ label: 'Write directly', value: 'write' },
|
||||
{ label: 'Create branch', value: 'branch' },
|
||||
],
|
||||
isNew,
|
||||
readOnly,
|
||||
workflow,
|
||||
isGitHub,
|
||||
} = options;
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
const defaultFormValues: Partial<ProvisionedDashboardFormData> = {
|
||||
path: '',
|
||||
comment: '',
|
||||
ref: '',
|
||||
workflow: 'write',
|
||||
...formDefaultValues,
|
||||
};
|
||||
|
||||
const FormWrapper = ({ children }: { children: ReactNode }) => {
|
||||
const methods = useForm<ProvisionedDashboardFormData>({
|
||||
defaultValues: defaultFormValues,
|
||||
mode: 'onChange',
|
||||
});
|
||||
return <FormProvider {...methods}>{children}</FormProvider>;
|
||||
};
|
||||
|
||||
const componentProps = {
|
||||
workflowOptions,
|
||||
isNew,
|
||||
readOnly,
|
||||
workflow,
|
||||
isGitHub,
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
...render(
|
||||
<FormWrapper>
|
||||
<DashboardEditFormSharedFields {...componentProps} />
|
||||
</FormWrapper>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DashboardEditFormSharedFields', () => {
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render path and comment fields by default', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByRole('textbox', { name: /Path/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'Comment' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render workflow fields when isGitHub is false', () => {
|
||||
setup({ isGitHub: false });
|
||||
|
||||
expect(screen.queryByText('Workflow')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render workflow fields when isGitHub is true', () => {
|
||||
setup({ isGitHub: true });
|
||||
|
||||
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Write directly' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Create branch' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReadOnly State', () => {
|
||||
it('should make path field readonly when readOnly is true', () => {
|
||||
setup({ readOnly: true });
|
||||
|
||||
const pathInput = screen.getByRole('textbox', { name: /path/i });
|
||||
expect(pathInput).toHaveAttribute('readonly');
|
||||
});
|
||||
|
||||
it('should disable comment field when readOnly is true', () => {
|
||||
setup({ readOnly: true });
|
||||
|
||||
const commentTextarea = screen.getByRole('textbox', { name: /comment/i });
|
||||
expect(commentTextarea).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should not render workflow fields when readOnly is true and isGitHub is true', () => {
|
||||
setup({ readOnly: true, isGitHub: true });
|
||||
|
||||
expect(screen.queryByText('Workflow')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workflow Fields', () => {
|
||||
it('should not render branch field when workflow is write', () => {
|
||||
setup({ formDefaultValues: { workflow: 'write' }, isGitHub: true, workflow: 'write' });
|
||||
|
||||
expect(screen.getByText('Workflow')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('textbox', { name: /branch/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render branch field when workflow is branch', () => {
|
||||
setup({ formDefaultValues: { workflow: 'branch' }, isGitHub: true, workflow: 'branch' });
|
||||
|
||||
expect(screen.getByText('Workflow')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: /branch/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Branch name in GitHub')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should allow typing in path field', async () => {
|
||||
const { user } = setup({ isNew: true });
|
||||
|
||||
const pathInput = screen.getByRole('textbox', { name: /path/i });
|
||||
await user.type(pathInput, 'dashboards/test.json');
|
||||
|
||||
expect(pathInput).toHaveValue('dashboards/test.json');
|
||||
});
|
||||
|
||||
it('should allow typing in comment field', async () => {
|
||||
const { user } = setup();
|
||||
|
||||
const commentTextarea = screen.getByRole('textbox', { name: /comment/i });
|
||||
await user.type(commentTextarea, 'Test comment');
|
||||
|
||||
expect(commentTextarea).toHaveValue('Test comment');
|
||||
});
|
||||
|
||||
it('should allow selecting workflow options', async () => {
|
||||
const { user } = setup({ isGitHub: true });
|
||||
|
||||
const branchOption = screen.getByRole('radio', { name: 'Create branch' });
|
||||
await user.click(branchOption);
|
||||
|
||||
expect(branchOption).toBeChecked();
|
||||
});
|
||||
|
||||
it('should allow typing in branch field when workflow is branch', async () => {
|
||||
const { user } = setup({ formDefaultValues: { workflow: 'branch' }, isGitHub: true, workflow: 'branch' });
|
||||
|
||||
const branchInput = screen.getByRole('textbox', { name: /branch/i });
|
||||
await user.type(branchInput, 'feature-branch');
|
||||
|
||||
expect(branchInput).toHaveValue('feature-branch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Integration', () => {
|
||||
it('should update form state when fields are changed', async () => {
|
||||
let formValues: Partial<ProvisionedDashboardFormData> | undefined;
|
||||
|
||||
const TestComponent = () => {
|
||||
const methods = useForm<ProvisionedDashboardFormData>({
|
||||
defaultValues: { path: '', comment: '', ref: '', workflow: 'write' },
|
||||
});
|
||||
|
||||
// Capture form values for assertion
|
||||
formValues = methods.watch();
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<DashboardEditFormSharedFields
|
||||
workflowOptions={[
|
||||
{ label: 'Write directly', value: 'write' },
|
||||
{ label: 'Create branch', value: 'branch' },
|
||||
]}
|
||||
isNew={true}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<TestComponent />);
|
||||
|
||||
const pathInput = screen.getByRole('textbox', { name: /path/i });
|
||||
const commentTextarea = screen.getByRole('textbox', { name: /comment/i });
|
||||
|
||||
await user.type(pathInput, 'test.json');
|
||||
await user.type(commentTextarea, 'Test comment');
|
||||
|
||||
expect(formValues?.path).toBe('test.json');
|
||||
expect(formValues?.comment).toBe('Test comment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
it('should show validation error for invalid branch name', async () => {
|
||||
const { user } = setup({ formDefaultValues: { workflow: 'branch' }, isGitHub: true, workflow: 'branch' });
|
||||
|
||||
const branchInput = screen.getByRole('textbox', { name: /branch/i });
|
||||
await user.type(branchInput, 'invalid//branch'); // Invalid branch name with consecutive slashes
|
||||
|
||||
// Trigger validation by blurring the field
|
||||
await user.tab();
|
||||
|
||||
// Check if validation error appears
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty workflowOptions', () => {
|
||||
setup({ workflowOptions: [], isGitHub: true });
|
||||
|
||||
expect(screen.getByText('Workflow')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle undefined props', () => {
|
||||
setup({ readOnly: undefined, isGitHub: undefined });
|
||||
|
||||
expect(screen.getByRole('textbox', { name: /Path/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'Comment' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,88 @@
|
|||
import { memo } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Field, TextArea, Input, RadioButtonGroup } from '@grafana/ui';
|
||||
import { BranchValidationError } from 'app/features/provisioning/Shared/BranchValidationError';
|
||||
import { WorkflowOption } from 'app/features/provisioning/types';
|
||||
import { validateBranchName } from 'app/features/provisioning/utils/git';
|
||||
|
||||
interface DashboardEditFormSharedFieldsProps {
|
||||
workflowOptions: Array<{ label: string; value: string }>;
|
||||
isNew?: boolean;
|
||||
readOnly?: boolean;
|
||||
workflow?: WorkflowOption;
|
||||
isGitHub?: boolean;
|
||||
}
|
||||
|
||||
export const DashboardEditFormSharedFields = memo<DashboardEditFormSharedFieldsProps>(
|
||||
({ readOnly = false, workflow, workflowOptions, isGitHub, isNew }) => {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Path */}
|
||||
<Field
|
||||
noMargin
|
||||
label={t('dashboard-scene.save-or-delete-provisioned-dashboard-form.label-path', 'Path')}
|
||||
description={t(
|
||||
'dashboard-scene.save-or-delete-provisioned-dashboard-form.description-inside-repository',
|
||||
'File path inside the repository (.json or .yaml)'
|
||||
)}
|
||||
>
|
||||
<Input id="dashboard-path" type="text" {...register('path')} readOnly={!isNew} />
|
||||
</Field>
|
||||
|
||||
{/* Comment */}
|
||||
<Field noMargin label={t('dashboard-scene.save-or-delete-provisioned-dashboard-form.label-comment', 'Comment')}>
|
||||
<TextArea
|
||||
id="dashboard-comment"
|
||||
{...register('comment')}
|
||||
disabled={readOnly}
|
||||
placeholder={t(
|
||||
'dashboard-scene.save-or-delete-provisioned-dashboard-form.dashboard-comment-placeholder-describe-changes-optional',
|
||||
'Add a note to describe your changes (optional)'
|
||||
)}
|
||||
rows={5}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Workflow */}
|
||||
{isGitHub && !readOnly && (
|
||||
<>
|
||||
<Field
|
||||
noMargin
|
||||
label={t('dashboard-scene.save-or-delete-provisioned-dashboard-form.label-workflow', 'Workflow')}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="workflow"
|
||||
render={({ field: { ref: _, ...field } }) => (
|
||||
<RadioButtonGroup id="dashboard-workflow" {...field} options={workflowOptions} />
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
{workflow === 'branch' && (
|
||||
<Field
|
||||
noMargin
|
||||
label={t('dashboard-scene.save-or-delete-provisioned-dashboard-form.label-branch', 'Branch')}
|
||||
description={t(
|
||||
'dashboard-scene.save-or-delete-provisioned-dashboard-form.description-branch-name-in-git-hub',
|
||||
'Branch name in GitHub'
|
||||
)}
|
||||
invalid={!!errors.ref}
|
||||
error={errors.ref && <BranchValidationError />}
|
||||
>
|
||||
<Input id="dashboard-branch" {...register('ref', { validate: validateBranchName })} />
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -1,11 +1,9 @@
|
|||
import { useUrlParams } from 'app/core/navigation/hooks';
|
||||
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
import { SaveDashboardDrawer } from '../SaveDashboardDrawer';
|
||||
import { DashboardChangeInfo } from '../shared';
|
||||
|
||||
import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm';
|
||||
import { useDefaultValues } from './hooks';
|
||||
import { useProvisionedDashboardData } from './hooks';
|
||||
|
||||
export interface SaveProvisionedDashboardProps {
|
||||
dashboard: DashboardScene;
|
||||
|
@ -14,17 +12,12 @@ export interface SaveProvisionedDashboardProps {
|
|||
}
|
||||
|
||||
export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: SaveProvisionedDashboardProps) {
|
||||
const { meta, title: defaultTitle, description: defaultDescription } = dashboard.useState();
|
||||
|
||||
const [params] = useUrlParams();
|
||||
const loadedFromRef = params.get('ref') ?? undefined;
|
||||
|
||||
const defaultValues = useDefaultValues({ meta, defaultTitle, defaultDescription, loadedFromRef });
|
||||
const { isNew, defaultValues, loadedFromRef, isGitHub, workflowOptions, readOnly } =
|
||||
useProvisionedDashboardData(dashboard);
|
||||
|
||||
if (!defaultValues) {
|
||||
return null;
|
||||
}
|
||||
const { values, isNew, isGitHub, repository } = defaultValues;
|
||||
|
||||
return (
|
||||
<SaveProvisionedDashboardForm
|
||||
|
@ -32,10 +25,11 @@ export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: Save
|
|||
drawer={drawer}
|
||||
changeInfo={changeInfo}
|
||||
isNew={isNew}
|
||||
defaultValues={values}
|
||||
defaultValues={defaultValues}
|
||||
loadedFromRef={loadedFromRef}
|
||||
isGitHub={isGitHub}
|
||||
repository={repository}
|
||||
workflowOptions={workflowOptions}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { getAppEvents, locationService } from '@grafana/runtime';
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { AnnoKeyFolder, AnnoKeySourcePath } from 'app/features/apiserver/types';
|
||||
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
|
||||
|
@ -32,6 +31,12 @@ jest.mock('@grafana/runtime', () => {
|
|||
};
|
||||
});
|
||||
|
||||
jest.mock('app/features/dashboard-scene/utils/useProvisionedRequestHandler', () => {
|
||||
return {
|
||||
useProvisionedRequestHandler: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('app/core/components/Select/FolderPicker', () => {
|
||||
const actual = jest.requireActual('app/core/components/Select/FolderPicker');
|
||||
return {
|
||||
|
@ -127,13 +132,11 @@ function setup(props: Partial<Props> = {}) {
|
|||
description: 'Test Description',
|
||||
workflow: 'write',
|
||||
},
|
||||
repository: {
|
||||
name: 'repo-xyz',
|
||||
type: 'github',
|
||||
workflows: ['write', 'branch'],
|
||||
title: 'Test Repository',
|
||||
target: 'folder',
|
||||
},
|
||||
readOnly: false,
|
||||
workflowOptions: [
|
||||
{ label: 'Branch', value: 'branch' },
|
||||
{ label: 'Write', value: 'write' },
|
||||
],
|
||||
...props,
|
||||
};
|
||||
|
||||
|
@ -186,6 +189,10 @@ describe('SaveProvisionedDashboardForm', () => {
|
|||
});
|
||||
|
||||
it('should save a new dashboard successfully', async () => {
|
||||
const mockAction = jest.fn();
|
||||
const mockRequest = { ...mockRequestBase, isSuccess: true };
|
||||
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
|
||||
|
||||
const { user, props } = setup();
|
||||
const newDashboard = {
|
||||
apiVersion: 'dashboard.grafana.app/v1alpha1',
|
||||
|
@ -202,9 +209,7 @@ describe('SaveProvisionedDashboardForm', () => {
|
|||
},
|
||||
};
|
||||
props.dashboard.getSaveResource = jest.fn().mockReturnValue(newDashboard);
|
||||
const mockAction = jest.fn();
|
||||
const mockRequest = { ...mockRequestBase, isSuccess: true };
|
||||
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
|
||||
|
||||
const titleInput = screen.getByRole('textbox', { name: /title/i });
|
||||
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
|
||||
const pathInput = screen.getByRole('textbox', { name: /path/i });
|
||||
|
@ -223,9 +228,6 @@ describe('SaveProvisionedDashboardForm', () => {
|
|||
const submitButton = screen.getByRole('button', { name: /save/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.dashboard.setState).toHaveBeenCalledWith({ isDirty: false });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockAction).toHaveBeenCalledWith({
|
||||
ref: undefined,
|
||||
|
@ -235,16 +237,13 @@ describe('SaveProvisionedDashboardForm', () => {
|
|||
body: newDashboard,
|
||||
});
|
||||
});
|
||||
const appEvents = getAppEvents();
|
||||
expect(appEvents.publish).toHaveBeenCalledWith({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: ['Dashboard changes saved'],
|
||||
});
|
||||
expect(props.dashboard.closeModal).toHaveBeenCalled();
|
||||
expect(locationService.partial).toHaveBeenCalledWith({ viewPanel: null, editPanel: null });
|
||||
});
|
||||
|
||||
it('should update an existing dashboard successfully', async () => {
|
||||
const mockAction = jest.fn();
|
||||
const mockRequest = { ...mockRequestBase, isSuccess: true };
|
||||
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
|
||||
|
||||
const updatedDashboard = {
|
||||
apiVersion: 'dashboard.grafana.app/vXyz',
|
||||
metadata: {
|
||||
|
@ -256,7 +255,7 @@ describe('SaveProvisionedDashboardForm', () => {
|
|||
},
|
||||
spec: { title: 'Test Dashboard', description: 'Test Description' },
|
||||
};
|
||||
const { user, props } = setup({
|
||||
const { user } = setup({
|
||||
isNew: false,
|
||||
dashboard: {
|
||||
useState: () => ({
|
||||
|
@ -276,9 +275,7 @@ describe('SaveProvisionedDashboardForm', () => {
|
|||
setManager: jest.fn(),
|
||||
} as unknown as DashboardScene,
|
||||
});
|
||||
const mockAction = jest.fn();
|
||||
const mockRequest = { ...mockRequestBase, isSuccess: true };
|
||||
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
|
||||
|
||||
const pathInput = screen.getByRole('textbox', { name: /path/i });
|
||||
expect(pathInput).toHaveAttribute('readonly'); // can not edit the path value
|
||||
pathInput.removeAttribute('readonly'); // save won't get called unless we have a value
|
||||
|
@ -290,9 +287,6 @@ describe('SaveProvisionedDashboardForm', () => {
|
|||
await user.type(commentInput, 'Update dashboard');
|
||||
const submitButton = screen.getByRole('button', { name: /save/i });
|
||||
await user.click(submitButton);
|
||||
await waitFor(() => {
|
||||
expect(props.dashboard.setState).toHaveBeenCalledWith({ isDirty: false });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockAction).toHaveBeenCalledWith({
|
||||
ref: undefined,
|
||||
|
@ -302,11 +296,17 @@ describe('SaveProvisionedDashboardForm', () => {
|
|||
body: updatedDashboard,
|
||||
});
|
||||
});
|
||||
expect(props.dashboard.closeModal).toHaveBeenCalled();
|
||||
expect(locationService.partial).toHaveBeenCalledWith({ viewPanel: null, editPanel: null });
|
||||
});
|
||||
|
||||
it('should show error when save fails', async () => {
|
||||
const mockAction = jest.fn();
|
||||
const mockRequest = {
|
||||
...mockRequestBase,
|
||||
isSuccess: false,
|
||||
isError: true,
|
||||
error: 'Failed to save dashboard',
|
||||
};
|
||||
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
|
||||
const { user, props } = setup();
|
||||
const newDashboard = {
|
||||
apiVersion: 'dashboard.grafana.app/v1alpha1',
|
||||
|
@ -323,14 +323,7 @@ describe('SaveProvisionedDashboardForm', () => {
|
|||
},
|
||||
};
|
||||
props.dashboard.getSaveResource = jest.fn().mockReturnValue(newDashboard);
|
||||
const mockAction = jest.fn();
|
||||
const mockRequest = {
|
||||
...mockRequestBase,
|
||||
isSuccess: false,
|
||||
isError: true,
|
||||
error: 'Failed to save dashboard',
|
||||
};
|
||||
(useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
|
||||
|
||||
const titleInput = screen.getByRole('textbox', { name: /title/i });
|
||||
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
|
||||
const pathInput = screen.getByRole('textbox', { name: /path/i });
|
||||
|
@ -358,13 +351,6 @@ describe('SaveProvisionedDashboardForm', () => {
|
|||
body: newDashboard,
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
const appEvents = getAppEvents();
|
||||
expect(appEvents.publish).toHaveBeenCalledWith({
|
||||
type: AppEvents.alertError.name,
|
||||
payload: ['Error saving dashboard', 'Failed to save dashboard'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable save button when dashboard is not dirty', () => {
|
||||
|
@ -393,13 +379,7 @@ describe('SaveProvisionedDashboardForm', () => {
|
|||
it('should properly handle read-only state for a repository without workflows', () => {
|
||||
setup({
|
||||
isNew: false,
|
||||
repository: {
|
||||
name: 'repo-abc',
|
||||
type: 'github',
|
||||
workflows: [],
|
||||
target: 'folder',
|
||||
title: 'Read-only Repository',
|
||||
},
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
// Alert is shown
|
||||
|
|
|
@ -1,50 +1,35 @@
|
|||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Controller, useForm, FormProvider } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { AppEvents, locationUtil } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { getAppEvents, locationService } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { Alert, Button, Field, Input, RadioButtonGroup, Stack, TextArea } from '@grafana/ui';
|
||||
import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { Alert, Button, Field, Input, Stack, TextArea } from '@grafana/ui';
|
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import { Resource } from 'app/features/apiserver/types';
|
||||
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
|
||||
import { BranchValidationError } from 'app/features/provisioning/Shared/BranchValidationError';
|
||||
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
|
||||
import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile';
|
||||
import { WorkflowOption } from 'app/features/provisioning/types';
|
||||
import { validateBranchName } from 'app/features/provisioning/utils/git';
|
||||
|
||||
import { DashboardEditFormSharedFields } from '../../components/Provisioned/DashboardEditFormSharedFields';
|
||||
import { getDashboardUrl } from '../../utils/getDashboardUrl';
|
||||
import { useProvisionedRequestHandler } from '../../utils/useProvisionedRequestHandler';
|
||||
import { SaveDashboardFormCommonOptions } from '../SaveDashboardForm';
|
||||
import { ProvisionedDashboardFormData } from '../shared';
|
||||
|
||||
import { SaveProvisionedDashboardProps } from './SaveProvisionedDashboard';
|
||||
import { getWorkflowOptions } from './defaults';
|
||||
import { getProvisionedMeta } from './utils/getProvisionedMeta';
|
||||
|
||||
type FormData = {
|
||||
ref?: string;
|
||||
path: string;
|
||||
comment?: string;
|
||||
repo: string;
|
||||
workflow?: WorkflowOption;
|
||||
title: string;
|
||||
description: string;
|
||||
folder: {
|
||||
uid?: string;
|
||||
title?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface Props extends SaveProvisionedDashboardProps {
|
||||
isNew: boolean;
|
||||
defaultValues: FormData;
|
||||
defaultValues: ProvisionedDashboardFormData;
|
||||
isGitHub: boolean;
|
||||
repository?: RepositoryView;
|
||||
loadedFromRef?: string;
|
||||
workflowOptions: Array<{ label: string; value: string }>;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export function SaveProvisionedDashboardForm({
|
||||
|
@ -54,8 +39,9 @@ export function SaveProvisionedDashboardForm({
|
|||
changeInfo,
|
||||
isNew,
|
||||
loadedFromRef,
|
||||
repository,
|
||||
isGitHub,
|
||||
workflowOptions,
|
||||
readOnly,
|
||||
}: Props) {
|
||||
const navigate = useNavigate();
|
||||
const appEvents = getAppEvents();
|
||||
|
@ -63,76 +49,69 @@ export function SaveProvisionedDashboardForm({
|
|||
|
||||
const [createOrUpdateFile, request] = useCreateOrUpdateRepositoryFile(isNew ? undefined : defaultValues.path);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
control,
|
||||
reset,
|
||||
} = useForm<FormData>({ defaultValues });
|
||||
const [ref, workflow, path] = watch(['ref', 'workflow', 'path']);
|
||||
const methods = useForm<ProvisionedDashboardFormData>({ defaultValues });
|
||||
const { handleSubmit, watch, control, reset, register } = methods;
|
||||
const [workflow] = watch(['workflow']);
|
||||
|
||||
// Update the form if default values change
|
||||
useEffect(() => {
|
||||
reset(defaultValues);
|
||||
}, [defaultValues, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (request.isSuccess) {
|
||||
dashboard.setState({ isDirty: false });
|
||||
const onRequestError = (error: unknown) => {
|
||||
appEvents.publish({
|
||||
type: AppEvents.alertError.name,
|
||||
payload: [t('dashboard-scene.save-provisioned-dashboard-form.api-error', 'Error saving dashboard'), error],
|
||||
});
|
||||
};
|
||||
|
||||
const { ref, path } = request.data;
|
||||
const onWriteSuccess = () => {
|
||||
panelEditor?.onDiscard();
|
||||
drawer.onClose();
|
||||
locationService.partial({
|
||||
viewPanel: null,
|
||||
editPanel: null,
|
||||
});
|
||||
};
|
||||
|
||||
if (workflow === 'branch' && ref && path) {
|
||||
dashboard.closeModal();
|
||||
panelEditor?.onDiscard();
|
||||
// Redirect to the provisioning preview pages
|
||||
navigate(`${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}?ref=${ref}`);
|
||||
return;
|
||||
}
|
||||
const onNewDashboardSuccess = (upsert: Resource<Dashboard>) => {
|
||||
panelEditor?.onDiscard();
|
||||
drawer.onClose();
|
||||
const url = locationUtil.assureBaseUrl(
|
||||
getDashboardUrl({
|
||||
uid: upsert.metadata.name,
|
||||
slug: kbn.slugifyForUrl(upsert.spec.title ?? ''),
|
||||
currentQueryParams: window.location.search,
|
||||
})
|
||||
);
|
||||
|
||||
appEvents.publish({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: [t('dashboard-scene.save-provisioned-dashboard-form.api-success', 'Dashboard changes saved')],
|
||||
});
|
||||
navigate(url);
|
||||
};
|
||||
|
||||
// Load the new URL
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const upsert = request.data.resource.upsert as Resource<Dashboard>;
|
||||
if (isNew && upsert?.metadata?.name) {
|
||||
const url = locationUtil.assureBaseUrl(
|
||||
getDashboardUrl({
|
||||
uid: upsert.metadata.name,
|
||||
slug: kbn.slugifyForUrl(upsert.spec.title ?? ''),
|
||||
currentQueryParams: window.location.search,
|
||||
})
|
||||
);
|
||||
const onBranchSuccess = (ref: string, path: string) => {
|
||||
panelEditor?.onDiscard();
|
||||
drawer.onClose();
|
||||
navigate(`${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}?ref=${ref}`);
|
||||
};
|
||||
|
||||
navigate(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the same URL
|
||||
dashboard.closeModal();
|
||||
locationService.partial({
|
||||
viewPanel: null,
|
||||
editPanel: null,
|
||||
});
|
||||
} else if (request.isError) {
|
||||
appEvents.publish({
|
||||
type: AppEvents.alertError.name,
|
||||
payload: [
|
||||
t('dashboard-scene.save-provisioned-dashboard-form.api-error', 'Error saving dashboard'),
|
||||
request.error,
|
||||
],
|
||||
});
|
||||
}
|
||||
}, [appEvents, dashboard, defaultValues.repo, drawer, isNew, navigate, panelEditor, path, ref, request, workflow]);
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
workflow,
|
||||
handlers: {
|
||||
onBranchSuccess: ({ ref, path }) => onBranchSuccess(ref, path),
|
||||
onWriteSuccess,
|
||||
onNewDashboardSuccess,
|
||||
onError: onRequestError,
|
||||
},
|
||||
isNew,
|
||||
});
|
||||
|
||||
// Submit handler for saving the form data
|
||||
const handleFormSubmit = async ({ title, description, repo, path, comment, ref }: FormData) => {
|
||||
const handleFormSubmit = async ({ title, description, repo, path, comment, ref }: ProvisionedDashboardFormData) => {
|
||||
// Validate required fields
|
||||
if (!repo || !path) {
|
||||
console.error('Missing required fields for saving:', { repo, path });
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -156,150 +135,106 @@ export function SaveProvisionedDashboardForm({
|
|||
});
|
||||
};
|
||||
|
||||
const workflowOptions = getWorkflowOptions(repository, loadedFromRef);
|
||||
const readOnly = !repository?.workflows?.length;
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} name="save-provisioned-form">
|
||||
<Stack direction="column" gap={2}>
|
||||
{readOnly && (
|
||||
<Alert
|
||||
title={t(
|
||||
'dashboard-scene.save-provisioned-dashboard-form.title-this-repository-is-read-only',
|
||||
'This repository is read only'
|
||||
)}
|
||||
>
|
||||
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.copy-json-message">
|
||||
If you have direct access to the target, copy the JSON and paste it there.
|
||||
</Trans>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isNew && (
|
||||
<>
|
||||
<Field
|
||||
noMargin
|
||||
label={t('dashboard-scene.save-provisioned-dashboard-form.label-title', 'Title')}
|
||||
invalid={!!errors.title}
|
||||
error={errors.title?.message}
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} name="save-provisioned-form">
|
||||
<Stack direction="column" gap={2}>
|
||||
{readOnly && (
|
||||
<Alert
|
||||
title={t(
|
||||
'dashboard-scene.save-provisioned-dashboard-form.title-this-repository-is-read-only',
|
||||
'This repository is read only'
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
id="dashboard-title"
|
||||
{...register('title', {
|
||||
required: t(
|
||||
'dashboard-scene.save-provisioned-dashboard-form.title-required',
|
||||
'Dashboard title is required'
|
||||
),
|
||||
validate: validateTitle,
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
noMargin
|
||||
label={t('dashboard-scene.save-provisioned-dashboard-form.label-description', 'Description')}
|
||||
invalid={!!errors.description}
|
||||
error={errors.description?.message}
|
||||
>
|
||||
<TextArea id="dashboard-description" {...register('description')} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
noMargin
|
||||
label={t('dashboard-scene.save-provisioned-dashboard-form.label-target-folder', 'Target folder')}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name={'folder'}
|
||||
render={({ field: { ref, value, onChange, ...field } }) => {
|
||||
return (
|
||||
<FolderPicker
|
||||
onChange={async (uid?: string, title?: string) => {
|
||||
onChange({ uid, title });
|
||||
// Update folderUid URL param
|
||||
updateURLParams('folderUid', uid);
|
||||
const meta = await getProvisionedMeta(uid);
|
||||
dashboard.setState({
|
||||
meta: {
|
||||
...meta,
|
||||
folderUid: uid,
|
||||
},
|
||||
});
|
||||
}}
|
||||
value={value.uid}
|
||||
{...field}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isNew && !readOnly && <SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />}
|
||||
|
||||
<Field
|
||||
noMargin
|
||||
label={t('dashboard-scene.save-provisioned-dashboard-form.label-path', 'Path')}
|
||||
description={t(
|
||||
'dashboard-scene.save-provisioned-dashboard-form.description-inside-repository',
|
||||
'File path inside the repository (.json or .yaml)'
|
||||
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.copy-json-message">
|
||||
If you have direct access to the target, copy the JSON and paste it there.
|
||||
</Trans>
|
||||
</Alert>
|
||||
)}
|
||||
>
|
||||
<Input id="dashboard-path" {...register('path')} readOnly={!isNew} />
|
||||
</Field>
|
||||
|
||||
<Field noMargin label={t('dashboard-scene.save-provisioned-dashboard-form.label-comment', 'Comment')}>
|
||||
<TextArea
|
||||
id="dashboard-comment"
|
||||
{...register('comment')}
|
||||
disabled={readOnly}
|
||||
placeholder={t(
|
||||
'dashboard-scene.save-provisioned-dashboard-form.dashboard-comment-placeholder-describe-changes-optional',
|
||||
'Add a note to describe your changes (optional)'
|
||||
)}
|
||||
rows={5}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isGitHub && !readOnly && (
|
||||
<>
|
||||
<Field noMargin label={t('dashboard-scene.save-provisioned-dashboard-form.label-workflow', 'Workflow')}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="workflow"
|
||||
render={({ field: { ref: _, ...field } }) => (
|
||||
<RadioButtonGroup id="dashboard-workflow" {...field} options={workflowOptions} />
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
{workflow === 'branch' && (
|
||||
{isNew && (
|
||||
<>
|
||||
<Field
|
||||
noMargin
|
||||
label={t('dashboard-scene.save-provisioned-dashboard-form.label-branch', 'Branch')}
|
||||
description={t(
|
||||
'dashboard-scene.save-provisioned-dashboard-form.description-branch-name-in-git-hub',
|
||||
'Branch name in GitHub'
|
||||
)}
|
||||
invalid={!!errors.ref}
|
||||
error={errors.ref && <BranchValidationError />}
|
||||
label={t('dashboard-scene.save-provisioned-dashboard-form.label-title', 'Title')}
|
||||
invalid={!!methods.formState.errors.title}
|
||||
error={methods.formState.errors.title?.message}
|
||||
>
|
||||
<Input id="dashboard-branch" {...register('ref', { validate: validateBranchName })} />
|
||||
<Input
|
||||
id="dashboard-title"
|
||||
{...register('title', {
|
||||
required: t(
|
||||
'dashboard-scene.save-provisioned-dashboard-form.title-required',
|
||||
'Dashboard title is required'
|
||||
),
|
||||
validate: validateTitle,
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
noMargin
|
||||
label={t('dashboard-scene.save-provisioned-dashboard-form.label-description', 'Description')}
|
||||
invalid={!!methods.formState.errors.description}
|
||||
error={methods.formState.errors.description?.message}
|
||||
>
|
||||
<TextArea id="dashboard-description" {...register('description')} />
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Stack gap={2}>
|
||||
<Button variant="primary" type="submit" disabled={request.isLoading || !isDirty || readOnly}>
|
||||
{request.isLoading
|
||||
? t('dashboard-scene.save-provisioned-dashboard-form.saving', 'Saving...')
|
||||
: t('dashboard-scene.save-provisioned-dashboard-form.save', 'Save')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={drawer.onClose} fill="outline">
|
||||
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
<Field
|
||||
noMargin
|
||||
label={t('dashboard-scene.save-provisioned-dashboard-form.label-target-folder', 'Target folder')}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name={'folder'}
|
||||
render={({ field: { ref, value, onChange, ...field } }) => {
|
||||
return (
|
||||
<FolderPicker
|
||||
onChange={async (uid?: string, title?: string) => {
|
||||
onChange({ uid, title });
|
||||
// Update folderUid URL param
|
||||
updateURLParams('folderUid', uid);
|
||||
const meta = await getProvisionedMeta(uid);
|
||||
dashboard.setState({
|
||||
meta: {
|
||||
...meta,
|
||||
folderUid: uid,
|
||||
},
|
||||
});
|
||||
}}
|
||||
value={value.uid}
|
||||
{...field}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isNew && !readOnly && <SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />}
|
||||
|
||||
<DashboardEditFormSharedFields
|
||||
readOnly={readOnly}
|
||||
workflow={workflow}
|
||||
workflowOptions={workflowOptions}
|
||||
isGitHub={isGitHub}
|
||||
isNew={isNew}
|
||||
/>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Button variant="primary" type="submit" disabled={request.isLoading || !isDirty || readOnly}>
|
||||
{request.isLoading
|
||||
? t('dashboard-scene.save-provisioned-dashboard-form.saving', 'Saving...')
|
||||
: t('dashboard-scene.save-provisioned-dashboard-form.save', 'Save')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={drawer.onClose} fill="outline">
|
||||
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -307,7 +242,7 @@ export function SaveProvisionedDashboardForm({
|
|||
* Dashboard title validation to ensure it's not the same as the folder name
|
||||
* and meets other naming requirements.
|
||||
*/
|
||||
async function validateTitle(title: string, formValues: FormData) {
|
||||
async function validateTitle(title: string, formValues: ProvisionedDashboardFormData) {
|
||||
if (title === formValues.folder.title?.trim()) {
|
||||
return t(
|
||||
'dashboard-scene.save-provisioned-dashboard-form.title-same-as-folder',
|
||||
|
|
|
@ -1,8 +1,15 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { useUrlParams } from 'app/core/navigation/hooks';
|
||||
import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, AnnoKeySourcePath } from 'app/features/apiserver/types';
|
||||
import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
|
||||
import { DashboardMeta } from 'app/types';
|
||||
|
||||
import { getDefaultWorkflow } from './defaults';
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
import { ProvisionedDashboardFormData } from '../shared';
|
||||
|
||||
import { getDefaultWorkflow, getWorkflowOptions } from './defaults';
|
||||
import { generatePath } from './utils/path';
|
||||
import { generateTimestamp } from './utils/timestamp';
|
||||
|
||||
|
@ -56,3 +63,68 @@ export function useDefaultValues({ meta, defaultTitle, defaultDescription, loade
|
|||
repository,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProvisionedDashboardData {
|
||||
isReady: boolean;
|
||||
isLoading: boolean;
|
||||
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
defaultValues: ProvisionedDashboardFormData | null;
|
||||
repository?: RepositoryView;
|
||||
loadedFromRef?: string;
|
||||
workflowOptions: Array<{ label: string; value: string }>;
|
||||
isNew: boolean;
|
||||
isGitHub: boolean;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to fetch and prepare data for a provisioned dashboard update/delete form.
|
||||
* It retrieves default values, repository information, and workflow options based on the current dashboard state.
|
||||
*/
|
||||
|
||||
export function useProvisionedDashboardData(dashboard: DashboardScene): ProvisionedDashboardData {
|
||||
const { meta, title: defaultTitle, description: defaultDescription } = dashboard.useState();
|
||||
const [params] = useUrlParams();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const loadedFromRef = params.get('ref') ?? undefined;
|
||||
|
||||
const defaultValuesResult = useDefaultValues({
|
||||
meta,
|
||||
defaultTitle,
|
||||
defaultDescription,
|
||||
loadedFromRef,
|
||||
});
|
||||
|
||||
if (!defaultValuesResult) {
|
||||
return {
|
||||
isReady: false,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
defaultValues: null,
|
||||
repository: undefined,
|
||||
loadedFromRef,
|
||||
workflowOptions: [],
|
||||
isNew: false,
|
||||
isGitHub: false,
|
||||
readOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { values, isNew, isGitHub, repository } = defaultValuesResult;
|
||||
const workflowOptions = getWorkflowOptions(repository, loadedFromRef);
|
||||
|
||||
const readOnly = !repository?.workflows?.length;
|
||||
|
||||
return {
|
||||
isReady: true,
|
||||
defaultValues: values,
|
||||
repository,
|
||||
loadedFromRef,
|
||||
workflowOptions,
|
||||
isNew,
|
||||
isGitHub,
|
||||
readOnly,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { config, isFetchError } from '@grafana/runtime';
|
|||
import { Dashboard } from '@grafana/schema';
|
||||
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
|
||||
import { Alert, Box, Button, Stack } from '@grafana/ui';
|
||||
import { WorkflowOption } from 'app/features/provisioning/types';
|
||||
|
||||
import { Diffs } from '../settings/version-history/utils';
|
||||
|
||||
|
@ -22,6 +23,19 @@ export interface DashboardChangeInfo {
|
|||
hasFolderChanges?: boolean;
|
||||
hasMigratedToV2?: boolean;
|
||||
}
|
||||
export interface ProvisionedDashboardFormData {
|
||||
ref?: string; // Branch or tag in the repository
|
||||
path: string; // Path to the dashboard file in the repository
|
||||
comment?: string; // Commit message for the change
|
||||
repo: string; // Repository name
|
||||
workflow?: WorkflowOption;
|
||||
title: string; // Title of the dashboard
|
||||
description: string;
|
||||
folder: {
|
||||
uid?: string;
|
||||
title?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isVersionMismatchError(error?: Error) {
|
||||
return isFetchError(error) && error.data && error.data.status === 'version-mismatch';
|
||||
|
|
|
@ -6,9 +6,10 @@ import { config, reportInteraction } from '@grafana/runtime';
|
|||
import { Button, ConfirmModal, Modal, Space, Text, TextLink } from '@grafana/ui';
|
||||
|
||||
import { useDeleteItemsMutation } from '../../browse-dashboards/api/browseDashboardsAPI';
|
||||
import { ProvisionedResourceDeleteModal } from '../saving/provisioned/ProvisionedResourceDeleteModal';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
import { DeleteProvisionedDashboardDrawer } from './DeleteProvisionedDashboardDrawer';
|
||||
|
||||
interface ButtonProps {
|
||||
dashboard: DashboardScene;
|
||||
}
|
||||
|
@ -50,12 +51,14 @@ export function DeleteDashboardButton({ dashboard }: ButtonProps) {
|
|||
await dashboard.onDashboardDelete();
|
||||
}, [dashboard, toggleModal]);
|
||||
|
||||
if (dashboard.state.meta.provisioned && showModal) {
|
||||
return <ProvisionedDeleteModal dashboardId={dashboard.state.meta.provisionedExternalId} onClose={toggleModal} />;
|
||||
// Git managed dashboard
|
||||
if (dashboard.isManagedRepository() && showModal) {
|
||||
return <DeleteProvisionedDashboardDrawer dashboard={dashboard} onDismiss={toggleModal} />;
|
||||
}
|
||||
|
||||
if (dashboard.isManagedRepository() && showModal) {
|
||||
return <ProvisionedResourceDeleteModal resource={dashboard} onDismiss={toggleModal} />;
|
||||
// classic provisioning
|
||||
if (dashboard.state.meta.provisioned && showModal) {
|
||||
return <ProvisionedDeleteModal dashboardId={dashboard.state.meta.provisionedExternalId} onClose={toggleModal} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import { useProvisionedDashboardData } from '../saving/provisioned/hooks';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
import { DeleteProvisionedDashboardForm } from './DeleteProvisionedDashboardForm';
|
||||
|
||||
export interface Props {
|
||||
dashboard: DashboardScene;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description
|
||||
* Drawer component for deleting a git provisioned dashboard.
|
||||
*/
|
||||
export function DeleteProvisionedDashboardDrawer({ dashboard, onDismiss }: Props) {
|
||||
const { defaultValues, loadedFromRef, readOnly, isGitHub, workflowOptions, isNew } =
|
||||
useProvisionedDashboardData(dashboard);
|
||||
|
||||
if (!defaultValues) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DeleteProvisionedDashboardForm
|
||||
dashboard={dashboard}
|
||||
defaultValues={defaultValues}
|
||||
loadedFromRef={loadedFromRef}
|
||||
readOnly={readOnly}
|
||||
isGitHub={isGitHub}
|
||||
isNew={isNew}
|
||||
workflowOptions={workflowOptions}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,302 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
import { useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
import { useProvisionedDashboardData, ProvisionedDashboardData } from '../saving/provisioned/hooks';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
import { DeleteProvisionedDashboardDrawer, Props } from './DeleteProvisionedDashboardDrawer';
|
||||
|
||||
// Mock the hooks and dependencies
|
||||
jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
|
||||
useDeleteRepositoryFilesWithPathMutation: jest.fn(),
|
||||
provisioningAPIv0alpha1: {
|
||||
endpoints: {
|
||||
listRepository: {
|
||||
select: jest.fn(() => () => ({ data: { items: [] } })),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
jest.mock('../saving/provisioned/hooks');
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getAppEvents: jest.fn(),
|
||||
}));
|
||||
jest.mock('react-router-dom-v5-compat', () => ({
|
||||
...jest.requireActual('react-router-dom-v5-compat'),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
// Add this variable declaration near your other mock variables
|
||||
const mockNavigate = jest.fn();
|
||||
|
||||
// Mock shared form components
|
||||
jest.mock('../components/Provisioned/DashboardEditFormSharedFields', () => ({
|
||||
DashboardEditFormSharedFields: ({ disabled }: { disabled: boolean }) => (
|
||||
<textarea data-testid="shared-fields" disabled={disabled} />
|
||||
),
|
||||
}));
|
||||
|
||||
const mockDeleteRepoFile = jest.fn();
|
||||
const mockPublish = jest.fn();
|
||||
const mockUseDeleteRepositoryFiles = useDeleteRepositoryFilesWithPathMutation as jest.MockedFunction<
|
||||
typeof useDeleteRepositoryFilesWithPathMutation
|
||||
>;
|
||||
const mockUseProvisionedDashboardData = useProvisionedDashboardData as jest.MockedFunction<
|
||||
typeof useProvisionedDashboardData
|
||||
>;
|
||||
|
||||
// Mock request state helper
|
||||
type MockRequestState = {
|
||||
isLoading: boolean;
|
||||
isSuccess: boolean;
|
||||
isError: boolean;
|
||||
error?: Error;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
const createMockRequestState = (overrides: Partial<MockRequestState> = {}): MockRequestState => ({
|
||||
isLoading: false,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
reset: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
interface SetupOptions extends Partial<Props> {
|
||||
provisionedData?: Partial<ProvisionedDashboardData>;
|
||||
requestState?: Partial<MockRequestState>;
|
||||
}
|
||||
|
||||
function setup(options: SetupOptions = {}) {
|
||||
const { provisionedData = {}, requestState = {}, ...props } = options;
|
||||
const user = userEvent.setup();
|
||||
|
||||
const defaultDashboard = new DashboardScene({
|
||||
title: 'Test Dashboard',
|
||||
uid: 'test-uid',
|
||||
meta: { slug: 'test-slug' },
|
||||
});
|
||||
|
||||
const defaultProvisionedData: ProvisionedDashboardData = {
|
||||
isReady: true,
|
||||
isLoading: false,
|
||||
setIsLoading: jest.fn(),
|
||||
defaultValues: {
|
||||
repo: 'test-repo',
|
||||
ref: 'main',
|
||||
workflow: 'branch' as const,
|
||||
path: 'dashboards/test.json',
|
||||
comment: '',
|
||||
title: 'Test Dashboard',
|
||||
description: 'Test Description',
|
||||
folder: {
|
||||
uid: 'test-folder',
|
||||
title: 'Test Folder',
|
||||
},
|
||||
},
|
||||
repository: {
|
||||
name: 'test-repo',
|
||||
target: 'folder' as const,
|
||||
title: 'Test Repository',
|
||||
type: 'github' as const,
|
||||
workflows: ['branch', 'write'] as Array<'branch' | 'write'>,
|
||||
},
|
||||
loadedFromRef: 'main',
|
||||
readOnly: false,
|
||||
isGitHub: true,
|
||||
workflowOptions: [
|
||||
{ label: 'Branch', value: 'branch' },
|
||||
{ label: 'Write', value: 'write' },
|
||||
],
|
||||
isNew: false,
|
||||
...provisionedData,
|
||||
};
|
||||
|
||||
const defaultProps: Props = {
|
||||
dashboard: defaultDashboard,
|
||||
onDismiss: jest.fn(),
|
||||
...props,
|
||||
};
|
||||
|
||||
// Set up mocks with the merged data
|
||||
mockUseProvisionedDashboardData.mockReturnValue(defaultProvisionedData);
|
||||
mockUseDeleteRepositoryFiles.mockReturnValue([
|
||||
mockDeleteRepoFile,
|
||||
createMockRequestState(requestState) as ReturnType<typeof useDeleteRepositoryFilesWithPathMutation>[1],
|
||||
]);
|
||||
|
||||
return {
|
||||
user,
|
||||
props: defaultProps,
|
||||
defaultProvisionedData,
|
||||
...render(<DeleteProvisionedDashboardDrawer {...defaultProps} />),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DeleteProvisionedDashboardDrawer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock getAppEvents
|
||||
(getAppEvents as jest.Mock).mockReturnValue({
|
||||
publish: mockPublish,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the drawer with correct title and subtitle', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Delete Provisioned Dashboard' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should return null when defaultValues are not provided', () => {
|
||||
setup({
|
||||
provisionedData: {
|
||||
defaultValues: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render shared form fields correctly', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByTestId('shared-fields')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render delete and cancel buttons', () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByRole('button', { name: /delete dashboard/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should successfully delete dashboard with branch workflow', async () => {
|
||||
const { user } = setup();
|
||||
|
||||
const deleteButton = screen.getByRole('button', { name: /delete dashboard/i });
|
||||
await user.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteRepoFile).toHaveBeenCalledWith({
|
||||
name: 'test-repo',
|
||||
path: 'dashboards/test.json',
|
||||
ref: 'main',
|
||||
message: 'Delete dashboard: Test Dashboard',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing repository name', async () => {
|
||||
const { user } = setup({
|
||||
provisionedData: {
|
||||
defaultValues: {
|
||||
repo: '',
|
||||
ref: 'main',
|
||||
workflow: 'branch' as const,
|
||||
path: 'dashboards/test.json',
|
||||
comment: '',
|
||||
title: 'Test Dashboard',
|
||||
description: 'Test Description',
|
||||
folder: { uid: 'test-folder', title: 'Test Folder' },
|
||||
},
|
||||
repository: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
const deleteButton = screen.getByRole('button', { name: /delete dashboard/i });
|
||||
await user.click(deleteButton);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Missing required fields for deletion:', {
|
||||
repo: '',
|
||||
path: 'dashboards/test.json',
|
||||
});
|
||||
expect(mockDeleteRepoFile).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle missing path', async () => {
|
||||
const { user } = setup({
|
||||
provisionedData: {
|
||||
defaultValues: {
|
||||
repo: 'test-repo',
|
||||
ref: 'main',
|
||||
workflow: 'branch' as const,
|
||||
path: '',
|
||||
comment: '',
|
||||
title: 'Test Dashboard',
|
||||
description: 'Test Description',
|
||||
folder: { uid: 'test-folder', title: 'Test Folder' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
const deleteButton = screen.getByRole('button', { name: /delete dashboard/i });
|
||||
await user.click(deleteButton);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Missing required fields for deletion:', {
|
||||
repo: 'test-repo',
|
||||
path: '',
|
||||
});
|
||||
expect(mockDeleteRepoFile).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle error state', async () => {
|
||||
const error = new Error('API Error');
|
||||
setup({
|
||||
requestState: {
|
||||
isError: true,
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPublish).toHaveBeenCalledWith({
|
||||
type: AppEvents.alertError.name,
|
||||
payload: ['Failed to delete dashboard', error],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should show loading state when deletion is in progress', () => {
|
||||
setup({
|
||||
requestState: {
|
||||
isLoading: true,
|
||||
},
|
||||
});
|
||||
|
||||
const deleteButton = screen.getByRole('button', { name: /deleting/i });
|
||||
expect(deleteButton).toBeDisabled();
|
||||
expect(deleteButton).toHaveTextContent('Deleting...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onDismiss when cancel button is clicked', async () => {
|
||||
const { user, props } = setup();
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(props.onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,148 @@
|
|||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
import { Alert, Button, Drawer, Stack } from '@grafana/ui';
|
||||
import { useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
|
||||
|
||||
import { DashboardEditFormSharedFields } from '../components/Provisioned/DashboardEditFormSharedFields';
|
||||
import { ProvisionedDashboardFormData } from '../saving/shared';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { useProvisionedRequestHandler } from '../utils/useProvisionedRequestHandler';
|
||||
|
||||
export interface Props {
|
||||
dashboard: DashboardScene;
|
||||
defaultValues: ProvisionedDashboardFormData;
|
||||
readOnly: boolean;
|
||||
isGitHub: boolean;
|
||||
isNew?: boolean;
|
||||
workflowOptions: Array<{ label: string; value: string }>;
|
||||
loadedFromRef?: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description
|
||||
* Drawer component for deleting a git provisioned dashboard.
|
||||
*/
|
||||
export function DeleteProvisionedDashboardForm({
|
||||
dashboard,
|
||||
defaultValues,
|
||||
loadedFromRef,
|
||||
readOnly,
|
||||
isGitHub,
|
||||
isNew,
|
||||
workflowOptions,
|
||||
onDismiss,
|
||||
}: Props) {
|
||||
const methods = useForm<ProvisionedDashboardFormData>({ defaultValues });
|
||||
const { editPanel: panelEditor } = dashboard.useState();
|
||||
const { handleSubmit, watch } = methods;
|
||||
|
||||
const [ref, workflow] = watch(['ref', 'workflow']);
|
||||
const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation();
|
||||
|
||||
const handleSubmitForm = async ({ repo, path, comment }: ProvisionedDashboardFormData) => {
|
||||
if (!repo || !path) {
|
||||
console.error('Missing required fields for deletion:', { repo, path });
|
||||
return;
|
||||
}
|
||||
|
||||
// If writing to the original branch, use the loaded reference; otherwise, use the selected ref.
|
||||
const branchRef = workflow === 'write' ? loadedFromRef : ref;
|
||||
const commitMessage = comment || `Delete dashboard: ${dashboard.state.title}`;
|
||||
|
||||
deleteRepoFile({
|
||||
name: repo,
|
||||
path: path,
|
||||
ref: branchRef,
|
||||
message: commitMessage,
|
||||
});
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onRequestError = (error: unknown) => {
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertError.name,
|
||||
payload: [t('dashboard-scene.delete-provisioned-dashboard-form.api-error', 'Failed to delete dashboard'), error],
|
||||
});
|
||||
};
|
||||
|
||||
const onWriteSuccess = () => {
|
||||
panelEditor?.onDiscard();
|
||||
onDismiss();
|
||||
// TODO reset search state instead
|
||||
window.location.href = '/dashboards';
|
||||
};
|
||||
|
||||
const onBranchSuccess = (path: string, urls?: Record<string, string>) => {
|
||||
panelEditor?.onDiscard();
|
||||
onDismiss();
|
||||
navigate(
|
||||
`${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}?pull_request_url=${urls?.newPullRequestURL}`
|
||||
);
|
||||
};
|
||||
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
workflow,
|
||||
handlers: {
|
||||
onBranchSuccess: ({ path, urls }) => onBranchSuccess(path, urls),
|
||||
onWriteSuccess,
|
||||
onError: onRequestError,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={t('dashboard-scene.delete-provisioned-dashboard-form.drawer-title', 'Delete Provisioned Dashboard')}
|
||||
subtitle={dashboard.state.title}
|
||||
onClose={onDismiss}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={handleSubmit(handleSubmitForm)}>
|
||||
<Stack direction="column" gap={2}>
|
||||
{readOnly && (
|
||||
<Alert
|
||||
title={t(
|
||||
'dashboard-scene.delete-provisioned-dashboard-form.title-this-repository-is-read-only',
|
||||
'This repository is read only'
|
||||
)}
|
||||
>
|
||||
<Trans i18nKey="dashboard-scene.delete-provisioned-dashboard-form.delete-read-only-file-message">
|
||||
This dashboard cannot be deleted directly from Grafana because the repository is read-only. To delete
|
||||
this dashboard, please remove the file from your Git repository.
|
||||
</Trans>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DashboardEditFormSharedFields
|
||||
isNew={isNew}
|
||||
readOnly={readOnly}
|
||||
workflow={workflow}
|
||||
workflowOptions={workflowOptions}
|
||||
isGitHub={isGitHub}
|
||||
/>
|
||||
|
||||
{/* Save / Cancel button */}
|
||||
<Stack gap={2}>
|
||||
<Button variant="destructive" type="submit" disabled={request.isLoading || readOnly}>
|
||||
{request.isLoading
|
||||
? t('dashboard-scene.delete-provisioned-dashboard-form.deleting', 'Deleting...')
|
||||
: t('dashboard-scene.delete-provisioned-dashboard-form.delete-action', 'Delete dashboard')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onDismiss} fill="outline">
|
||||
<Trans i18nKey="dashboard-scene.delete-provisioned-dashboard-form.cancel-action">Cancel</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,433 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import {
|
||||
DeleteRepositoryFilesWithPathApiResponse,
|
||||
GetRepositoryFilesWithPathApiResponse,
|
||||
ResourceWrapper,
|
||||
} from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { Resource } from 'app/features/apiserver/types';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
import { useProvisionedRequestHandler } from './useProvisionedRequestHandler';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
getAppEvents: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/i18n', () => ({
|
||||
t: jest.fn((key: string, defaultValue: string) => defaultValue),
|
||||
}));
|
||||
|
||||
const mockGetAppEvents = jest.mocked(getAppEvents);
|
||||
|
||||
describe('useProvisionedRequestHandler', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when request has an error', () => {
|
||||
it('should call onError handler', () => {
|
||||
const { request, handlers, dashboard } = setup({
|
||||
requestOverrides: {
|
||||
isError: true,
|
||||
isSuccess: false,
|
||||
error: new Error('Test error'),
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
handlers,
|
||||
})
|
||||
);
|
||||
|
||||
expect(handlers.onError).toHaveBeenCalledWith(new Error('Test error'));
|
||||
expect(handlers.onBranchSuccess).not.toHaveBeenCalled();
|
||||
expect(handlers.onWriteSuccess).not.toHaveBeenCalled();
|
||||
expect(handlers.onNewDashboardSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when request is successful', () => {
|
||||
it('should set dashboard isDirty to false', () => {
|
||||
const { request, handlers, dashboard } = setup({
|
||||
requestOverrides: {
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: {
|
||||
ref: 'main',
|
||||
path: '/path/to/dashboard',
|
||||
},
|
||||
},
|
||||
workflowOverride: 'branch',
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
workflow: 'branch',
|
||||
handlers,
|
||||
})
|
||||
);
|
||||
|
||||
expect(dashboard.setState).toHaveBeenCalledWith({ isDirty: false });
|
||||
});
|
||||
|
||||
it('should publish success event', () => {
|
||||
const { request, handlers, dashboard, mockPublish } = setup({
|
||||
requestOverrides: {
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
handlers,
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockPublish).toHaveBeenCalledWith({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: ['Dashboard changes saved successfully'],
|
||||
});
|
||||
});
|
||||
|
||||
describe('branch workflow', () => {
|
||||
it('should call onBranchSuccess when workflow is branch and data has ref and path', () => {
|
||||
const { request, handlers, dashboard } = setup({
|
||||
requestOverrides: {
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: {
|
||||
ref: 'feature-branch',
|
||||
path: '/path/to/dashboard.json',
|
||||
urls: { compareURL: 'http://example.com/edit' },
|
||||
},
|
||||
},
|
||||
workflowOverride: 'branch',
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
workflow: 'branch',
|
||||
handlers,
|
||||
})
|
||||
);
|
||||
|
||||
expect(handlers.onBranchSuccess).toHaveBeenCalledWith({
|
||||
ref: 'feature-branch',
|
||||
path: '/path/to/dashboard.json',
|
||||
urls: { compareURL: 'http://example.com/edit' },
|
||||
});
|
||||
expect(handlers.onWriteSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onBranchSuccess when ref is missing', () => {
|
||||
const { request, handlers, dashboard } = setup({
|
||||
requestOverrides: {
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: {
|
||||
path: '/path/to/dashboard.json',
|
||||
},
|
||||
},
|
||||
workflowOverride: 'branch',
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
workflow: 'branch',
|
||||
handlers,
|
||||
})
|
||||
);
|
||||
|
||||
expect(handlers.onBranchSuccess).not.toHaveBeenCalled();
|
||||
expect(handlers.onWriteSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('new dashboard flow', () => {
|
||||
it('should call onNewDashboardSuccess when isNew is true and resource.upsert exists', () => {
|
||||
const mockUpsertResource = {
|
||||
metadata: {
|
||||
name: 'test-dashboard',
|
||||
uid: 'test-uid',
|
||||
resourceVersion: '1',
|
||||
creationTimestamp: new Date().toISOString(),
|
||||
},
|
||||
spec: { title: 'Test Dashboard' } as Dashboard,
|
||||
apiVersion: 'v1',
|
||||
kind: 'Dashboard',
|
||||
};
|
||||
|
||||
const mockResource = {
|
||||
metadata: {
|
||||
name: 'test-dashboard',
|
||||
uid: 'test-uid',
|
||||
resourceVersion: '1',
|
||||
creationTimestamp: new Date().toISOString(),
|
||||
},
|
||||
spec: { title: 'Test Dashboard' } as Dashboard,
|
||||
apiVersion: 'v1',
|
||||
kind: 'Dashboard',
|
||||
upsert: mockUpsertResource,
|
||||
} as Resource<Dashboard> & { upsert: Resource<Dashboard> };
|
||||
|
||||
const { request, handlers, dashboard } = setup({
|
||||
requestOverrides: {
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: {
|
||||
repository: 'test-repo',
|
||||
resource: mockResource,
|
||||
} as unknown as ProvisionedRequestData,
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
handlers,
|
||||
isNew: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(handlers.onNewDashboardSuccess).toHaveBeenCalledWith(mockResource.upsert);
|
||||
expect(handlers.onWriteSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onNewDashboardSuccess when isNew is false', () => {
|
||||
const { request, handlers, dashboard } = setup({
|
||||
requestOverrides: {
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: {
|
||||
repository: 'test-repo',
|
||||
resource: {
|
||||
upsert: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Dashboard',
|
||||
metadata: { name: 'test-dashboard' },
|
||||
spec: { title: 'Test Dashboard' } as Dashboard,
|
||||
},
|
||||
metadata: { name: 'test-dashboard' },
|
||||
spec: { title: 'Test Dashboard' } as Dashboard,
|
||||
apiVersion: 'v1',
|
||||
kind: 'Dashboard',
|
||||
} as unknown as Resource<Dashboard>,
|
||||
} as unknown as ProvisionedRequestData,
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
handlers,
|
||||
isNew: false,
|
||||
})
|
||||
);
|
||||
|
||||
expect(handlers.onNewDashboardSuccess).not.toHaveBeenCalled();
|
||||
expect(handlers.onWriteSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onNewDashboardSuccess when resource.upsert is missing', () => {
|
||||
const { request, handlers, dashboard } = setup({
|
||||
requestOverrides: {
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: {
|
||||
resource: {},
|
||||
} as ResourceWrapper,
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
handlers,
|
||||
isNew: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(handlers.onNewDashboardSuccess).not.toHaveBeenCalled();
|
||||
expect(handlers.onWriteSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('write workflow', () => {
|
||||
it('should call onWriteSuccess as fallback', () => {
|
||||
const { request, handlers, dashboard } = setup({
|
||||
requestOverrides: {
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: {} as GetRepositoryFilesWithPathApiResponse,
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
handlers,
|
||||
})
|
||||
);
|
||||
|
||||
expect(handlers.onWriteSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when request is neither error nor success', () => {
|
||||
it('should not call any handlers', () => {
|
||||
const { request, handlers, dashboard, mockPublish } = setup({
|
||||
requestOverrides: {
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
isLoading: true,
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
handlers,
|
||||
})
|
||||
);
|
||||
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
expect(handlers.onBranchSuccess).not.toHaveBeenCalled();
|
||||
expect(handlers.onWriteSuccess).not.toHaveBeenCalled();
|
||||
expect(handlers.onNewDashboardSuccess).not.toHaveBeenCalled();
|
||||
expect(dashboard.setState).not.toHaveBeenCalled();
|
||||
expect(mockPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when request success but no data', () => {
|
||||
it('should not call any handlers when data is undefined', () => {
|
||||
const { request, handlers, dashboard, mockPublish } = setup({
|
||||
requestOverrides: {
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
handlers,
|
||||
})
|
||||
);
|
||||
|
||||
expect(handlers.onWriteSuccess).not.toHaveBeenCalled();
|
||||
expect(handlers.onBranchSuccess).not.toHaveBeenCalled();
|
||||
expect(dashboard.setState).not.toHaveBeenCalled();
|
||||
expect(mockPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('optional handlers', () => {
|
||||
it('should not throw when optional handlers are not provided', () => {
|
||||
const { request, dashboard } = setup({
|
||||
requestOverrides: {
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
},
|
||||
handlersOverrides: {},
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() =>
|
||||
useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
handlers: {},
|
||||
})
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type ProvisionedRequestData = DeleteRepositoryFilesWithPathApiResponse | GetRepositoryFilesWithPathApiResponse;
|
||||
|
||||
function setup({
|
||||
requestOverrides = {},
|
||||
handlersOverrides = {},
|
||||
workflowOverride,
|
||||
}: {
|
||||
requestOverrides?: Partial<{
|
||||
isError: boolean;
|
||||
isSuccess: boolean;
|
||||
isLoading?: boolean;
|
||||
error?: unknown;
|
||||
data?: Partial<ProvisionedRequestData>;
|
||||
}>;
|
||||
handlersOverrides?: Partial<{
|
||||
onBranchSuccess?: jest.Mock;
|
||||
onWriteSuccess?: jest.Mock;
|
||||
onNewDashboardSuccess?: jest.Mock;
|
||||
onError?: jest.Mock;
|
||||
}>;
|
||||
workflowOverride?: string;
|
||||
} = {}) {
|
||||
const mockPublish = jest.fn();
|
||||
const mockSetState = jest.fn();
|
||||
|
||||
mockGetAppEvents.mockReturnValue({
|
||||
publish: mockPublish,
|
||||
} as unknown as ReturnType<typeof getAppEvents>);
|
||||
|
||||
const dashboard = {
|
||||
setState: mockSetState,
|
||||
} as unknown as DashboardScene;
|
||||
|
||||
const request = {
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
data: undefined,
|
||||
...(requestOverrides as ResourceWrapper),
|
||||
};
|
||||
|
||||
const handlers = {
|
||||
onError: jest.fn(),
|
||||
onBranchSuccess: jest.fn(),
|
||||
onWriteSuccess: jest.fn(),
|
||||
onNewDashboardSuccess: jest.fn(),
|
||||
...handlersOverrides,
|
||||
};
|
||||
|
||||
return {
|
||||
dashboard,
|
||||
request,
|
||||
handlers,
|
||||
mockPublish,
|
||||
mockSetState,
|
||||
workflow: workflowOverride,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import {
|
||||
DeleteRepositoryFilesWithPathApiResponse,
|
||||
GetRepositoryFilesWithPathApiResponse,
|
||||
} from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { Resource } from 'app/features/apiserver/types';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
interface RequestHandlers {
|
||||
onBranchSuccess?: (data: { ref: string; path: string; urls?: Record<string, string> }) => void;
|
||||
onWriteSuccess?: () => void;
|
||||
onNewDashboardSuccess?: (resource: Resource<Dashboard>) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
interface ProvisionedRequest {
|
||||
isError: boolean;
|
||||
isSuccess: boolean;
|
||||
isLoading?: boolean;
|
||||
error?: unknown;
|
||||
data?: DeleteRepositoryFilesWithPathApiResponse | GetRepositoryFilesWithPathApiResponse;
|
||||
}
|
||||
|
||||
// This hook handles save new dashboard, edit existing dashboard, and delete dashboard response logic for provisioned dashboards.
|
||||
export function useProvisionedRequestHandler({
|
||||
dashboard,
|
||||
request,
|
||||
workflow,
|
||||
handlers,
|
||||
isNew,
|
||||
}: {
|
||||
dashboard: DashboardScene;
|
||||
request: ProvisionedRequest;
|
||||
workflow?: string;
|
||||
handlers: RequestHandlers;
|
||||
isNew?: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (request.isError) {
|
||||
handlers.onError?.(request.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.isSuccess && request.data) {
|
||||
dashboard.setState({ isDirty: false });
|
||||
const { ref, path, urls, resource } = request.data;
|
||||
|
||||
// Branch workflow
|
||||
if (workflow === 'branch' && ref && path) {
|
||||
handlers.onBranchSuccess?.({ ref, path, urls });
|
||||
return;
|
||||
}
|
||||
|
||||
// Success message (could be configurable)
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: [t('dashboard-scene.edit-provisioned-dashboard-form.success', 'Dashboard changes saved successfully')],
|
||||
});
|
||||
|
||||
// New dashboard flow
|
||||
if (isNew && resource?.upsert && handlers.onNewDashboardSuccess) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
handlers.onNewDashboardSuccess(resource.upsert as Resource<Dashboard>);
|
||||
return;
|
||||
}
|
||||
|
||||
// Write workflow
|
||||
handlers.onWriteSuccess?.();
|
||||
}
|
||||
}, [request, workflow, handlers, isNew, dashboard]);
|
||||
}
|
|
@ -5041,6 +5041,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"delete-provisioned-dashboard-form": {
|
||||
"api-error": "Failed to delete dashboard",
|
||||
"cancel-action": "Cancel",
|
||||
"delete-action": "Delete dashboard",
|
||||
"delete-read-only-file-message": "This dashboard cannot be deleted directly from Grafana because the repository is read-only. To delete this dashboard, please remove the file from your Git repository.",
|
||||
"deleting": "Deleting...",
|
||||
"drawer-title": "Delete Provisioned Dashboard",
|
||||
"title-this-repository-is-read-only": "This repository is read only"
|
||||
},
|
||||
"description-label": {
|
||||
"description": "Description"
|
||||
},
|
||||
|
@ -5051,6 +5060,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"edit-provisioned-dashboard-form": {
|
||||
"success": "Dashboard changes saved successfully"
|
||||
},
|
||||
"email-list": {
|
||||
"aria-label-emailmenu": "Toggle email menu"
|
||||
},
|
||||
|
@ -5323,24 +5335,25 @@
|
|||
"placeholder-search-affected-dashboards": "Search affected dashboards",
|
||||
"update-all": "Update all"
|
||||
},
|
||||
"save-or-delete-provisioned-dashboard-form": {
|
||||
"dashboard-comment-placeholder-describe-changes-optional": "Add a note to describe your changes (optional)",
|
||||
"description-branch-name-in-git-hub": "Branch name in GitHub",
|
||||
"description-inside-repository": "File path inside the repository (.json or .yaml)",
|
||||
"label-branch": "Branch",
|
||||
"label-comment": "Comment",
|
||||
"label-path": "Path",
|
||||
"label-workflow": "Workflow"
|
||||
},
|
||||
"save-provisioned-dashboard-form": {
|
||||
"api-error": "Error saving dashboard",
|
||||
"api-success": "Dashboard changes saved",
|
||||
"cancel": "Cancel",
|
||||
"cannot-be-saved": "This dashboard cannot be saved from the Grafana UI because it has been provisioned from another source. Copy the JSON or save it to a file below, then you can update your dashboard in the provisioning source.",
|
||||
"copy-json-message": "If you have direct access to the target, copy the JSON and paste it there.",
|
||||
"copy-json-to-clipboard": "Copy JSON to clipboard",
|
||||
"dashboard-comment-placeholder-describe-changes-optional": "Add a note to describe your changes (optional)",
|
||||
"description-branch-name-in-git-hub": "Branch name in GitHub",
|
||||
"description-inside-repository": "File path inside the repository (.json or .yaml)",
|
||||
"file-path": "<0>File path:</0> {{filePath}}",
|
||||
"label-branch": "Branch",
|
||||
"label-comment": "Comment",
|
||||
"label-description": "Description",
|
||||
"label-path": "Path",
|
||||
"label-target-folder": "Target folder",
|
||||
"label-title": "Title",
|
||||
"label-workflow": "Workflow",
|
||||
"save": "Save",
|
||||
"save-json-to-file": "Save JSON to file",
|
||||
"saving": "Saving...",
|
||||
|
|
Loading…
Reference in New Issue