From c8d237dd562815b4cb9e559a3a21186ec6c386ee Mon Sep 17 00:00:00 2001 From: Kristina Date: Mon, 20 May 2024 08:05:59 -0500 Subject: [PATCH] Explore / Query Library: Enable run button (#87882) * Enable run button * First pass of shared component, tests half-implemented * cleanup --- .../explore/ExploreRunQueryButton.test.tsx | 157 ++++++++++++++++++ .../explore/ExploreRunQueryButton.tsx | 126 ++++++++++++++ .../QueryTemplatesTable/ActionsCell.tsx | 23 +-- .../QueryTemplatesTable/index.tsx | 10 +- .../RichHistory/RichHistoryCard.test.tsx | 119 +------------ .../explore/RichHistory/RichHistoryCard.tsx | 105 +----------- public/locales/en-US/grafana.json | 10 +- public/locales/pseudo-LOCALE/grafana.json | 10 +- 8 files changed, 325 insertions(+), 235 deletions(-) create mode 100644 public/app/features/explore/ExploreRunQueryButton.test.tsx create mode 100644 public/app/features/explore/ExploreRunQueryButton.tsx diff --git a/public/app/features/explore/ExploreRunQueryButton.test.tsx b/public/app/features/explore/ExploreRunQueryButton.test.tsx new file mode 100644 index 00000000000..5860c852657 --- /dev/null +++ b/public/app/features/explore/ExploreRunQueryButton.test.tsx @@ -0,0 +1,157 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { DatasourceSrvMock, MockDataSourceApi } from 'test/mocks/datasource_srv'; + +import { DataSourceApi } from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; +import { configureStore } from 'app/store/configureStore'; +import { ExploreItemState, ExploreState } from 'app/types'; + +import { Props, ExploreRunQueryButton } from './ExploreRunQueryButton'; +import { makeExplorePaneState } from './state/utils'; + +interface MockQuery extends DataQuery { + query: string; + queryText?: string; +} + +const lokiDs = { + uid: 'loki', + name: 'testDs', + type: 'loki', + meta: { mixed: false }, + getRef: () => { + return { type: 'loki', uid: 'loki' }; + }, +} as unknown as DataSourceApi; + +const promDs = { + uid: 'prom', + name: 'testDs2', + type: 'prom', + meta: { mixed: false }, + getRef: () => { + return { type: 'prom', uid: 'prom' }; + }, +} as unknown as DataSourceApi; + +const datasourceSrv = new DatasourceSrvMock(lokiDs, { + prom: promDs, + mixed: { + uid: 'mixed', + name: 'testDSMixed', + type: 'mixed', + meta: { mixed: true }, + } as MockDataSourceApi, +}); + +const getDataSourceSrvMock = jest.fn().mockReturnValue(datasourceSrv); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => getDataSourceSrvMock(), +})); + +const setup = (propOverrides?: Partial, paneCount = 1) => { + const props: Props = { + queries: [], + rootDatasourceUid: 'loki', + setQueries: jest.fn(), + changeDatasource: jest.fn(), + }; + + Object.assign(props, propOverrides); + + const panes: Record = {}; + + if (paneCount > 0) { + panes.left = makeExplorePaneState({ datasourceInstance: lokiDs }); + } + if (paneCount === 2) { + panes.right = makeExplorePaneState({ datasourceInstance: lokiDs }); + } + + const store = configureStore({ + explore: { + panes, + } as unknown as ExploreState, + }); + + render( + + + + ); +}; + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('ExploreRunQueryButton', () => { + it('should disable run query button if there are no explore IDs', async () => { + setup({}, 0); + const runQueryButton = await screen.findByRole('button', { name: /run query/i }); + expect(runQueryButton).toBeDisabled(); + }); + + it('should be disabled if the root datasource is undefined (invalid datasource)', async () => { + setup({ + rootDatasourceUid: undefined, + }); + const runQueryButton = await screen.findByRole('button', { name: /run query/i }); + expect(runQueryButton).toBeDisabled(); + }); + + it('should be disabled if property is set', async () => { + setup({ + disabled: true, + }); + const runQueryButton = await screen.findByRole('button', { name: /run query/i }); + expect(runQueryButton).toBeDisabled(); + }); + + it('should set new queries without changing DS when running queries from the same datasource', async () => { + const setQueries = jest.fn(); + const changeDatasource = jest.fn(); + const queries: MockQuery[] = [ + { query: 'query1', refId: 'A', datasource: { uid: 'loki' } }, + { query: 'query2', refId: 'B', datasource: { uid: 'loki' } }, + ]; + setup({ + setQueries, + changeDatasource, + rootDatasourceUid: 'loki', + queries, + }); + const runQueryButton = await screen.findByRole('button', { name: /run query/i }); + await userEvent.click(runQueryButton); + + expect(setQueries).toHaveBeenCalledWith(expect.any(String), queries); + expect(changeDatasource).not.toHaveBeenCalled(); + }); + + it('should change datasource to mixed and set new queries when running queries from mixed datasource', async () => { + const setQueries = jest.fn(); + const changeDatasource = jest.fn(); + const queries: MockQuery[] = [ + { query: 'query1', refId: 'A', datasource: { type: 'loki', uid: 'loki' } }, + { query: 'query2', refId: 'B', datasource: { type: 'prometheus', uid: 'prometheus' } }, + ]; + setup({ + setQueries, + changeDatasource, + rootDatasourceUid: 'mixed', + queries, + }); + + const runQueryButton = await screen.findByRole('button', { name: /run query/i }); + await userEvent.click(runQueryButton); + + await waitFor(() => { + expect(setQueries).toHaveBeenCalledWith(expect.any(String), queries); + expect(changeDatasource).toHaveBeenCalledWith({ datasource: 'mixed', exploreId: 'left' }); + }); + }); +}); diff --git a/public/app/features/explore/ExploreRunQueryButton.tsx b/public/app/features/explore/ExploreRunQueryButton.tsx new file mode 100644 index 00000000000..d1e9132482c --- /dev/null +++ b/public/app/features/explore/ExploreRunQueryButton.tsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react'; +import { ConnectedProps, connect } from 'react-redux'; + +import { config, reportInteraction } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; +import { Button, Dropdown, Menu, ToolbarButton } from '@grafana/ui'; +import { t } from '@grafana/ui/src/utils/i18n'; +import { useSelector } from 'app/types'; + +import { changeDatasource } from './state/datasource'; +import { setQueries } from './state/query'; +import { isSplit, selectExploreDSMaps, selectPanesEntries } from './state/selectors'; + +const mapDispatchToProps = { + setQueries, + changeDatasource, +}; + +const connector = connect(undefined, mapDispatchToProps); + +interface ExploreRunQueryButtonProps { + queries: DataQuery[]; + rootDatasourceUid?: string; + disabled?: boolean; +} + +export type Props = ConnectedProps & ExploreRunQueryButtonProps; + +/* +This component does not validate datasources before running them. Root datasource validation should happen outside this component and can pass in an undefined if invalid +If query level validation is done and a query datasource is invalid, pass in disabled = true +*/ + +export function ExploreRunQueryButton({ + rootDatasourceUid, + queries, + disabled = false, + changeDatasource, + setQueries, +}: Props) { + const [openRunQueryButton, setOpenRunQueryButton] = useState(false); + const isPaneSplit = useSelector(isSplit); + const exploreActiveDS = useSelector(selectExploreDSMaps); + const panesEntries = useSelector(selectPanesEntries); + + const isDifferentDatasource = (uid: string, exploreId: string) => + !exploreActiveDS.dsToExplore.find((di) => di.datasource.uid === uid)?.exploreIds.includes(exploreId); + + // exploreId on where the query will be ran, and the datasource ID for the item's DS + const runQueryText = (exploreId: string, dsUid?: string) => { + // if the datasource or exploreID is undefined, it will be disabled, but give it default query button text + return dsUid !== undefined && exploreId !== undefined && isDifferentDatasource(dsUid, exploreId) + ? { + fallbackText: 'Switch data source and run query', + translation: t('explore.run-query.switch-datasource-button', 'Switch data source and run query'), + } + : { + fallbackText: 'Run query', + translation: t('explore.run-query.run-query-button', 'Run query'), + }; + }; + + const runQuery = async (exploreId: string) => { + const differentDataSource = isDifferentDatasource(rootDatasourceUid!, exploreId); + if (differentDataSource) { + await changeDatasource({ exploreId, datasource: rootDatasourceUid! }); + } + setQueries(exploreId, queries); + + reportInteraction('grafana_explore_query_history_run', { + queryHistoryEnabled: config.queryHistoryEnabled, + differentDataSource, + }); + }; + + const runButton = () => { + const isInvalid = disabled || queries.length === 0 || rootDatasourceUid === undefined; + if (!isPaneSplit) { + const exploreId = exploreActiveDS.exploreToDS[0]?.exploreId; // may be undefined if explore is refreshed while the pane is up + const buttonText = runQueryText(exploreId, rootDatasourceUid); + return ( + + ); + } else { + const menu = ( + + {panesEntries.map((pane, i) => { + const buttonText = runQueryText(pane[0], rootDatasourceUid); + const paneLabel = + i === 0 ? t('explore.run-query.left-pane', 'Left pane') : t('explore.run-query.right-pane', 'Right pane'); + return ( + { + runQuery(pane[0]); + }} + label={`${paneLabel}: ${buttonText.translation}`} + disabled={isInvalid || pane[0] === undefined} + /> + ); + })} + + ); + + return ( + setOpenRunQueryButton(state)} placement="bottom-start" overlay={menu}> + + {t('explore.run-query.run-query-button', 'Run query')} + + + ); + } + }; + + return <>{runButton()}; +} + +export default connector(ExploreRunQueryButton); diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx index ccc18049a78..6dd1c181d69 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx @@ -1,13 +1,16 @@ import React from 'react'; -import { Button } from '@grafana/ui'; - -export function ActionsCell() { - return ( - <> - - - ); +import { DataQuery } from '@grafana/schema'; + +import ExploreRunQueryButton from '../../ExploreRunQueryButton'; + +interface ActionsCellProps { + query?: DataQuery; + rootDatasourceUid?: string; } + +function ActionsCell({ query, rootDatasourceUid }: ActionsCellProps) { + return ; +} + +export default ActionsCell; diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx index 53a54848d24..56625e3cf15 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx @@ -4,7 +4,7 @@ import { SortByFn } from 'react-table'; import { Column, InteractiveTable } from '@grafana/ui'; -import { ActionsCell } from './ActionsCell'; +import ActionsCell from './ActionsCell'; import { AddedByCell } from './AddedByCell'; import { DatasourceTypeCell } from './DatasourceTypeCell'; import { DateAddedCell } from './DateAddedCell'; @@ -22,7 +22,13 @@ const columns: Array> = [ { id: 'addedBy', header: 'Added by', cell: AddedByCell }, { id: 'datasourceType', header: 'Datasource type', cell: DatasourceTypeCell, sortType: 'string' }, { id: 'createdAtTimestamp', header: 'Date added', cell: DateAddedCell, sortType: timestampSort }, - { id: 'actions', header: '', cell: ActionsCell }, + { + id: 'actions', + header: '', + cell: ({ row: { original } }) => ( + + ), + }, ]; const styles = { diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx index 23ead9d44b0..ce8e8e2956b 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, getByText, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, getByText } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; @@ -213,12 +213,6 @@ describe('RichHistoryCard', () => { expect(datasourceName).toHaveTextContent('Data source does not exist anymore'); }); - it('should disable run query button if there are no explore IDs', async () => { - setup({}, true); - const runQueryButton = await screen.findByRole('button', { name: /run query/i }); - expect(runQueryButton).toBeDisabled(); - }); - describe('copy queries to clipboard', () => { it('should copy query model to clipboard when copying a query from a non existent datasource', async () => { setup({ @@ -304,117 +298,6 @@ describe('RichHistoryCard', () => { }); }); - describe('run queries', () => { - it('should be disabled if at least one query datasource is missing when using mixed', async () => { - const setQueries = jest.fn(); - const changeDatasource = jest.fn(); - const queries: MockQuery[] = [ - { query: 'query1', refId: 'A', datasource: { uid: 'nonexistent-ds' } }, - { query: 'query2', refId: 'B', datasource: { uid: 'loki' } }, - ]; - setup({ - setQueries, - changeDatasource, - queryHistoryItem: { - id: '2', - createdAt: 1, - datasourceUid: 'mixed', - datasourceName: 'Mixed', - starred: false, - comment: '', - queries, - }, - }); - const runQueryButton = await screen.findByRole('button', { name: /run query/i }); - - expect(runQueryButton).toBeDisabled(); - }); - - it('should be disabled if at datasource is missing', async () => { - const setQueries = jest.fn(); - const changeDatasource = jest.fn(); - const queries: MockQuery[] = [ - { query: 'query1', refId: 'A' }, - { query: 'query2', refId: 'B' }, - ]; - setup({ - setQueries, - changeDatasource, - queryHistoryItem: { - id: '2', - createdAt: 1, - datasourceUid: 'nonexistent-ds', - datasourceName: 'nonexistent-ds', - starred: false, - comment: '', - queries, - }, - }); - const runQueryButton = await screen.findByRole('button', { name: /run query/i }); - - expect(runQueryButton).toBeDisabled(); - }); - - it('should only set new queries when running queries from the same datasource', async () => { - const setQueries = jest.fn(); - const changeDatasource = jest.fn(); - const queries: MockQuery[] = [ - { query: 'query1', refId: 'A' }, - { query: 'query2', refId: 'B' }, - ]; - setup({ - setQueries, - changeDatasource, - queryHistoryItem: { - id: '2', - createdAt: 1, - datasourceUid: 'loki', - datasourceName: 'Loki', - starred: false, - comment: '', - queries, - }, - }); - - const runQueryButton = await screen.findByRole('button', { name: /run query/i }); - await userEvent.click(runQueryButton); - - expect(setQueries).toHaveBeenCalledWith(expect.any(String), queries); - expect(changeDatasource).not.toHaveBeenCalled(); - }); - - it('should change datasource to mixed and set new queries when running queries from mixed datasource', async () => { - const setQueries = jest.fn(); - const changeDatasource = jest.fn(); - const queries: MockQuery[] = [ - { query: 'query1', refId: 'A', datasource: { type: 'loki', uid: 'loki' } }, - { query: 'query2', refId: 'B', datasource: { type: 'prometheus', uid: 'prometheus' } }, - ]; - setup({ - setQueries, - changeDatasource, - queryHistoryItem: { - id: '2', - createdAt: 1, - datasourceUid: 'mixed', - datasourceName: 'Mixed', - starred: false, - comment: '', - queries, - }, - datasourceInstances: [dsStore.loki, dsStore.prometheus, dsStore.mixed], - }); - - const runQueryButton = await screen.findByRole('button', { name: /run query/i }); - await userEvent.click(runQueryButton); - - await waitFor(() => { - expect(setQueries).toHaveBeenCalledWith(expect.any(String), queries); - expect(changeDatasource).toHaveBeenCalledWith({ datasource: 'mixed', exploreId: 'left' }); - }); - }); - }); - describe('commenting', () => { it('should render comment, if comment present', async () => { setup({ queryHistoryItem: starredQueryWithComment }); diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx index 28d3f86a0b4..350cc43e886 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx @@ -5,7 +5,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { GrafanaTheme2, DataSourceApi } from '@grafana/data'; import { config, reportInteraction, getAppEvents } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; -import { TextArea, Button, IconButton, useStyles2, ToolbarButton, Dropdown, Menu } from '@grafana/ui'; +import { TextArea, Button, IconButton, useStyles2 } from '@grafana/ui'; import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; import { Trans, t } from 'app/core/internationalization'; @@ -16,11 +16,10 @@ import { changeDatasource } from 'app/features/explore/state/datasource'; import { starHistoryItem, commentHistoryItem, deleteHistoryItem } from 'app/features/explore/state/history'; import { setQueries } from 'app/features/explore/state/query'; import { dispatch } from 'app/store/store'; -import { useSelector } from 'app/types'; import { ShowConfirmModalEvent } from 'app/types/events'; import { RichHistoryQuery } from 'app/types/explore'; -import { isSplit, selectExploreDSMaps, selectPanesEntries } from '../state/selectors'; +import ExploreRunQueryButton from '../ExploreRunQueryButton'; const mapDispatchToProps = { changeDatasource, @@ -134,46 +133,16 @@ const getStyles = (theme: GrafanaTheme2) => { }; export function RichHistoryCard(props: Props) { - const { - queryHistoryItem, - commentHistoryItem, - starHistoryItem, - deleteHistoryItem, - changeDatasource, - setQueries, - datasourceInstances, - } = props; + const { queryHistoryItem, commentHistoryItem, starHistoryItem, deleteHistoryItem, datasourceInstances } = props; const [activeUpdateComment, setActiveUpdateComment] = useState(false); - const [openRunQueryButton, setOpenRunQueryButton] = useState(false); const [comment, setComment] = useState(queryHistoryItem.comment); - const panesEntries = useSelector(selectPanesEntries); - const exploreActiveDS = useSelector(selectExploreDSMaps); - const isPaneSplit = useSelector(isSplit); - const styles = useStyles2(getStyles); const cardRootDatasource = datasourceInstances ? datasourceInstances.find((di) => di.uid === queryHistoryItem.datasourceUid) : undefined; - const isDifferentDatasource = (uid: string, exploreId: string) => - !exploreActiveDS.dsToExplore.find((di) => di.datasource.uid === uid)?.exploreIds.includes(exploreId); - - const onRunQuery = async (exploreId: string) => { - const queriesToRun = queryHistoryItem.queries; - const differentDataSource = isDifferentDatasource(queryHistoryItem.datasourceUid, exploreId); - if (differentDataSource) { - await changeDatasource({ exploreId, datasource: queryHistoryItem.datasourceUid }); - } - setQueries(exploreId, queriesToRun); - - reportInteraction('grafana_explore_query_history_run', { - queryHistoryEnabled: config.queryHistoryEnabled, - differentDataSource, - }); - }; - const onCopyQuery = async () => { const datasources = [...queryHistoryItem.queries.map((query) => query.datasource?.type || 'unknown')]; reportInteraction('grafana_explore_query_history_copy_query', { @@ -344,68 +313,6 @@ export function RichHistoryCard(props: Props) { ); - // exploreId on where the query will be ran, and the datasource ID for the item's DS - const runQueryText = (exploreId: string, dsUid: string) => { - return dsUid !== undefined && exploreId !== undefined && isDifferentDatasource(dsUid, exploreId) - ? { - fallbackText: 'Switch data source and run query', - translation: t('explore.rich-history-card.switch-datasource-button', 'Switch data source and run query'), - } - : { - fallbackText: 'Run query', - translation: t('explore.rich-history-card.run-query-button', 'Run query'), - }; - }; - - const runButton = () => { - const disabled = cardRootDatasource?.uid === undefined; - if (!isPaneSplit) { - const exploreId = exploreActiveDS.exploreToDS[0]?.exploreId; // may be undefined if explore is refreshed while the pane is up - const buttonText = runQueryText(exploreId, props.queryHistoryItem.datasourceUid); - return ( - - ); - } else { - const menu = ( - - {panesEntries.map((pane, i) => { - const buttonText = runQueryText(pane[0], props.queryHistoryItem.datasourceUid); - const paneLabel = - i === 0 - ? t('explore.rich-history-card.left-pane', 'Left pane') - : t('explore.rich-history-card.right-pane', 'Right pane'); - return ( - { - onRunQuery(pane[0]); - }} - label={`${paneLabel}: ${buttonText.translation}`} - disabled={disabled} - /> - ); - })} - - ); - - return ( - setOpenRunQueryButton(state)} placement="bottom-start" overlay={menu}> - - {t('explore.rich-history-card.run-query-button', 'Run query')} - - - ); - } - }; - return (
@@ -435,7 +342,11 @@ export function RichHistoryCard(props: Props) { )} {activeUpdateComment && updateComment}
- {!activeUpdateComment &&
{runButton()}
} + {!activeUpdateComment && ( +
+ +
+ )}
); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 76d9d9fe01a..088ac95e07c 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -491,15 +491,11 @@ "delete-query-tooltip": "Delete query", "delete-starred-query-confirmation-text": "Are you sure you want to permanently delete your starred query?", "edit-comment-tooltip": "Edit comment", - "left-pane": "Left pane", "optional-description": "An optional description of what the query does.", "query-comment-label": "Query comment", "query-text-label": "Query text", - "right-pane": "Right pane", - "run-query-button": "Run query", "save-comment": "Save comment", "star-query-tooltip": "Star query", - "switch-datasource-button": "Switch data source and run query", "unstar-query-tooltip": "Unstar query", "update-comment-form": "Update comment form" }, @@ -568,6 +564,12 @@ "saving-failed": "Saving rich history failed", "update-failed": "Rich History update failed" }, + "run-query": { + "left-pane": "Left pane", + "right-pane": "Right pane", + "run-query-button": "Run query", + "switch-datasource-button": "Switch data source and run query" + }, "secondary-actions": { "query-add-button": "Add query", "query-add-button-aria-label": "Add query", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 7142d64ccf6..68d631a4efe 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -491,15 +491,11 @@ "delete-query-tooltip": "Đęľęŧę qūęřy", "delete-starred-query-confirmation-text": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő pęřmäʼnęʼnŧľy đęľęŧę yőūř şŧäřřęđ qūęřy?", "edit-comment-tooltip": "Ēđįŧ čőmmęʼnŧ", - "left-pane": "Ŀęƒŧ päʼnę", "optional-description": "Åʼn őpŧįőʼnäľ đęşčřįpŧįőʼn őƒ ŵĥäŧ ŧĥę qūęřy đőęş.", "query-comment-label": "Qūęřy čőmmęʼnŧ", "query-text-label": "Qūęřy ŧęχŧ", - "right-pane": "Ŗįģĥŧ päʼnę", - "run-query-button": "Ŗūʼn qūęřy", "save-comment": "Ŝävę čőmmęʼnŧ", "star-query-tooltip": "Ŝŧäř qūęřy", - "switch-datasource-button": "Ŝŵįŧčĥ đäŧä şőūřčę äʼnđ řūʼn qūęřy", "unstar-query-tooltip": "Ůʼnşŧäř qūęřy", "update-comment-form": "Ůpđäŧę čőmmęʼnŧ ƒőřm" }, @@ -568,6 +564,12 @@ "saving-failed": "Ŝävįʼnģ řįčĥ ĥįşŧőřy ƒäįľęđ", "update-failed": "Ŗįčĥ Ħįşŧőřy ūpđäŧę ƒäįľęđ" }, + "run-query": { + "left-pane": "Ŀęƒŧ päʼnę", + "right-pane": "Ŗįģĥŧ päʼnę", + "run-query-button": "Ŗūʼn qūęřy", + "switch-datasource-button": "Ŝŵįŧčĥ đäŧä şőūřčę äʼnđ řūʼn qūęřy" + }, "secondary-actions": { "query-add-button": "Åđđ qūęřy", "query-add-button-aria-label": "Åđđ qūęřy",