diff --git a/.changeset/four-knives-wonder.md b/.changeset/four-knives-wonder.md new file mode 100644 index 00000000..0e8541ba --- /dev/null +++ b/.changeset/four-knives-wonder.md @@ -0,0 +1,5 @@ +--- +'grafana-google-sheets-datasource': minor +--- + +Add default spreadsheet ID to config editor diff --git a/pkg/models/settings.go b/pkg/models/settings.go index 54e30616..c40b3f08 100644 --- a/pkg/models/settings.go +++ b/pkg/models/settings.go @@ -19,6 +19,7 @@ type DatasourceSettings struct { TokenURI string `json:"tokenUri"` AuthenticationType string `json:"authenticationType"` PrivateKeyPath string `json:"privateKeyPath"` + DefaultSheetID string `json:"defaultSheetID"` // Saved in secure JSON PrivateKey string `json:"-"` diff --git a/src/DataSource.ts b/src/DataSource.ts index 80eb73a4..eaba0121 100644 --- a/src/DataSource.ts +++ b/src/DataSource.ts @@ -4,18 +4,18 @@ import { DataSourceInstanceSettings, ScopedVars, SelectableValue, + CoreApp, } from '@grafana/data'; -import { DataSourceOptions } from '@grafana/google-sdk'; import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; -import { SheetsQuery, SheetsVariableQuery } from './types'; +import { GoogleSheetsDataSourceOptions, SheetsQuery, SheetsVariableQuery } from './types'; import { Observable } from 'rxjs'; import { trackRequest } from 'tracking'; import { SheetsVariableSupport } from 'variables'; -export class DataSource extends DataSourceWithBackend { +export class DataSource extends DataSourceWithBackend { authType: string; constructor( - instanceSettings: DataSourceInstanceSettings, + private instanceSettings: DataSourceInstanceSettings, private readonly templateSrv: TemplateSrv = getTemplateSrv() ) { super(instanceSettings); @@ -63,8 +63,16 @@ export class DataSource extends DataSourceWithBackend>> { return this.getResource('spreadsheets').then(({ spreadsheets }) => spreadsheets - ? Object.entries(spreadsheets).map(([value, label]) => ({ label, value }) as SelectableValue) + ? Object.entries(spreadsheets).map(([value, label]) => ({ + label, + value, + description: value, + }) as SelectableValue) : [] ); } + + getDefaultQuery(app: CoreApp): Partial { + return { spreadsheet: this.instanceSettings.jsonData.defaultSheetID || '' }; + } } diff --git a/src/components/ConfigEditor.test.tsx b/src/components/ConfigEditor.test.tsx index 490fe6dc..1a5d14e8 100644 --- a/src/components/ConfigEditor.test.tsx +++ b/src/components/ConfigEditor.test.tsx @@ -1,9 +1,42 @@ import React from 'react'; - -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ConfigEditor } from './ConfigEditor'; +import { DataSourceSettings } from '@grafana/data'; +import { GoogleSheetsSecureJSONData } from '../types'; +import { GoogleAuthType, DataSourceOptions } from '@grafana/google-sdk'; + +jest.mock('@grafana/plugin-ui', () => ({ + DataSourceDescription: () =>
, +})); +jest.mock('@grafana/runtime', () => ({ + getDataSourceSrv: () => ({ + get: (_: string) => + Promise.resolve({ + getSpreadSheets: () => + Promise.resolve([ + { label: 'label1', value: 'value1', description: 'value1' }, + ]), + }), + }), +})); + +const dataSourceSettings: Partial> = { + jsonData: { + authenticationType: GoogleAuthType.JWT, + }, + secureJsonFields: { + jwt: true, + }, + uid: 'test-uid', +}; + describe('ConfigEditor', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('should support old authType property', () => { const onOptionsChange = jest.fn(); // Render component with old authType property @@ -42,8 +75,6 @@ describe('ConfigEditor', () => { expect(screen.getByPlaceholderText('Enter API key')).toHaveAttribute('value', 'configured'); }); - // - it('should be backward compatible with JWT auth type', () => { render( { // Check that the Private key input is configured expect(screen.getByTestId('Private Key Input')).toHaveAttribute('value', 'configured'); }); + it('should render default spreadsheet ID field', () => { + render( + + ); + expect(screen.getByText('Default Spreadsheet ID')).toBeInTheDocument(); + }); + + it('should update default spreadsheet after selecting it', async () => { + const onOptionsChange = jest.fn(); + render( + } + /> + ); + + const selectEl = screen.getByText('Select Spreadsheet ID'); + expect(selectEl).toBeInTheDocument(); + + await userEvent.click(selectEl); + const spreadsheetOption = await screen.findByText('label1', {}, { timeout: 3000 }); + await userEvent.click(spreadsheetOption); + + await waitFor(() => { + expect(onOptionsChange).toHaveBeenCalledWith( + expect.objectContaining({ + jsonData: expect.objectContaining({ + defaultSheetID: 'value1', + }), + }) + ); + }); + }); }); diff --git a/src/components/ConfigEditor.tsx b/src/components/ConfigEditor.tsx index 03c0ed9d..a6cf0828 100644 --- a/src/components/ConfigEditor.tsx +++ b/src/components/ConfigEditor.tsx @@ -1,16 +1,25 @@ -import { DataSourcePluginOptionsEditorProps, onUpdateDatasourceSecureJsonDataOption } from '@grafana/data'; -import { AuthConfig, DataSourceOptions } from '@grafana/google-sdk'; -import { Field, SecretInput, Divider } from '@grafana/ui'; -import React from 'react'; -import { GoogleSheetsAuth, GoogleSheetsSecureJSONData, googleSheetsAuthTypes } from '../types'; +import { + DataSourcePluginOptionsEditorProps, + onUpdateDatasourceSecureJsonDataOption, + SelectableValue, +} from '@grafana/data'; +import { AuthConfig } from '@grafana/google-sdk'; +import { DataSourceDescription } from '@grafana/plugin-ui'; +import { Field, SecretInput, SegmentAsync, Divider } from '@grafana/ui'; +import React, { useState, useEffect } from 'react'; +import { GoogleSheetsSecureJSONData, googleSheetsAuthTypes, GoogleSheetsAuth, GoogleSheetsDataSourceOptions } from '../types'; import { getBackwardCompatibleOptions } from '../utils'; import { ConfigurationHelp } from './ConfigurationHelp'; -import { DataSourceDescription } from '@grafana/plugin-ui'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { DataSource } from '../DataSource'; -export type Props = DataSourcePluginOptionsEditorProps; +export type Props = DataSourcePluginOptionsEditorProps; export function ConfigEditor(props: Props) { const options = getBackwardCompatibleOptions(props.options); + const [selectedSheetOption, setSelectedSheetOption] = useState | string | undefined>( + options.jsonData.defaultSheetID + ); const apiKeyProps = { isConfigured: Boolean(options.secureJsonFields.apiKey), @@ -27,6 +36,36 @@ export function ConfigEditor(props: Props) { onChange: onUpdateDatasourceSecureJsonDataOption(props, 'apiKey'), }; + const loadSheetIDs = async () => { + if (!options.uid) { + return []; + } + try { + const ds = (await getDataSourceSrv().get(options.uid)) as DataSource; + return ds.getSpreadSheets(); + } catch { + return []; + } + }; + + useEffect(() => { + const currentValue = options.jsonData.defaultSheetID; + if (!currentValue || !options.uid) { + setSelectedSheetOption(currentValue); + return; + } + const updateSelectedOption = async () => { + try { + const ds = (await getDataSourceSrv().get(options.uid!)) as DataSource; + const sheetOptions = await ds.getSpreadSheets(); + const matchingOption = sheetOptions.find((opt) => opt.value === currentValue); + setSelectedSheetOption(matchingOption || currentValue); + } catch { + setSelectedSheetOption(currentValue); + } + }; + updateSelectedOption(); + }, [options.jsonData.defaultSheetID, options.uid]); return ( <> )} + + + + + { + const sheetId = typeof value === 'string' ? value : value?.value; + setSelectedSheetOption(value); + props.onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + defaultSheetID: sheetId, + }, + }); + }} + /> + ); } diff --git a/src/components/QueryEditor.tsx b/src/components/QueryEditor.tsx index ce57675e..851dfd0c 100644 --- a/src/components/QueryEditor.tsx +++ b/src/components/QueryEditor.tsx @@ -1,4 +1,4 @@ -import { QueryEditorProps } from '@grafana/data'; +import { QueryEditorProps, SelectableValue } from '@grafana/data'; import { DataSourceOptions } from '@grafana/google-sdk'; import { InlineFieldRow, InlineFormLabel, InlineSwitch, Input, LinkButton, Segment, SegmentAsync } from '@grafana/ui'; import React, { ChangeEvent, PureComponent } from 'react'; @@ -9,6 +9,25 @@ import { css } from '@emotion/css'; type Props = QueryEditorProps; +type SelectedSheetOption = SelectableValue | string | undefined; + +function resolveSelectedSheetOption( + options: Array>, + spreadsheet?: string +): SelectableValue | string | undefined { + if (!spreadsheet) { + return undefined; + } + return options.find((opt) => opt.value === spreadsheet) ?? spreadsheet; +} + +function selectedSheetOptionKey(option: SelectedSheetOption): string | undefined { + if (option === undefined) { + return undefined; + } + return typeof option === 'string' ? option : option.value; +} + export function getGoogleSheetRangeInfoFromURL(url: string): Partial { let idx = url?.indexOf('/d/'); if (!idx) { @@ -51,6 +70,10 @@ export const formatCacheTimeLabel = (s: number = defaultCacheDuration) => { }; export class QueryEditor extends PureComponent { + state = { + selectedSheetOption: undefined as SelectedSheetOption, + }; + componentDidMount() { if (!this.props.query.hasOwnProperty('cacheDurationSeconds')) { this.props.onChange({ @@ -58,8 +81,29 @@ export class QueryEditor extends PureComponent { cacheDurationSeconds: defaultCacheDuration, // um :( }); } + this.updateSelectedSheetOption(); } + componentDidUpdate(prevProps: Props) { + if (prevProps.query.spreadsheet !== this.props.query.spreadsheet) { + this.updateSelectedSheetOption(); + } + } + + updateSelectedSheetOption = async () => { + const { query, datasource } = this.props; + if (!query.spreadsheet) { + this.setState({ selectedSheetOption: undefined }); + return; + } + try { + const sheetOptions = await datasource.getSpreadSheets(); + this.setState({ selectedSheetOption: resolveSelectedSheetOption(sheetOptions, query.spreadsheet) }); + } catch { + this.setState({ selectedSheetOption: query.spreadsheet }); + } + }; + onRangeChange = (event: ChangeEvent) => { this.props.onChange({ ...this.props.query, @@ -71,10 +115,12 @@ export class QueryEditor extends PureComponent { const { query, onRunQuery, onChange } = this.props; if (!item.value) { + this.setState({ selectedSheetOption: undefined }); return; // ignore delete? } const v = item.value; + this.setState({ selectedSheetOption: item }); // Check for pasted full URLs if (/(.*)\/spreadsheets\/d\/(.*)/.test(v)) { onChange({ ...query, ...getGoogleSheetRangeInfoFromURL(v) }); @@ -118,9 +164,17 @@ export class QueryEditor extends PureComponent { Spreadsheet ID datasource.getSpreadSheets()} + loadOptions={async () => { + const options = await datasource.getSpreadSheets(); + const { query } = this.props; + const next = resolveSelectedSheetOption(options, query.spreadsheet); + if (selectedSheetOptionKey(this.state.selectedSheetOption) !== selectedSheetOptionKey(next)) { + this.setState({ selectedSheetOption: next }); + } + return options; + }} placeholder="Enter SpreadsheetID" - value={query.spreadsheet} + value={this.state.selectedSheetOption ?? query.spreadsheet} allowCustomValue={true} onChange={this.onSpreadsheetIDChange} /> diff --git a/src/types.ts b/src/types.ts index 4d98d66d..c85a0690 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import { DataQuery } from '@grafana/schema'; -import { GoogleAuthType, GOOGLE_AUTH_TYPE_OPTIONS, DataSourceSecureJsonData } from '@grafana/google-sdk'; +import { GoogleAuthType, GOOGLE_AUTH_TYPE_OPTIONS, DataSourceSecureJsonData, DataSourceOptions } from '@grafana/google-sdk'; export const GoogleSheetsAuth = { ...GoogleAuthType, @@ -12,6 +12,10 @@ export interface GoogleSheetsSecureJSONData extends DataSourceSecureJsonData { apiKey?: string; } +export interface GoogleSheetsDataSourceOptions extends DataSourceOptions { + defaultSheetID?: string; +} + export interface CacheInfo { hit: boolean; count: number;