diff --git a/package.json b/package.json index 99e693fe..6b06b5d0 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "@grafana/runtime": "12.4.0", "@grafana/schema": "12.4.0", "@grafana/ui": "12.4.0", + "@json-render/core": "^0.14.1", + "@json-render/react": "^0.14.1", "react": "18.3.1", "react-dom": "18.3.1", "tslib": "2.8.1" diff --git a/src/jsonrender/catalog.ts b/src/jsonrender/catalog.ts new file mode 100644 index 00000000..0c2abac2 --- /dev/null +++ b/src/jsonrender/catalog.ts @@ -0,0 +1,101 @@ +import { defineCatalog } from '@json-render/core'; +import { schema } from '@json-render/react'; +import { z } from 'zod'; + +/** + * Component catalog for the config editor. + * + * Each component mirrors a Grafana UI primitive and exposes a Zod-typed + * props contract so that json-render can validate specs at build-time and + * generate AI prompts. + */ +export const configEditorCatalog = defineCatalog(schema, { + components: { + /** Root vertical stack container. */ + Stack: { + props: z.object({}), + description: 'A vertical stack that renders its children sequentially', + }, + /** Titled collapsible section (maps to Grafana ConfigSection). */ + Section: { + props: z.object({ + title: z.string(), + isCollapsible: z.boolean().optional(), + }), + description: 'A titled section for grouping related form fields', + }, + /** Radio button group for selecting one of several options. */ + RadioGroup: { + props: z.object({ + label: z.string().optional(), + value: z.string().optional(), + options: z.array(z.object({ label: z.string(), value: z.string() })), + }), + description: 'A radio button group for selecting one of several predefined options', + }, + /** Standard text input wrapped in a form field. */ + TextInput: { + props: z.object({ + label: z.string(), + value: z.string().optional(), + placeholder: z.string().optional(), + width: z.number().optional(), + }), + description: 'A text input field for entering a string value', + }, + /** Secret text input that hides its value once configured. */ + SecretInput: { + props: z.object({ + label: z.string(), + fieldName: z.string().describe('The key in secureJsonData for this field'), + value: z.string().optional(), + placeholder: z.string().optional(), + isConfigured: z.boolean().optional(), + width: z.number().optional(), + }), + description: 'A secret input field that masks its value', + }, + /** Multi-line secret text area (e.g. PEM private keys). */ + SecretTextArea: { + props: z.object({ + label: z.string(), + fieldName: z.string().describe('The key in secureJsonData for this field'), + placeholder: z.string().optional(), + isConfigured: z.boolean().optional(), + cols: z.number().optional(), + rows: z.number().optional(), + }), + description: 'A secret multi-line text area for entering sensitive multi-line content', + }, + /** Horizontal divider. */ + Divider: { + props: z.object({}), + description: 'A horizontal visual divider', + }, + /** Data-source description header. */ + Description: { + props: z.object({ + dataSourceName: z.string(), + docsLink: z.string(), + }), + description: 'Data source description and documentation link header', + }, + }, + actions: { + /** Fired when a non-secure jsonData field changes. */ + updateJsonData: { + params: z.object({ field: z.string(), value: z.string() }), + description: 'Update a field in jsonData', + }, + /** Fired when a secure field value changes. */ + updateSecureJsonData: { + params: z.object({ field: z.string(), value: z.string() }), + description: 'Update a field in secureJsonData', + }, + /** Reset a previously-configured secure field. */ + resetSecureField: { + params: z.object({ field: z.string() }), + description: 'Reset a configured secure field so the user can re-enter it', + }, + }, +}); diff --git a/src/jsonrender/configEditorSpec.ts b/src/jsonrender/configEditorSpec.ts new file mode 100644 index 00000000..a68f6a7f --- /dev/null +++ b/src/jsonrender/configEditorSpec.ts @@ -0,0 +1,314 @@ +import type { Spec } from '@json-render/react'; +import configSchema from '../../pkg/schema/config.json'; + +/* + * --------------------------------------------------------------------------- + * Config-editor UI spec – derived from the config JSON schema + * --------------------------------------------------------------------------- + * + * The generated JSON schema (pkg/schema/config.json) describes the DATA + * shape as a set of discriminated unions: + * + * jsonData = allOf( + * anyOf(basic-plan | enterprise-cloud | enterprise-server), ← plan + * anyOf(personal-access-token | github-app) ← auth + * ) + * + * secureJsonData = anyOf( + * { accessToken } ← PAT + * | { privateKey } ← GitHub App + * ) + * + * The function below walks the schema to extract field names, descriptions, + * enum constants and discriminator values so that the UI spec is driven by + * the schema rather than hard-coded labels. + * --------------------------------------------------------------------------- + */ + +// --------------------------------------------------------------------------- +// Helpers – extract metadata from the JSON schema +// --------------------------------------------------------------------------- + +type JSONSchemaObject = Record; +type JSONSchemaProperty = { type?: string; const?: string; description?: string; not?: unknown }; + +/** Safely narrow an `unknown` to a JSON-schema-like object. */ +function isObj(v: unknown): v is JSONSchemaObject { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** Return the `properties` record of a JSON-schema object node. */ +function propsOf(node: JSONSchemaObject): Record { + const p = node['properties']; + return isObj(p) ? (p as Record) : {}; +} + +/** + * Collect every concrete variant from the schema's anyOf/allOf structure + * for a given top-level key (e.g. "jsonData" or "secureJsonData"). + */ +function collectVariants(topKey: 'jsonData' | 'secureJsonData'): JSONSchemaObject[] { + const top = (configSchema as JSONSchemaObject).properties; + if (!isObj(top)) { + return []; + } + const section = top[topKey]; + if (!isObj(section)) { + return []; + } + + const result: JSONSchemaObject[] = []; + + function walk(node: unknown) { + if (!isObj(node)) { + return; + } + if (Array.isArray(node['anyOf'])) { + (node['anyOf'] as unknown[]).forEach(walk); + } + if (Array.isArray(node['allOf'])) { + (node['allOf'] as unknown[]).forEach(walk); + } + // A leaf variant has `properties` + if (isObj(node['properties'])) { + result.push(node as JSONSchemaObject); + } + } + + walk(section); + return result; +} + +// --------------------------------------------------------------------------- +// Extract auth-type & plan-type options from the schema +// --------------------------------------------------------------------------- + +const jsonDataVariants = collectVariants('jsonData'); +const secureVariants = collectVariants('secureJsonData'); + +/** Return the authentication-type variants (discriminator: selectedAuthType). */ +function authVariants() { + return jsonDataVariants.filter((v) => 'selectedAuthType' in propsOf(v)); +} + +/** Return the plan/license variants (discriminator: githubPlan). */ +function planVariants() { + return jsonDataVariants.filter((v) => 'githubPlan' in propsOf(v)); +} + +/** + * Build radio-group options from an array of schema variants keyed by a + * discriminator property. + */ +function radioOptionsFromVariants( + variants: JSONSchemaObject[], + discriminator: string, + labelMap: Record +): Array<{ label: string; value: string }> { + return variants + .map((v) => { + const prop = propsOf(v)[discriminator]; + const constVal = prop?.const; + if (!constVal) { + return null; + } + return { label: labelMap[constVal] ?? constVal, value: constVal }; + }) + .filter(Boolean) as Array<{ label: string; value: string }>; +} + +// Human-readable labels keyed by schema const values +const AUTH_LABELS: Record = { + 'personal-access-token': 'Personal Access Token', + 'github-app': 'GitHub App', +}; + +const PLAN_LABELS: Record = { + 'github-basic': 'Free, Pro & Team', + 'github-enterprise-cloud': 'Enterprise Cloud', + 'github-enterprise-server': 'Enterprise Server', +}; + +// --------------------------------------------------------------------------- +// Build the json-render spec +// --------------------------------------------------------------------------- + +/** + * Build the complete json-render `Spec` for the config editor. + * + * State keys map 1-to-1 to jsonData / secureJsonData property names so + * that the bridge in `ConfigEditorJsonRender.tsx` can propagate changes + * without a mapping layer. + */ +export function buildConfigEditorSpec(state: { + selectedAuthType: string; + githubPlan: string; + githubUrl: string; + appId: string; + installationId: string; + accessTokenConfigured: boolean; + privateKeyConfigured: boolean; +}): Spec { + // Derive descriptions from schema for field labels / placeholders + const authVariantsArr = authVariants(); + const planVariantsArr = planVariants(); + + const ghAppVariant = authVariantsArr.find((v) => propsOf(v)['selectedAuthType']?.const === 'github-app'); + const enterpriseServerVariant = planVariantsArr.find( + (v) => propsOf(v)['githubPlan']?.const === 'github-enterprise-server' + ); + + const appIdDesc = ghAppVariant ? propsOf(ghAppVariant)['appId']?.description ?? 'App ID' : 'App ID'; + const installationIdDesc = ghAppVariant + ? propsOf(ghAppVariant)['installationId']?.description ?? 'Installation ID' + : 'Installation ID'; + const githubUrlDesc = enterpriseServerVariant + ? propsOf(enterpriseServerVariant)['githubUrl']?.description ?? 'GitHub Enterprise Server URL' + : 'GitHub Enterprise Server URL'; + + // Schema-derived descriptions for secure fields + const patSecureVariant = secureVariants.find((v) => { + const p = propsOf(v); + return p['accessToken'] && !p['accessToken'].not; + }); + const ghAppSecureVariant = secureVariants.find((v) => { + const p = propsOf(v); + return p['privateKey'] && !p['privateKey'].not; + }); + const accessTokenDesc = patSecureVariant + ? propsOf(patSecureVariant)['accessToken']?.description ?? 'Personal Access Token' + : 'Personal Access Token'; + const privateKeyDesc = ghAppSecureVariant + ? propsOf(ghAppSecureVariant)['privateKey']?.description ?? 'Private Key' + : 'Private Key'; + + const authOptions = radioOptionsFromVariants(authVariantsArr, 'selectedAuthType', AUTH_LABELS); + const planOptions = radioOptionsFromVariants(planVariantsArr, 'githubPlan', PLAN_LABELS); + + return { + root: 'root', + state: { + selectedAuthType: state.selectedAuthType, + githubPlan: state.githubPlan, + githubUrl: state.githubUrl, + appId: state.appId, + installationId: state.installationId, + accessTokenConfigured: state.accessTokenConfigured, + privateKeyConfigured: state.privateKeyConfigured, + }, + elements: { + root: { + type: 'Stack', + props: {}, + children: ['description', 'divider-1', 'auth-section', 'divider-2', 'connection-section'], + }, + + // -- Header -- + description: { + type: 'Description', + props: { + dataSourceName: 'GitHub', + docsLink: 'https://grafana.com/docs/plugins/grafana-github-datasource', + }, + children: [], + }, + + 'divider-1': { type: 'Divider', props: {}, children: [] }, + + // -- Authentication (driven by jsonData auth anyOf) -- + 'auth-section': { + type: 'Section', + props: { title: 'Authentication' }, + children: ['auth-type', 'pat-token', 'app-id', 'installation-id', 'private-key'], + }, + + 'auth-type': { + type: 'RadioGroup', + props: { + label: 'Authentication Type', + value: { $bindState: '/selectedAuthType' } as unknown as string, + options: authOptions, + }, + children: [], + }, + + // PAT fields – visible when selectedAuthType == "personal-access-token" + 'pat-token': { + type: 'SecretInput', + props: { + label: accessTokenDesc, + fieldName: 'accessToken', + placeholder: 'Personal Access Token', + isConfigured: { $state: '/accessTokenConfigured' } as unknown as boolean, + }, + visible: { $state: '/selectedAuthType', eq: 'personal-access-token' }, + children: [], + }, + + // GitHub App fields – visible when selectedAuthType == "github-app" + 'app-id': { + type: 'TextInput', + props: { + label: appIdDesc, + value: { $bindState: '/appId' } as unknown as string, + placeholder: 'App ID', + }, + visible: { $state: '/selectedAuthType', eq: 'github-app' }, + children: [], + }, + + 'installation-id': { + type: 'TextInput', + props: { + label: installationIdDesc, + value: { $bindState: '/installationId' } as unknown as string, + placeholder: 'Installation ID', + }, + visible: { $state: '/selectedAuthType', eq: 'github-app' }, + children: [], + }, + + 'private-key': { + type: 'SecretTextArea', + props: { + label: privateKeyDesc, + fieldName: 'privateKey', + placeholder: '-----BEGIN CERTIFICATE-----', + isConfigured: { $state: '/privateKeyConfigured' } as unknown as boolean, + }, + visible: { $state: '/selectedAuthType', eq: 'github-app' }, + children: [], + }, + + 'divider-2': { type: 'Divider', props: {}, children: [] }, + + // -- Connection / License (driven by jsonData plan anyOf) -- + 'connection-section': { + type: 'Section', + props: { title: 'Connection', isCollapsible: true }, + children: ['license-type', 'enterprise-url'], + }, + + 'license-type': { + type: 'RadioGroup', + props: { + label: 'GitHub License Type', + value: { $bindState: '/githubPlan' } as unknown as string, + options: planOptions, + }, + children: [], + }, + + 'enterprise-url': { + type: 'TextInput', + props: { + label: githubUrlDesc, + value: { $bindState: '/githubUrl' } as unknown as string, + placeholder: 'http(s)://HOSTNAME/', + }, + visible: { $state: '/githubPlan', eq: 'github-enterprise-server' }, + children: [], + }, + }, + }; +} diff --git a/src/jsonrender/registry.tsx b/src/jsonrender/registry.tsx new file mode 100644 index 00000000..9f7eac85 --- /dev/null +++ b/src/jsonrender/registry.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { defineRegistry, useBoundProp } from '@json-render/react'; +import { ConfigSection, DataSourceDescription } from '@grafana/plugin-ui'; +import { + Field, + Input, + Label, + RadioButtonGroup, + SecretInput as GrafanaSecretInput, + SecretTextArea as GrafanaSecretTextArea, +} from '@grafana/ui'; +import { Divider } from 'components/Divider'; +import { configEditorCatalog } from './catalog'; + +/** + * Create a json-render registry that maps catalog component names to + * concrete React implementations backed by Grafana UI primitives. + * + * The registry also wires up catalog actions so that field mutations + * can be forwarded to the Grafana DataSourcePluginOptionsEditor callback. + * + * @param onJsonDataChange called when a non-secure jsonData field changes + * @param onSecureJsonDataChange called when a secure field value changes + * @param onResetSecureField called when a configured secret needs resetting + */ +export function createConfigEditorRegistry( + onJsonDataChange: (field: string, value: string) => void, + onSecureJsonDataChange: (field: string, value: string) => void, + onResetSecureField: (field: string) => void +) { + return defineRegistry(configEditorCatalog, { + components: { + Stack: ({ children }) =>
{children}
, + + Section: ({ props, children }) => ( + + {children} + + ), + + RadioGroup: ({ props, bindings }) => { + const [value, setValue] = useBoundProp(props.value, bindings?.value); + return ( + <> + {props.label && } + { + setValue(v); + // Also propagate via action so external listeners can react + const fieldPath = bindings?.value; + if (fieldPath) { + onJsonDataChange(fieldPath.replace(/^\//, ''), v); + } + }} + /> + + ); + }, + + TextInput: ({ props, bindings }) => { + const [value, setValue] = useBoundProp(props.value, bindings?.value); + return ( + + { + const v = e.currentTarget.value; + setValue(v); + const fieldPath = bindings?.value; + if (fieldPath) { + onJsonDataChange(fieldPath.replace(/^\//, ''), v); + } + }} + /> + + ); + }, + + SecretInput: ({ props }) => { + const isConfigured = props.isConfigured ?? false; + return ( + + { + onSecureJsonDataChange(props.fieldName, e.currentTarget.value); + }} + onReset={() => { + onResetSecureField(props.fieldName); + }} + /> + + ); + }, + + SecretTextArea: ({ props }) => { + const isConfigured = props.isConfigured ?? false; + return ( + + { + onSecureJsonDataChange(props.fieldName, e.currentTarget.value); + }} + onReset={() => { + onResetSecureField(props.fieldName); + }} + /> + + ); + }, + + Divider: () => , + + Description: ({ props }) => ( + + ), + }, + actions: { + updateJsonData: async (params, _setState, _state) => { + if (params) { + onJsonDataChange(params.field, params.value); + } + }, + updateSecureJsonData: async (params) => { + if (params) { + onSecureJsonDataChange(params.field, params.value); + } + }, + resetSecureField: async (params) => { + if (params) { + onResetSecureField(params.field); + } + }, + }, + }); +} diff --git a/src/module.ts b/src/module.ts index 35b3ce31..a711c6ea 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,6 +1,6 @@ import { DataSourcePlugin } from '@grafana/data'; import { GitHubDataSource } from './DataSource'; -import ConfigEditor from './views/ConfigEditor'; +import ConfigEditorJsonRender from './views/ConfigEditorJsonRender'; import QueryEditor from './views/QueryEditor'; import type { GitHubQuery } from './types/query'; import type { GitHubDataSourceOptions, GitHubSecureJsonData } from './types/config'; @@ -11,5 +11,5 @@ export const plugin = new DataSourcePlugin< GitHubDataSourceOptions, GitHubSecureJsonData >(GitHubDataSource) - .setConfigEditor(ConfigEditor) + .setConfigEditor(ConfigEditorJsonRender) .setQueryEditor(QueryEditor); diff --git a/src/views/ConfigEditorJsonRender.spec.tsx b/src/views/ConfigEditorJsonRender.spec.tsx new file mode 100644 index 00000000..8206a895 --- /dev/null +++ b/src/views/ConfigEditorJsonRender.spec.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { screen, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ConfigEditorJsonRender from './ConfigEditorJsonRender'; + +describe('ConfigEditorJsonRender (json-render POC)', () => { + it('should render authentication and connection sections', async () => { + const onOptionsChange = jest.fn(); + const options = { jsonData: {}, secureJsonFields: {} } as any; + render(); + await waitFor(() => expect(screen.getByText('Authentication')).toBeInTheDocument()); + expect(screen.getByText('Connection')).toBeInTheDocument(); + }); + + it('should default to PAT auth type and basic license', async () => { + const onOptionsChange = jest.fn(); + const options = { jsonData: {}, secureJsonFields: {} } as any; + render(); + await waitFor(() => expect(screen.getByText('Authentication')).toBeInTheDocument()); + expect(screen.getByLabelText('Personal Access Token')).toBeChecked(); + expect(screen.getByLabelText('Free, Pro & Team')).toBeChecked(); + }); + + it('should show enterprise URL field when enterprise server is selected', async () => { + const onOptionsChange = jest.fn(); + const options = { + jsonData: { githubPlan: 'github-enterprise-server', selectedAuthType: 'personal-access-token' }, + secureJsonFields: {}, + } as any; + render(); + await waitFor(() => expect(screen.getByText('Authentication')).toBeInTheDocument()); + expect(screen.getByLabelText('Enterprise Server')).toBeChecked(); + expect( + screen.getByText('The URL of the GitHub Enterprise Server instance') + ).toBeInTheDocument(); + }); + + it('should show GitHub App fields when github-app auth is selected', async () => { + const onOptionsChange = jest.fn(); + const options = { + jsonData: { selectedAuthType: 'github-app' }, + secureJsonFields: {}, + } as any; + render(); + await waitFor(() => expect(screen.getByText('Authentication')).toBeInTheDocument()); + expect(screen.getByLabelText('GitHub App')).toBeChecked(); + expect(screen.getByText('The GitHub App ID')).toBeInTheDocument(); + expect(screen.getByText('The GitHub App installation ID')).toBeInTheDocument(); + expect( + screen.getByText('Private key for GitHub App authentication (PEM format)') + ).toBeInTheDocument(); + }); + + it('should call onOptionsChange when switching auth type', async () => { + const onOptionsChange = jest.fn(); + const options = { + jsonData: { selectedAuthType: 'personal-access-token' }, + secureJsonFields: {}, + } as any; + render(); + await waitFor(() => expect(screen.getByText('Authentication')).toBeInTheDocument()); + onOptionsChange.mockClear(); + await userEvent.click(screen.getByLabelText('GitHub App')); + expect(onOptionsChange).toHaveBeenCalledTimes(1); + expect(onOptionsChange).toHaveBeenCalledWith( + expect.objectContaining({ + jsonData: expect.objectContaining({ selectedAuthType: 'github-app' }), + }) + ); + }); +}); diff --git a/src/views/ConfigEditorJsonRender.tsx b/src/views/ConfigEditorJsonRender.tsx new file mode 100644 index 00000000..1af34075 --- /dev/null +++ b/src/views/ConfigEditorJsonRender.tsx @@ -0,0 +1,144 @@ +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { Renderer, JSONUIProvider, createStateStore } from '@json-render/react'; +import type { DataSourcePluginOptionsEditorProps, DataSourceSettings } from '@grafana/data'; +import type { GitHubDataSourceOptions, GitHubSecureJsonData } from 'types/config'; +import { createConfigEditorRegistry } from 'jsonrender/registry'; +import { buildConfigEditorSpec } from 'jsonrender/configEditorSpec'; + +export type ConfigEditorProps = DataSourcePluginOptionsEditorProps; + +/** + * Proof-of-concept ConfigEditor rendered entirely via json-render. + * + * The component: + * 1. Derives a json-render UI spec from the config JSON schema + * (see `configEditorSpec.ts`) + * 2. Maps catalog components to Grafana UI via a component registry + * (see `registry.tsx`) + * 3. Bridges json-render's internal state with Grafana's + * `onOptionsChange` callback so that edits persist. + */ +const ConfigEditorJsonRender = (props: ConfigEditorProps) => { + const { options, onOptionsChange } = props; + const { jsonData, secureJsonFields } = options; + + // ---- Stable references to avoid stale closures in callbacks ---- + const optionsRef = useRef(options); + const onOptionsChangeRef = useRef(onOptionsChange); + useEffect(() => { + optionsRef.current = options; + onOptionsChangeRef.current = onOptionsChange; + }, [options, onOptionsChange]); + + // ---- Callbacks forwarded into the json-render action / registry layer ---- + + const onJsonDataChange = useCallback((field: string, value: string) => { + const opts = optionsRef.current; + const updatedFields: Record = { ...opts.jsonData, [field]: value }; + + // When switching license, clear githubUrl for non-enterprise-server plans + if (field === 'githubPlan' && value !== 'github-enterprise-server') { + updatedFields['githubUrl'] = ''; + } + + onOptionsChangeRef.current({ ...opts, jsonData: updatedFields as unknown as GitHubDataSourceOptions }); + }, []); + + const onSecureJsonDataChange = useCallback((field: string, value: string) => { + const opts = optionsRef.current; + onOptionsChangeRef.current({ + ...opts, + secureJsonData: { ...opts.secureJsonData, [field]: value } as GitHubSecureJsonData, + secureJsonFields: { ...opts.secureJsonFields, [field]: false }, + }); + }, []); + + const onResetSecureField = useCallback((field: string) => { + const opts = optionsRef.current; + onOptionsChangeRef.current({ + ...opts, + secureJsonData: { ...opts.secureJsonData, [field]: '' } as GitHubSecureJsonData, + secureJsonFields: { ...opts.secureJsonFields, [field]: false }, + }); + }, []); + + // ---- Registry (stable across renders because callbacks are ref-based) ---- + + const { registry } = useMemo( + () => createConfigEditorRegistry(onJsonDataChange, onSecureJsonDataChange, onResetSecureField), + [onJsonDataChange, onSecureJsonDataChange, onResetSecureField] + ); + + // ---- State store (controlled mode) ---- + + const store = useMemo(() => { + return createStateStore(buildStateFromOptions(options)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Keep the store in sync with external option changes + const prevOptionsRef = useRef(options); + useEffect(() => { + if (prevOptionsRef.current !== options) { + prevOptionsRef.current = options; + store.update(buildStateFromOptions(options)); + } + }, [options, store]); + + // ---- Build spec (recalculated when relevant state changes) ---- + + const selectedAuthType = (jsonData.selectedAuthType as string) || 'personal-access-token'; + const githubPlan = (jsonData.githubPlan as string) || 'github-basic'; + + const jd = jsonData as unknown as Record; + const spec = useMemo( + () => + buildConfigEditorSpec({ + selectedAuthType, + githubPlan, + githubUrl: jd.githubUrl ?? '', + appId: jd.appId ?? '', + installationId: jd.installationId ?? '', + accessTokenConfigured: !!secureJsonFields?.['accessToken'], + privateKeyConfigured: !!secureJsonFields?.['privateKey'], + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedAuthType, githubPlan, jsonData, secureJsonFields] + ); + + // ---- Set default auth type on first mount (matches original behaviour) ---- + + useEffect(() => { + if (!jsonData.selectedAuthType) { + onJsonDataChange('selectedAuthType', 'personal-access-token'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + ); +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildStateFromOptions( + options: DataSourceSettings +): Record { + const jd = options.jsonData as unknown as Record; + return { + selectedAuthType: jd.selectedAuthType ?? 'personal-access-token', + githubPlan: jd.githubPlan ?? 'github-basic', + githubUrl: jd.githubUrl ?? '', + appId: jd.appId ?? '', + installationId: jd.installationId ?? '', + accessTokenConfigured: !!options.secureJsonFields?.['accessToken'], + privateKeyConfigured: !!options.secureJsonFields?.['privateKey'], + }; +} + +export default ConfigEditorJsonRender; diff --git a/yarn.lock b/yarn.lock index 507b78c1..7fdb240a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2578,6 +2578,28 @@ __metadata: languageName: node linkType: hard +"@json-render/core@npm:0.14.1, @json-render/core@npm:^0.14.1": + version: 0.14.1 + resolution: "@json-render/core@npm:0.14.1" + dependencies: + zod: "npm:^4.3.6" + peerDependencies: + zod: ^4.0.0 + checksum: 10c0/3c48fe327c033b82d9dfe659011f67dd10a7f75ad537e5a4179a5cd2cc58afe2b39091ac7858aad17ec74af05c38c2eb65dd7e8df4d23c068aee2d0af0945c6d + languageName: node + linkType: hard + +"@json-render/react@npm:^0.14.1": + version: 0.14.1 + resolution: "@json-render/react@npm:0.14.1" + dependencies: + "@json-render/core": "npm:0.14.1" + peerDependencies: + react: ^19.2.3 + checksum: 10c0/9b34861e5b7c251d862c6c7bba7f2a1ad1a003ce69c0cf3a4c1d37879be4be4ce205b5a84615bdafe3f89ad2ce2aabe4d324723083070cb5f21a9eb8ffcc761e + languageName: node + linkType: hard + "@leeoniya/ufuzzy@npm:1.0.19": version: 1.0.19 resolution: "@leeoniya/ufuzzy@npm:1.0.19" @@ -7963,6 +7985,8 @@ __metadata: "@grafana/schema": "npm:12.4.0" "@grafana/tsconfig": "npm:2.0.1" "@grafana/ui": "npm:12.4.0" + "@json-render/core": "npm:^0.14.1" + "@json-render/react": "npm:^0.14.1" "@openfeature/web-sdk": "npm:1.7.3" "@playwright/test": "npm:1.58.2" "@stylistic/eslint-plugin-ts": "npm:4.4.1" @@ -13990,7 +14014,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:4.3.6, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.3.0": +"zod@npm:4.3.6, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.3.0, zod@npm:^4.3.6": version: 4.3.6 resolution: "zod@npm:4.3.6" checksum: 10c0/860d25a81ab41d33aa25f8d0d07b091a04acb426e605f396227a796e9e800c44723ed96d0f53a512b57be3d1520f45bf69c0cb3b378a232a00787a2609625307