@grafana/create-plugin 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.eslintrc +6 -0
  2. package/.github/workflows/create-release.yml +49 -0
  3. package/.github/workflows/npm-bump-version.yml +51 -0
  4. package/.github/workflows/test.yml +31 -0
  5. package/.prettierrc +10 -0
  6. package/CONTRIBUTING.md +42 -0
  7. package/README.md +143 -0
  8. package/dist/bin/run.js +17 -0
  9. package/dist/commands/generate.command.js +56 -0
  10. package/dist/commands/generate.plopfile.js +119 -0
  11. package/dist/commands/index.js +19 -0
  12. package/dist/commands/migrate.command.js +133 -0
  13. package/dist/commands/update.command.js +101 -0
  14. package/dist/constants.js +74 -0
  15. package/dist/utils/tests/utils.files.test.js +30 -0
  16. package/dist/utils/tests/utils.handlebars.test.js +26 -0
  17. package/dist/utils/tests/utils.npm.test.js +138 -0
  18. package/dist/utils/tests/utils.plugin.test.js +20 -0
  19. package/dist/utils/tests/utils.templates.test.js +32 -0
  20. package/dist/utils/utils.console.js +41 -0
  21. package/dist/utils/utils.files.js +61 -0
  22. package/dist/utils/utils.handlebars.js +73 -0
  23. package/dist/utils/utils.npm.js +162 -0
  24. package/dist/utils/utils.plugin.js +14 -0
  25. package/dist/utils/utils.templates.js +77 -0
  26. package/docs/example.mp4 +0 -0
  27. package/jest.config.js +20 -0
  28. package/package.json +69 -0
  29. package/src/bin/run.ts +15 -0
  30. package/src/commands/generate.command.ts +15 -0
  31. package/src/commands/generate.plopfile.ts +151 -0
  32. package/src/commands/index.ts +3 -0
  33. package/src/commands/migrate.command.ts +99 -0
  34. package/src/commands/update.command.ts +66 -0
  35. package/src/constants.ts +99 -0
  36. package/src/utils/tests/utils.files.test.ts +41 -0
  37. package/src/utils/tests/utils.handlebars.test.ts +35 -0
  38. package/src/utils/tests/utils.npm.test.ts +193 -0
  39. package/src/utils/tests/utils.plugin.test.ts +22 -0
  40. package/src/utils/tests/utils.templates.test.ts +44 -0
  41. package/src/utils/utils.console.ts +41 -0
  42. package/src/utils/utils.files.ts +65 -0
  43. package/src/utils/utils.handlebars.ts +47 -0
  44. package/src/utils/utils.npm.ts +189 -0
  45. package/src/utils/utils.plugin.ts +9 -0
  46. package/src/utils/utils.templates.ts +76 -0
  47. package/templates/_partials/backend-getting-started.md +20 -0
  48. package/templates/_partials/frontend-getting-started.md +59 -0
  49. package/templates/app/README.md +20 -0
  50. package/templates/app/src/components/App/App.test.tsx +32 -0
  51. package/templates/app/src/components/App/App.tsx +8 -0
  52. package/templates/app/src/components/App/index.tsx +1 -0
  53. package/templates/app/src/components/AppConfig/AppConfig.test.tsx +51 -0
  54. package/templates/app/src/components/AppConfig/AppConfig.tsx +92 -0
  55. package/templates/app/src/components/AppConfig/index.tsx +1 -0
  56. package/templates/app/src/module.ts +12 -0
  57. package/templates/app/src/plugin.json +33 -0
  58. package/templates/backend/Magefile.go +12 -0
  59. package/templates/backend/go.mod +5 -0
  60. package/templates/backend/go.sum +568 -0
  61. package/templates/backend/pkg/main.go +24 -0
  62. package/templates/backend/pkg/plugin/datasource.go +111 -0
  63. package/templates/backend/pkg/plugin/datasource_test.go +28 -0
  64. package/templates/common/.config/.eslintrc +12 -0
  65. package/templates/common/.config/.prettierrc.js +16 -0
  66. package/templates/common/.config/Dockerfile +15 -0
  67. package/templates/common/.config/README.md +115 -0
  68. package/templates/common/.config/jest-setup.js +24 -0
  69. package/templates/common/.config/jest.config.js +31 -0
  70. package/templates/common/.config/tsconfig.json +17 -0
  71. package/templates/common/.config/types/custom.d.ts +37 -0
  72. package/templates/common/.config/webpack/constants.ts +3 -0
  73. package/templates/common/.config/webpack/tsconfig.webpack.json +9 -0
  74. package/templates/common/.config/webpack/utils.ts +17 -0
  75. package/templates/common/.config/webpack/webpack.config.ts +187 -0
  76. package/templates/common/.eslintrc +3 -0
  77. package/templates/common/.nvmrc +1 -0
  78. package/templates/common/.prettierrc.js +4 -0
  79. package/templates/common/CHANGELOG.md +5 -0
  80. package/templates/common/LICENSE +201 -0
  81. package/templates/common/cypress/integration/01-smoke.spec.ts +10 -0
  82. package/templates/common/docker-compose.yaml +14 -0
  83. package/templates/common/jest-setup.js +2 -0
  84. package/templates/common/jest.config.js +4 -0
  85. package/templates/common/package.json +67 -0
  86. package/templates/common/src/README.md +5 -0
  87. package/templates/common/src/img/logo.svg +1 -0
  88. package/templates/common/tsconfig.json +3 -0
  89. package/templates/datasource/README.md +20 -0
  90. package/templates/datasource/src/components/ConfigEditor.tsx +83 -0
  91. package/templates/datasource/src/components/QueryEditor.tsx +50 -0
  92. package/templates/datasource/src/datasource.ts +46 -0
  93. package/templates/datasource/src/module.ts +9 -0
  94. package/templates/datasource/src/plugin.json +37 -0
  95. package/templates/datasource/src/types.ts +24 -0
  96. package/templates/github/ci/.github/workflows/ci.yml +76 -0
  97. package/templates/github/ci/.github/workflows/release.yml +165 -0
  98. package/templates/github/is-compatible/.github/workflows/is-compatible.yml +17 -0
  99. package/templates/panel/README.md +22 -0
  100. package/templates/panel/src/components/SimplePanel.tsx +62 -0
  101. package/templates/panel/src/module.test.ts +6 -0
  102. package/templates/panel/src/module.ts +40 -0
  103. package/templates/panel/src/plugin.json +33 -0
  104. package/templates/panel/src/types.ts +7 -0
  105. package/tsconfig.json +17 -0
