Explore: Allow saving to Query Library from query row (#89381)

* wip

* Add save functionality to query row

* add success conditional

* move around translations

* Add translations

* Add key to fix test

* Add key to the right spot

* define specific save button

* WIP - Use RowActionComponents to add action without modifying the core component

* Only add component once on render

* Move logic to main explore page

* Add keyed render actions to prevent redundancy, use this to add keyed action

* Overcome the forces of dayquil to attempt to make actual sense

* Add scoped actions to query action component

* Spaces not allowed in generateName
This commit is contained in:
Kristina 2024-07-30 13:56:44 -05:00 committed by GitHub
parent e7156e7e60
commit 783ff71560
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 157 additions and 59 deletions

View File

@ -3951,10 +3951,6 @@ exports[`better eslint`] = {
"public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/explore/RichHistory/RichHistoryAddToLibraryForm.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/explore/RichHistory/RichHistoryCard.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],

View File

@ -1,22 +1,27 @@
import { css, cx } from '@emotion/css';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { CoreApp, GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { ErrorBoundaryAlert, useStyles2, useTheme2 } from '@grafana/ui';
import { DataQuery } from '@grafana/schema/dist/esm/index';
import { ErrorBoundaryAlert, Modal, useStyles2, useTheme2 } from '@grafana/ui';
import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction';
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { useNavModel } from 'app/core/hooks/useNavModel';
import { Trans } from 'app/core/internationalization';
import { Trans, t } from 'app/core/internationalization';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { useSelector } from 'app/types';
import { ExploreQueryParams } from 'app/types/explore';
import { RowActionComponents } from '../query/components/QueryActionComponent';
import { CorrelationEditorModeBar } from './CorrelationEditorModeBar';
import { ExploreActions } from './ExploreActions';
import { ExploreDrawer } from './ExploreDrawer';
import { ExplorePaneContainer } from './ExplorePaneContainer';
import { QueriesDrawerContextProvider, useQueriesDrawerContext } from './QueriesDrawer/QueriesDrawerContext';
import { AddToLibraryForm } from './QueryLibrary/AddToLibraryForm';
import RichHistoryContainer from './RichHistory/RichHistoryContainer';
import { useExplorePageTitle } from './hooks/useExplorePageTitle';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
@ -26,6 +31,7 @@ import { useTimeSrvFix } from './hooks/useTimeSrvFix';
import { isSplit, selectCorrelationDetails, selectPanesEntries } from './state/selectors';
const MIN_PANE_WIDTH = 200;
const QUERY_LIBRARY_ACTION_KEY = 'queryLibraryAction';
export default function ExplorePage(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) {
return (
@ -55,6 +61,7 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa
const correlationDetails = useSelector(selectCorrelationDetails);
const { drawerOpened, setDrawerOpened, queryLibraryAvailable } = useQueriesDrawerContext();
const showCorrelationEditorBar = config.featureToggles.correlations && (correlationDetails?.editorMode || false);
const [queryToAdd, setQueryToAdd] = useState<DataQuery | undefined>();
useEffect(() => {
//This is needed for breadcrumbs and topnav.
@ -64,6 +71,25 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa
});
}, [chrome, navModel]);
useEffect(() => {
const hasQueryLibrary = config.featureToggles.queryLibrary || false;
if (hasQueryLibrary) {
RowActionComponents.addKeyedExtraRenderAction(QUERY_LIBRARY_ACTION_KEY, {
scope: CoreApp.Explore,
queryActionComponent: (props) => (
<QueryOperationAction
key={props.key}
title={t('query-operation.header.save-to-query-library', 'Save to query library')}
icon="save"
onClick={() => {
setQueryToAdd(props.query);
}}
/>
),
});
}
}, []);
useKeyboardShortcuts();
return (
@ -105,6 +131,23 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa
/>
</ExploreDrawer>
)}
<Modal
title={t('explore.add-to-library-modal.title', 'Add query to Query Library')}
isOpen={queryToAdd !== undefined}
onDismiss={() => setQueryToAdd(undefined)}
>
<AddToLibraryForm
onCancel={() => {
setQueryToAdd(undefined);
}}
onSave={(isSuccess) => {
if (isSuccess) {
setQueryToAdd(undefined);
}
}}
query={queryToAdd!}
/>
</Modal>
</div>
);
}

View File