@@ -0,0 +1,65 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import { TEMPLATE_PATHS } from '../constants';
4
+
5
+ // Removes common template files from the list in case they have a plugin-specific override
6
+ export function filterOutCommonFiles(files: string[], pluginType: string) {
7
+ const isFileCommonAndOverriden = (file: string) =>
8
+ file.includes(TEMPLATE_PATHS.common) &&
9
+ files.includes(file.replace(TEMPLATE_PATHS.common, TEMPLATE_PATHS[pluginType]));
10
+
11
+ return files.filter((file) => (isFileCommonAndOverriden(file) ? false : true));
12
+ }
13
+
14
+ export function isFile(path: string) {
15
+ try {
16
+ return fs.lstatSync(path).isFile();
17
+ } catch (e) {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ export function readJsonFile(filename: string) {
23
+ if (!isFile(filename)) {
24
+ throw new Error(
25
+ `There is no "${path.basename(
26
+ filename
27
+ )}" file found at "${filename}". Make sure you run this command from a plugins root directory.`
28
+ );
29
+ }
30
+
31
+ try {
32
+ return JSON.parse(fs.readFileSync(filename).toString());
33
+ } catch (error: any) {
34
+ error.message = `Cannot parse the "${path.basename(filename)}" file at ${filename}.`;
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ export function getOnlyExistingInCwd(files: string[]) {
40
+ return files.filter((file) => fs.existsSync(path.join(process.cwd(), file)));
41
+ }
42
+
43
+ export function getOnlyNotExistingInCwd(files: string[]) {
44
+ return files.filter((file) => !fs.existsSync(path.join(process.cwd(), file)));
45
+ }
46
+
47
+ export function removeFilesInCwd(files: string[]) {
48
+ for (const file of files) {
49
+ fs.rmSync(path.join(process.cwd(), file), { recursive: true, force: true });
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Returns TRUE if the file is starting with any of the provided string filters.
55
+ *
56
+ * @param file - Path of the file
57
+ * @param filter - A single or array of strings to filter the files by - only returns TRUE if the file starts with any of the provided filter(s).
58
+ */
59
+ export function isFileStartingWith(file: string, filter: string | string[]) {
60
+ if (Array.isArray(filter)) {
61
+ return filter.some((f) => file.startsWith(f));
62
+ }
63
+
64
+ return file.startsWith(filter);
65
+ }
@@ -0,0 +1,47 @@
1
+ import Handlebars, { HelperOptions } from 'handlebars';
2
+ import * as changeCase from 'change-case';
3
+ import titleCase from 'title-case';
4
+ import upperCase from 'upper-case';
5
+ import lowerCase from 'lower-case';
6
+ import { PLUGIN_TYPES } from '../constants';
7
+
8
+ // Why? The `{#if}` expression in Handlebars unfortunately only accepts a boolean, which makes it hard to compare values in templates.
9
+ export const ifEq = (a: any, b: any, options: HelperOptions) => {
10
+ return a === b ? options.fn(this) : options.inverse(this);
11
+ };
12
+
13
+ export const normalizeId = (pluginName: string, orgName: string, type: PLUGIN_TYPES) => {
14
+ const re = new RegExp(`-?${type}$`, 'i');
15
+ const newPluginName = pluginName.replace(re, '');
16
+ return changeCase.paramCase(orgName) + '-' + changeCase.paramCase(newPluginName) + `-${type}`;
17
+ };
18
+
19
+ // Needed when we are rendering the templates outside of the context of Plop but still would like to support the same helpers
20
+ export function registerHandlebarsHelpers() {
21
+ const helpers = {
22
+ camelCase: changeCase.camelCase,
23
+ snakeCase: changeCase.snakeCase,
24
+ dotCase: changeCase.dotCase,
25
+ pathCase: changeCase.pathCase,
26
+ lowerCase: lowerCase,
27
+ upperCase: upperCase,
28
+ sentenceCase: changeCase.sentenceCase,
29
+ constantCase: changeCase.constantCase,
30
+ titleCase: titleCase,
31
+ dashCase: changeCase.paramCase,
32
+ kabobCase: changeCase.paramCase,
33
+ kebabCase: changeCase.paramCase,
34
+ properCase: changeCase.pascalCase,
35
+ pascalCase: changeCase.pascalCase,
36
+ if_eq: ifEq,
37
+ };
38
+
39
+ Object.keys(helpers).forEach((helperName) =>
40
+ Handlebars.registerHelper(helperName, helpers[helperName as keyof typeof helpers])
41
+ );
42
+ }
43
+
44
+ export function renderHandlebarsTemplate(template: string, data?: any) {
45
+ registerHandlebarsHelpers();
46
+ return Handlebars.compile(template)(data);
47
+ }
@@ -0,0 +1,189 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import semver from 'semver';
4
+ import { readJsonFile } from './utils.files';
5
+ import { renderTemplateFromFile, getTemplateData } from './utils.templates';
6
+ import { TEMPLATE_PATHS } from '../constants';
7
+
8
+ type UpdateSummary = Record<string, { prev: string | null; next: string | null }>;
9
+
10
+ type UpdateOptions = {
11
+ onlyOutdated?: Boolean;
12
+ };
13
+
14
+ type PackageJson = {
15
+ scripts: Record<string, string>;
16
+ dependencies: Record<string, string>;
17
+ devDependencies: Record<string, string>;
18
+ } & Record<string, any>;
19
+
20
+ export function getPackageJson(): PackageJson {
21
+ return readJsonFile(path.join(process.cwd(), 'package.json'));
22
+ }
23
+
24
+ // Returns with a package.json that is generated based on the latest templates
25
+ export function getLatestPackageJson(): PackageJson {
26
+ const packageJsonPath = path.join(TEMPLATE_PATHS.common, 'package.json');
27
+ const data = getTemplateData();
28
+
29
+ return JSON.parse(renderTemplateFromFile(packageJsonPath, data));
30
+ }
31
+
32
+ export function writePackageJson(json: PackageJson) {
33
+ return fs.writeFileSync(path.join(process.cwd(), 'package.json'), `${JSON.stringify(json, null, 2)}\n`);
34
+ }
35
+
36
+ export function getNpmDependencyUpdatesAsText(dependencyUpdates: UpdateSummary) {
37
+ return Object.entries(dependencyUpdates)
38
+ .map(([packageName, { prev, next }]) => {
39
+ // New package
40
+ if (!prev) {
41
+ return `\`${packageName}\` - \`${next}\` (new)`;
42
+ }
43
+
44
+ // Updated package
45
+ return `\`${packageName}\` - \`${prev}\` -> \`${next}\``;
46
+ })
47
+ .join('\n ');
48
+ }
49
+
50
+ export function getPackageJsonUpdatesAsText(options: UpdateOptions = {}) {
51
+ let asText = '';
52
+ const { dependencyUpdates, devDependencyUpdates } = getPackageJsonUpdates(options);
53
+
54
+ if (Object.keys(dependencyUpdates).length > 0) {
55
+ asText += `\n\n **Dependencies**\n ${getNpmDependencyUpdatesAsText(dependencyUpdates)}`;
56
+ }
57
+
58
+ if (Object.keys(devDependencyUpdates).length > 0) {
59
+ asText += `\n\n **Dev Dependencies**\n ${getNpmDependencyUpdatesAsText(devDependencyUpdates)}`;
60
+ }
61
+
62
+ return asText;
63
+ }
64
+
65
+ export function hasNpmDependenciesToUpdate(options: UpdateOptions = {}) {
66
+ const { dependencyUpdates, devDependencyUpdates } = getPackageJsonUpdates(options);
67
+
68
+ return Object.keys(dependencyUpdates).length > 0 || Object.keys(devDependencyUpdates).length > 0;
69
+ }
70
+
71
+ export function updatePackageJson(options: UpdateOptions = {}) {
72
+ const packageJson = getPackageJson();
73
+ const { dependencyUpdates, devDependencyUpdates } = getPackageJsonUpdates(options);
74
+
75
+ packageJson.dependencies = updateNpmDependencies(packageJson.dependencies, dependencyUpdates);
76
+ packageJson.devDependencies = updateNpmDependencies(packageJson.devDependencies, devDependencyUpdates);
77
+ writePackageJson(packageJson);
78
+ }
79
+
80
+ export function updateNpmDependencies(
81
+ dependencies: Record<string, string>,
82
+ updateSummary: UpdateSummary
83
+ ): Record<string, string> {
84
+ const updatedDependencies: Record<string, string> = { ...dependencies };
85
+
86
+ for (const [packageName, summary] of Object.entries(updateSummary)) {
87
+ updatedDependencies[packageName] = summary.next;
88
+ }
89
+
90
+ return updatedDependencies;
91
+ }
92
+
93
+ export function getPackageJsonUpdates(options: UpdateOptions = {}) {
94
+ const packageJson = getPackageJson();
95
+ const newPackageJson = getLatestPackageJson();
96
+ const dependencies = packageJson.dependencies || {};
97
+ const devDependencies = packageJson.devDependencies || {};
98
+ const newDependencies = newPackageJson.dependencies || {};
99
+ const newDevDependencies = newPackageJson.devDependencies || {};
100
+ const dependencyUpdates = getUpdatableNpmDependencies(dependencies, newDependencies, options);
101
+ const devDependencyUpdates = getUpdatableNpmDependencies(devDependencies, newDevDependencies, options);
102
+
103
+ return {
104
+ dependencyUpdates,
105
+ devDependencyUpdates,
106
+ };
107
+ }
108
+
109
+ export function getUpdatableNpmDependencies(
110
+ prevDeps: Record<string, string>,
111
+ nextDeps: Record<string, string>,
112
+ options: UpdateOptions = {}
113
+ ) {
114
+ const updateSummary: UpdateSummary = {};
115
+
116
+ // Dependencies
117
+ for (const [packageName, newSemverRange] of Object.entries(nextDeps)) {
118
+ const currentSemverRange = prevDeps[packageName];
119
+
120
+ // New dependency
121
+ if (!currentSemverRange) {
122
+ updateSummary[packageName] = { prev: null, next: newSemverRange };
123
+ continue;
124
+ }
125
+
126
+ // No changes
127
+ if (newSemverRange === currentSemverRange) {
128
+ continue;
129
+ }
130
+
131
+ // Invalid semver range (e.g. when using release channels like "latest")
132
+ if (!semver.validRange(newSemverRange) || !semver.validRange(currentSemverRange)) {
133
+ updateSummary[packageName] = { prev: currentSemverRange, next: newSemverRange };
134
+ continue;
135
+ }
136
+
137
+ const newSemverVersion = semver.minVersion(newSemverRange);
138
+ const currentSemverVersion = semver.minVersion(currentSemverRange);
139
+
140
+ // Invalid semver version / range
141
+ if (!newSemverVersion || !semver.valid(newSemverVersion)) {
142
+ console.warn(`Skipping: invalid new semver version "${newSemverRange}" for "${packageName}"`);
143
+ continue;
144
+ }
145
+ if (!currentSemverVersion || !semver.valid(currentSemverVersion)) {
146
+ console.warn(`Skipping: invalid current semver version "${currentSemverRange}" for "${packageName}"`);
147
+ continue;
148
+ }
149
+
150
+ // Update dependencies
151
+ if (!options.onlyOutdated || semver.gte(newSemverVersion, currentSemverVersion)) {
152
+ updateSummary[packageName] = { prev: currentSemverRange, next: newSemverRange };
153
+ continue;
154
+ }
155
+ }
156
+
157
+ return updateSummary;
158
+ }
159
+
160
+ export function getRemovableNpmDependencies(packageNames: string[]) {
161
+ const { dependencies = {}, devDependencies = {} } = getPackageJson();
162
+
163
+ return packageNames.filter((packageName) => dependencies[packageName] || devDependencies[packageName]);
164
+ }
165
+
166
+ export function removeNpmDependencies(packageNames: string[], { devOnly = false } = {}) {
167
+ const packageJson = getPackageJson();
168
+
169
+ for (const packageName of packageNames) {
170
+ if (!devOnly) {
171
+ delete packageJson.dependencies[packageName];
172
+ }
173
+ delete packageJson.devDependencies[packageName];
174
+ }
175
+
176
+ writePackageJson(packageJson);
177
+ }
178
+
179
+ export function updateNpmScripts() {
180
+ const packageJson = getPackageJson();
181
+ const latestPackageJson = getLatestPackageJson();
182
+
183
+ packageJson.scripts = {
184
+ ...packageJson.scripts,
185
+ ...latestPackageJson.scripts,
186
+ };
187
+
188
+ writePackageJson(packageJson);
189
+ }
@@ -0,0 +1,9 @@
1
+ import path from 'path';
2
+ import { readJsonFile } from './utils.files';
3
+
4
+ export function getPluginJson(srcDir?: string) {
5
+ const srcPath = srcDir || path.join(process.cwd(), 'src');
6
+ const pluginJsonPath = path.join(srcPath, 'plugin.json');
7
+
8
+ return readJsonFile(pluginJsonPath);
9
+ }
@@ -0,0 +1,76 @@
1
+ import glob from 'glob';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import mkdirp from 'mkdirp';
5
+ import { filterOutCommonFiles, isFile, isFileStartingWith } from './utils.files';
6
+ import { renderHandlebarsTemplate } from './utils.handlebars';
7
+ import { getPluginJson } from './utils.plugin';
8
+ import { TEMPLATE_PATHS, EXPORT_PATH_PREFIX, EXTRA_TEMPLATE_VARIABLES } from '../constants';
9
+
10
+ /**
11
+ *
12
+ * @param pluginType - The type of the plugin to get template files for (plugin-specific templates override the common ones)
13
+ * @param filter - (Optional) A single or array of strings to filter the files by - only the files that are starting with the filter string(s) are going to be returned.
14
+ */
15
+ export function getTemplateFiles(pluginType: string, filter?: string | string[]): string[] {
16
+ const commonFiles = glob.sync(`${TEMPLATE_PATHS.common}/**`, { dot: true });
17
+ const pluginTypeSpecificFiles = glob.sync(`${TEMPLATE_PATHS[pluginType]}/**`, { dot: true });
18
+ const templateFiles = filterOutCommonFiles([...commonFiles, ...pluginTypeSpecificFiles], pluginType);
19
+
20
+ if (filter) {
21
+ return templateFiles.filter((file) => {
22
+ const projectRelativePath = getProjectRelativeTemplatePath(file, pluginType);
23
+
24
+ return isFileStartingWith(projectRelativePath, filter);
25
+ });
26
+ }
27
+
28
+ return templateFiles;
29
+ }
30
+
31
+ /**
32
+ * Returns the path of the template file that is relative to the root of the scaffolded project.
33
+ *
34
+ * @param file - The absolute path of the template file
35
+ * @param pluginType - The type of the plugin
36
+ */
37
+ export function getProjectRelativeTemplatePath(file: string, pluginType: string) {
38
+ return file.replace(TEMPLATE_PATHS.common, '').replace(TEMPLATE_PATHS[pluginType], '').replace(/^\/+/, '');
39
+ }
40
+
41
+ export function compileTemplateFiles(filter?: string[], data?: any) {
42
+ const { type } = getPluginJson();
43
+
44
+ getTemplateFiles(type, filter).forEach((file) => compileSingleTemplateFile(type, file, data));
45
+ }
46
+
47
+ export function compileSingleTemplateFile(pluginType: string, templateFile: string, data?: any) {
48
+ if (!isFile(templateFile)) {
49
+ return;
50
+ }
51
+
52
+ const rendered = renderTemplateFromFile(templateFile, data);
53
+ const relativeExportPath = templateFile.replace(TEMPLATE_PATHS.common, '').replace(TEMPLATE_PATHS[pluginType], '');
54
+ const exportPath = path.join(EXPORT_PATH_PREFIX, relativeExportPath);
55
+
56
+ mkdirp.sync(path.dirname(exportPath));
57
+ fs.writeFileSync(exportPath, rendered);
58
+ }
59
+
60
+ export function renderTemplateFromFile(templateFile: string, data?: any) {
61
+ return renderHandlebarsTemplate(fs.readFileSync(templateFile).toString(), data);
62
+ }
63
+
64
+ export function getTemplateData() {
65
+ const pluginJson = getPluginJson();
66
+
67
+ return {
68
+ ...EXTRA_TEMPLATE_VARIABLES,
69
+ pluginId: pluginJson.id,
70
+ pluginName: pluginJson.name,
71
+ pluginDescription: pluginJson.info?.description,
72
+ hasBackend: Boolean(pluginJson.backend),
73
+ orgName: pluginJson.info?.author?.name,
74
+ pluginType: pluginJson.type,
75
+ };
76
+ }
@@ -0,0 +1,20 @@
1
+ ### Backend
2
+
3
+ 1. Update [Grafana plugin SDK for Go](https://grafana.com/docs/grafana/latest/developers/plugins/backend/grafana-plugin-sdk-for-go/) dependency to the latest minor version:
4
+
5
+ ```bash
6
+ go get -u github.com/grafana/grafana-plugin-sdk-go
7
+ go mod tidy
8
+ ```
9
+
10
+ 2. Build backend plugin binaries for Linux, Windows and Darwin:
11
+
12
+ ```bash
13
+ mage -v
14
+ ```
15
+
16
+ 3. List all available Mage targets for additional commands:
17
+
18
+ ```bash
19
+ mage -l
20
+ ```
@@ -0,0 +1,59 @@
1
+ ### Frontend
2
+
3
+ 1. Install dependencies
4
+
5
+ ```bash
6
+ yarn install
7
+ ```
8
+
9
+ 2. Build plugin in development mode or run in watch mode
10
+
11
+ ```bash
12
+ yarn dev
13
+
14
+ # or
15
+
16
+ yarn watch
17
+ ```
18
+
19
+ 3. Build plugin in production mode
20
+
21
+ ```bash
22
+ yarn build
23
+ ```
24
+
25
+ 4. Run the tests (using Jest)
26
+
27
+ ```bash
28
+ # Runs the tests and watches for changes
29
+ yarn test
30
+
31
+ # Exists after running all the tests
32
+ yarn lint:ci
33
+ ```
34
+
35
+ 5. Spin up a Grafana instance and run the plugin inside it (using Docker)
36
+
37
+ ```bash
38
+ yarn server
39
+ ```
40
+
41
+ 6. Run the E2E tests (using Cypress)
42
+
43
+ ```bash
44
+ # Spin up a Grafana instance first that we tests against
45
+ yarn server
46
+
47
+ # Start the tests
48
+ yarn e2e
49
+ ```
50
+
51
+ 7. Run the linter
52
+
53
+ ```bash
54
+ yarn lint
55
+
56
+ # or
57
+
58
+ yarn lint:fix
59
+ ```
@@ -0,0 +1,20 @@
1
+ # Grafana app plugin template
2
+
3
+ This template is a starting point for building an app plugin for Grafana.
4
+
5
+ ## What are Grafana app plugins?
6
+
7
+ App plugins can let you create a custom out-of-the-box monitoring experience by custom pages, nested datasources and panel plugins.
8
+
9
+ ## Getting started
10
+
11
+ -- INSERT FRONTEND GETTING STARTED --
12
+ {{#if hasBackend}}-- INSERT BACKEND GETTING STARTED --{{/if}}
13
+
14
+ ## Learn more
15
+
16
+ Below you can find source code for existing app plugins and other related documentation.
17
+
18
+ - [Basic app plugin example](https://github.com/grafana/grafana-plugin-examples/tree/master/examples/app-basic#readme)
19
+ - [Plugin.json documentation](https://grafana.com/docs/grafana/latest/developers/plugins/metadata/)
20
+ - [How to sign a plugin?](https://grafana.com/docs/grafana/latest/developers/plugins/sign-a-plugin/)
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { AppRootProps, PluginType } from '@grafana/data';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { App } from './App';
5
+
6
+ describe('Components/App', () => {
7
+ let props: AppRootProps;
8
+
9
+ beforeEach(() => {
10
+ jest.resetAllMocks();
11
+
12
+ props = {
13
+ basename: 'a/sample-app',
14
+ meta: {
15
+ id: 'sample-app',
16
+ name: 'Sample App',
17
+ type: PluginType.app,
18
+ enabled: true,
19
+ jsonData: {},
20
+ },
21
+ query: {},
22
+ path: '',
23
+ onNavChanged: jest.fn(),
24
+ } as unknown as AppRootProps;
25
+ });
26
+
27
+ test('renders without an error"', () => {
28
+ render(<App {...props} />);
29
+
30
+ expect(screen.queryByText(/Hello Grafana!/i)).toBeInTheDocument();
31
+ });
32
+ });
@@ -0,0 +1,8 @@
1
+ import * as React from 'react';
2
+ import { AppRootProps } from '@grafana/data';
3
+
4
+ export class App extends React.PureComponent<AppRootProps> {
5
+ render() {
6
+ return <div className="page-container">Hello Grafana!</div>;
7
+ }
8
+ }
@@ -0,0 +1 @@
1
+ export * from './App';
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { PluginType } from '@grafana/data';
4
+ import { AppConfig, AppConfigProps } from './AppConfig';
5
+
6
+ describe('Components/AppConfig', () => {
7
+ let props: AppConfigProps;
8
+
9
+ beforeEach(() => {
10
+ jest.resetAllMocks();
11
+
12
+ props = {
13
+ plugin: {
14
+ meta: {
15
+ id: 'sample-app',
16
+ name: 'Sample App',
17
+ type: PluginType.app,
18
+ enabled: true,
19
+ jsonData: {},
20
+ },
21
+ },
22
+ query: {},
23
+ } as unknown as AppConfigProps;
24
+ });
25
+
26
+ test('renders without an error"', () => {
27
+ render(<AppConfig plugin={props.plugin} query={props.query} />);
28
+
29
+ expect(screen.queryByText(/Enable \/ Disable/i)).toBeInTheDocument();
30
+ });
31
+
32
+ test('renders an "Enable" button if the plugin is disabled', () => {
33
+ const plugin = { meta: { ...props.plugin.meta, enabled: false } };
34
+
35
+ // @ts-ignore - We don't need to provide `addConfigPage()` and `setChannelSupport()` for these tests
36
+ render(<AppConfig plugin={plugin} query={props.query} />);
37
+
38
+ expect(screen.queryByText(/The plugin is currently not enabled./i)).toBeInTheDocument();
39
+ expect(screen.queryByText(/The plugin is currently enabled./i)).not.toBeInTheDocument();
40
+ });
41
+
42
+ test('renders a "Disable" button if the plugin is enabled', () => {
43
+ const plugin = { meta: { ...props.plugin.meta, enabled: true } };
44
+
45
+ // @ts-ignore - We don't need to provide `addConfigPage()` and `setChannelSupport()` for these tests
46
+ render(<AppConfig plugin={plugin} query={props.query} />);
47
+
48
+ expect(screen.queryByText(/The plugin is currently enabled./i)).toBeInTheDocument();
49
+ expect(screen.queryByText(/The plugin is currently not enabled./i)).not.toBeInTheDocument();
50
+ });
51
+ });
@@ -0,0 +1,92 @@
1
+ import React from 'react';
2
+ import { Button, Legend, useStyles2 } from '@grafana/ui';
3
+ import { PluginConfigPageProps, AppPluginMeta, PluginMeta, GrafanaTheme2 } from '@grafana/data';
4
+ import { getBackendSrv } from '@grafana/runtime';
5
+ import { css } from '@emotion/css';
6
+ import { lastValueFrom } from 'rxjs';
7
+
8
+ export type AppPluginSettings = {};
9
+
10
+ export interface AppConfigProps extends PluginConfigPageProps<AppPluginMeta<AppPluginSettings>> {}
11
+
12
+ export const AppConfig = ({ plugin }: AppConfigProps) => {
13
+ const s = useStyles2(getStyles);
14
+ const { enabled, jsonData } = plugin.meta;
15
+
16
+ return (
17
+ <div className="gf-form-group">
18
+ <div>
19
+ {/* Enable the plugin */}
20
+ <Legend>Enable / Disable</Legend>
21
+ {!enabled && (
22
+ <>
23
+ <div className={s.colorWeak}>The plugin is currently not enabled.</div>
24
+ <Button
25
+ className={s.marginTop}
26
+ variant="primary"
27
+ onClick={() =>
28
+ updatePluginAndReload(plugin.meta.id, {
29
+ enabled: true,
30
+ pinned: true,
31
+ jsonData,
32
+ })
33
+ }
34
+ >
35
+ Enable plugin
36
+ </Button>
37
+ </>
38
+ )}
39
+
40
+ {/* Disable the plugin */}
41
+ {enabled && (
42
+ <>
43
+ <div className={s.colorWeak}>The plugin is currently enabled.</div>
44
+ <Button
45
+ className={s.marginTop}
46
+ variant="destructive"
47
+ onClick={() =>
48
+ updatePluginAndReload(plugin.meta.id, {
49
+ enabled: false,
50
+ pinned: false,
51
+ jsonData,
52
+ })
53
+ }
54
+ >
55
+ Disable plugin
56
+ </Button>
57
+ </>
58
+ )}
59
+ </div>
60
+ </div>
61
+ );
62
+ };
63
+
64
+ const getStyles = (theme: GrafanaTheme2) => ({
65
+ colorWeak: css`
66
+ color: ${theme.colors.text.secondary};
67
+ `,
68
+ marginTop: css`
69
+ margin-top: ${theme.spacing(3)};
70
+ `,
71
+ });
72
+
73
+ const updatePluginAndReload = async (pluginId: string, data: Partial<PluginMeta>) => {
74
+ try {
75
+ await updatePlugin(pluginId, data);
76
+
77
+ // Reloading the page as the changes made here wouldn't be propagated to the actual plugin otherwise.
78
+ // This is not ideal, however unfortunately currently there is no supported way for updating the plugin state.
79
+ window.location.reload();
80
+ } catch (e) {
81
+ console.error('Error while updating the plugin', e);
82
+ }
83
+ };
84
+
85
+ export const updatePlugin = async (pluginId: string, data: Partial<PluginMeta>) => {
86
+ const response = getBackendSrv().fetch({
87
+ url: `/api/plugins/${pluginId}/settings`,
88
+ method: 'POST',
89
+ data,
90
+ });
91
+ return lastValueFrom(response);
92
+ };
@@ -0,0 +1 @@
1
+ export * from './AppConfig';