@ -1,19 +1,22 @@
import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { DataSourcePicker } from '@grafana/runtime';
import { AppEvents, dateTime } from '@grafana/data';
import { DataSourcePicker, getAppEvents } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { Button, InlineSwitch, Modal, RadioButtonGroup, TextArea } from '@grafana/ui';
import { Field } from '@grafana/ui/';
import { Input } from '@grafana/ui/src/components/Input/Input';
import { t } from 'app/core/internationalization';
import { Trans, t } from 'app/core/internationalization';
import { getQueryDisplayText } from 'app/core/utils/richHistory';
import { useAddQueryTemplateMutation } from 'app/features/query-library';
import { AddQueryTemplateCommand } from 'app/features/query-library/types';
import { getQueryDisplayText } from '../../../core/utils/richHistory';
import { useDatasource } from '../QueryLibrary/utils/useDatasource';
type Props = {
onCancel: () => void;
onSave: (details: QueryDetails) => void;
onSave: (isSuccess: boolean) => void;
query: DataQuery;
};
@ -22,8 +25,8 @@ export type QueryDetails = {
};
const VisibilityOptions = [
{ value: 'Public', label: 'Public' },
{ value: 'Private', label: 'Private' },
{ value: 'Public', label: t('explore.query-library.public', 'Public') },
{ value: 'Private', label: t('explore.query-library.private', 'Private') },
];
const info = t(
@ -31,17 +34,47 @@ const info = t(
`You're about to save this query. Once saved, you can easily access it in the Query Library tab for future use and reference.`
);
export const RichHistoryAddToLibraryForm = ({ onCancel, onSave, query }: Props) => {
export const AddToLibraryForm = ({ onCancel, onSave, query }: Props) => {
const { register, handleSubmit } = useForm<QueryDetails>();
const [addQueryTemplate] = useAddQueryTemplateMutation();
const handleAddQueryTemplate = async (addQueryTemplateCommand: AddQueryTemplateCommand) => {
return addQueryTemplate(addQueryTemplateCommand)
.unwrap()
.then(() => {
getAppEvents().publish({
type: AppEvents.alertSuccess.name,
payload: [
t('explore.query-library.query-template-added', 'Query template successfully added to the library'),
],
});
return true;
})
.catch(() => {
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: [
t('explore.query-library.query-template-error', 'Error attempting to add this query to the library'),
],
});
return false;
});
};
const datasource = useDatasource(query.datasource);
const displayText = useMemo(() => {
return datasource?.getQueryDisplayText?.(query) || getQueryDisplayText(query);
}, [datasource, query]);
const onSubmit = (data: QueryDetails) => {
onSave(data);
const onSubmit = async (data: QueryDetails) => {
const timestamp = dateTime().toISOString();
const temporaryDefaultTitle =
data.description || t('explore.query-library.default-description', 'Public', { timestamp: timestamp });
handleAddQueryTemplate({ title: temporaryDefaultTitle, targets: [query] }).then((isSuccess) => {
onSave(isSuccess);
});
};
return (
@ -72,10 +105,10 @@ export const RichHistoryAddToLibraryForm = ({ onCancel, onSave, query }: Props)
/>
<Modal.ButtonRow>
<Button variant="secondary" onClick={() => onCancel()} fill="outline">
Cancel
<Trans i18nKey="explore.query-library.cancel">Cancel</Trans>
</Button>
<Button variant="primary" type="submit">
Save
<Trans i18nKey="explore.query-library.save">Save</Trans>
</Button>
</Modal.ButtonRow>
</form>

View File

@ -1,14 +1,11 @@
import { t } from 'i18next';
import { useState } from 'react';
import { AppEvents, dateTime } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { Button, Modal } from '@grafana/ui';
import { isQueryLibraryEnabled, useAddQueryTemplateMutation } from 'app/features/query-library';
import { AddQueryTemplateCommand } from 'app/features/query-library/types';
import { isQueryLibraryEnabled } from 'app/features/query-library';
import { QueryDetails, RichHistoryAddToLibraryForm } from './RichHistoryAddToLibraryForm';
import { AddToLibraryForm } from '../QueryLibrary/AddToLibraryForm';
type Props = {
query: DataQuery;
@ -16,29 +13,11 @@ type Props = {
export const RichHistoryAddToLibrary = ({ query }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [addQueryTemplate, { isSuccess }] = useAddQueryTemplateMutation();
const handleAddQueryTemplate = async (addQueryTemplateCommand: AddQueryTemplateCommand) => {
const result = await addQueryTemplate(addQueryTemplateCommand);
if (!result.error) {
getAppEvents().publish({
type: AppEvents.alertSuccess.name,
payload: [
t('explore.rich-history-card.query-template-added', 'Query template successfully added to the library'),
],
});
}
};
const [hasBeenSaved, setHasBeenSaved] = useState(false);
const buttonLabel = t('explore.rich-history-card.add-to-library', 'Add to library');
const submit = (data: QueryDetails) => {
const timestamp = dateTime().toISOString();
const temporaryDefaultTitle = data.description || `Imported from Explore - ${timestamp}`;
handleAddQueryTemplate({ title: temporaryDefaultTitle, targets: [query] });
};
return isQueryLibraryEnabled() && !isSuccess ? (
return isQueryLibraryEnabled() && !hasBeenSaved ? (
<>
<Button variant="secondary" aria-label={buttonLabel} onClick={() => setIsOpen(true)}>
{buttonLabel}
@ -48,12 +27,14 @@ export const RichHistoryAddToLibrary = ({ query }: Props) => {
isOpen={isOpen}
onDismiss={() => setIsOpen(false)}
>
<RichHistoryAddToLibraryForm
<AddToLibraryForm
onCancel={() => setIsOpen(() => false)}
query={query}
onSave={(data) => {
submit(data);
setIsOpen(false);
onSave={(isSuccess) => {
if (isSuccess) {
setIsOpen(false);
setHasBeenSaved(true);
}
}}
/>
</Modal>

View File

@ -59,7 +59,7 @@ export const submitAddToQueryLibrary = async ({ description }: { description: st
const input = within(screen.getByRole('dialog')).getByLabelText('Description');
await userEvent.type(input, description);
const saveButton = screen.getByRole('button', {
name: /save/i,
name: /^save$/i,
});
await userEvent.click(saveButton);
};

View File

@ -48,7 +48,7 @@ export const convertAddQueryTemplateCommandToDataQuerySpec = (
apiVersion: API_VERSION,
kind: QueryTemplateKinds.QueryTemplate,
metadata: {
generateName: 'A' + title,
generateName: 'A' + title.replaceAll(' ', '-'),
},
spec: {
title: title,

View File

@ -1,4 +1,4 @@
import { DataQuery, DataSourceInstanceSettings, TimeRange } from '@grafana/data';
import { CoreApp, DataQuery, DataSourceInstanceSettings, TimeRange } from '@grafana/data';
interface ActionComponentProps {
query?: DataQuery;
@ -10,18 +10,41 @@ interface ActionComponentProps {
key: string | number;
}
type QueryActionComponent = (props: ActionComponentProps) => JSX.Element | null;
export type QueryActionComponent = (props: ActionComponentProps) => JSX.Element | null;
type ScopedQueryActionComponent = {
scope: CoreApp;
queryActionComponent: QueryActionComponent;
};
class QueryActionComponents {
extraRenderActions: QueryActionComponent[] = [];
/* additional actions added in core grafana are likely to be needed in only one kind of app,
and the add function may be ran multiple times by the component so it is keyed to ensure uniqueness
*/
keyedScopedExtraRenderActions: Map<string, ScopedQueryActionComponent> = new Map();
addExtraRenderAction(extra: QueryActionComponent) {
this.extraRenderActions = this.extraRenderActions.concat(extra);
}
// for adding actions that will need to be unique, even if the add function is ran multiple times
addKeyedExtraRenderAction(key: string, extra: ScopedQueryActionComponent) {
this.keyedScopedExtraRenderActions.set(key, extra);
}
// only returns actions that are not scoped to a specific CoreApp
getAllExtraRenderAction(): QueryActionComponent[] {
return this.extraRenderActions;
}
getScopedExtraRenderAction(scope: CoreApp): QueryActionComponent[] {
const scopedActions = Array.from(this.keyedScopedExtraRenderActions, (value) => value[1]).filter(
(sra) => sra.scope === scope
);
return scopedActions.map((sa) => sa.queryActionComponent);
}
}
/**

View File

@ -40,7 +40,7 @@ import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { RowActionComponents } from './QueryActionComponent';
import { QueryActionComponent, RowActionComponents } from './QueryActionComponent';
import { QueryEditorRowHeader } from './QueryEditorRowHeader';
import { QueryErrorAlert } from './QueryErrorAlert';
@ -425,9 +425,17 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop
};
renderExtraActions = () => {
const { query, queries, data, onAddQuery, dataSource } = this.props;
const { query, queries, data, onAddQuery, dataSource, app } = this.props;
const extraActions = RowActionComponents.getAllExtraRenderAction()
const unscopedActions = RowActionComponents.getAllExtraRenderAction();
let scopedActions: QueryActionComponent[] = [];
if (app !== undefined) {
scopedActions = RowActionComponents.getScopedExtraRenderAction(app);
}
const extraActions = [...unscopedActions, ...scopedActions]
.map((action, index) =>
action({
query,

View File

@ -660,10 +660,17 @@
"stop-scan": "Stop scan"
},
"query-library": {
"cancel": "Cancel",
"default-description": "Public",
"delete-query": "Delete query",
"delete-query-text": "You're about to remove this query from the query library. This action cannot be undone. Do you want to continue?",
"delete-query-title": "Delete query",
"query-deleted": "Query deleted"
"private": "Private",
"public": "Public",
"query-deleted": "Query deleted",
"query-template-added": "Query template successfully added to the library",
"query-template-error": "Error attempting to add this query to the library",
"save": "Save"
},
"rich-history": {
"close-tooltip": "Close query history",
@ -694,7 +701,6 @@
"edit-comment-tooltip": "Edit comment",
"optional-description": "An optional description of what the query does.",
"query-comment-label": "Query comment",
"query-template-added": "Query template successfully added to the library",
"query-text-label": "Query text",
"save-comment": "Save comment",
"star-query-tooltip": "Star query",
@ -1787,6 +1793,7 @@
"expand-row": "Expand query row",
"hide-response": "Hide response",
"remove-query": "Remove query",
"save-to-query-library": "Save to query library",
"show-response": "Show response",
"toggle-edit-mode": "Toggle text edit mode"
},

View File

@ -660,10 +660,17 @@
"stop-scan": "Ŝŧőp şčäʼn"
},
"query-library": {
"cancel": "Cäʼnčęľ",
"default-description": "Pūþľįč",
"delete-query": "Đęľęŧę qūęřy",
"delete-query-text": "Ÿőū'řę äþőūŧ ŧő řęmővę ŧĥįş qūęřy ƒřőm ŧĥę qūęřy ľįþřäřy. Ŧĥįş äčŧįőʼn čäʼnʼnőŧ þę ūʼnđőʼnę. Đő yőū ŵäʼnŧ ŧő čőʼnŧįʼnūę?",
"delete-query-title": "Đęľęŧę qūęřy",
"query-deleted": "Qūęřy đęľęŧęđ"
"private": "Přįväŧę",
"public": "Pūþľįč",
"query-deleted": "Qūęřy đęľęŧęđ",
"query-template-added": "Qūęřy ŧęmpľäŧę şūččęşşƒūľľy äđđęđ ŧő ŧĥę ľįþřäřy",
"query-template-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő äđđ ŧĥįş qūęřy ŧő ŧĥę ľįþřäřy",
"save": "Ŝävę"
},
"rich-history": {
"close-tooltip": "Cľőşę qūęřy ĥįşŧőřy",
@ -694,7 +701,6 @@
"edit-comment-tooltip": "Ēđįŧ čőmmęʼnŧ",
"optional-description": "Åʼn őpŧįőʼnäľ đęşčřįpŧįőʼn őƒ ŵĥäŧ ŧĥę qūęřy đőęş.",
"query-comment-label": "Qūęřy čőmmęʼnŧ",
"query-template-added": "Qūęřy ŧęmpľäŧę şūččęşşƒūľľy äđđęđ ŧő ŧĥę ľįþřäřy",
"query-text-label": "Qūęřy ŧęχŧ",
"save-comment": "Ŝävę čőmmęʼnŧ",
"star-query-tooltip": "Ŝŧäř qūęřy",
@ -1787,6 +1793,7 @@
"expand-row": "Ēχpäʼnđ qūęřy řőŵ",
"hide-response": "Ħįđę řęşpőʼnşę",
"remove-query": "Ŗęmővę qūęřy",
"save-to-query-library": "Ŝävę ŧő qūęřy ľįþřäřy",
"show-response": "Ŝĥőŵ řęşpőʼnşę",
"toggle-edit-mode": "Ŧőģģľę ŧęχŧ ęđįŧ mőđę"
},