mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Chore: Data links and Actions components refactor (#100097)
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
parent
bce05cd48d
commit
f5c049012b
@ -1367,13 +1367,6 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "\'@grafana/ui/src/themes\' import is restricted from being used by a pattern. Import from the public export instead.", "2"],
|
[0, 0, 0, "\'@grafana/ui/src/themes\' import is restricted from being used by a pattern. Import from the public export instead.", "2"],
|
||||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"]
|
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"]
|
||||||
],
|
],
|
||||||
"public/app/features/actions/ActionsListItem.tsx:5381": [
|
|
||||||
[0, 0, 0, "\'@grafana/ui/src/components/Icon/Icon\' import is restricted from being used by a pattern. Import from the public export instead.", "0"],
|
|
||||||
[0, 0, 0, "\'@grafana/ui/src/components/IconButton/IconButton\' import is restricted from being used by a pattern. Import from the public export instead.", "1"],
|
|
||||||
[0, 0, 0, "\'@grafana/ui/src/themes\' import is restricted from being used by a pattern. Import from the public export instead.", "2"],
|
|
||||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
|
|
||||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"]
|
|
||||||
],
|
|
||||||
"public/app/features/actions/ParamsEditor.tsx:5381": [
|
"public/app/features/actions/ParamsEditor.tsx:5381": [
|
||||||
[0, 0, 0, "\'@grafana/ui/src/components/IconButton/IconButton\' import is restricted from being used by a pattern. Import from the public export instead.", "0"],
|
[0, 0, 0, "\'@grafana/ui/src/components/IconButton/IconButton\' import is restricted from being used by a pattern. Import from the public export instead.", "0"],
|
||||||
[0, 0, 0, "\'@grafana/ui/src/components/Input/Input\' import is restricted from being used by a pattern. Import from the public export instead.", "1"],
|
[0, 0, 0, "\'@grafana/ui/src/components/Input/Input\' import is restricted from being used by a pattern. Import from the public export instead.", "1"],
|
||||||
|
@ -16,6 +16,7 @@ export interface Action {
|
|||||||
// once multiple types are valid, usage of this will need to be optional
|
// once multiple types are valid, usage of this will need to be optional
|
||||||
[ActionType.Fetch]: FetchOptions;
|
[ActionType.Fetch]: FetchOptions;
|
||||||
confirmation?: string;
|
confirmation?: string;
|
||||||
|
oneClick?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -25,6 +26,7 @@ export interface ActionModel<T = any> {
|
|||||||
title: string;
|
title: string;
|
||||||
onClick: (event: any, origin?: any) => void;
|
onClick: (event: any, origin?: any) => void;
|
||||||
confirmation?: string;
|
confirmation?: string;
|
||||||
|
oneClick?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FetchOptions {
|
interface FetchOptions {
|
||||||
|
@ -17,6 +17,7 @@ interface DataLinkEditorProps {
|
|||||||
value: DataLink;
|
value: DataLink;
|
||||||
suggestions: VariableSuggestion[];
|
suggestions: VariableSuggestion[];
|
||||||
onChange: (index: number, link: DataLink, callback?: () => void) => void;
|
onChange: (index: number, link: DataLink, callback?: () => void) => void;
|
||||||
|
showOneClick?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
@ -30,58 +31,63 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const DataLinkEditor = memo(({ index, value, onChange, suggestions, isLast }: DataLinkEditorProps) => {
|
export const DataLinkEditor = memo(
|
||||||
const styles = useStyles2(getStyles);
|
({ index, value, onChange, suggestions, isLast, showOneClick = false }: DataLinkEditorProps) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const onUrlChange = (url: string, callback?: () => void) => {
|
const onUrlChange = (url: string, callback?: () => void) => {
|
||||||
onChange(index, { ...value, url }, callback);
|
onChange(index, { ...value, url }, callback);
|
||||||
};
|
};
|
||||||
const onTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onChange(index, { ...value, title: event.target.value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const onOpenInNewTabChanged = () => {
|
const onTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
onChange(index, { ...value, targetBlank: !value.targetBlank });
|
onChange(index, { ...value, title: event.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOneClickChanged = () => {
|
const onOpenInNewTabChanged = () => {
|
||||||
onChange(index, { ...value, oneClick: !value.oneClick });
|
onChange(index, { ...value, targetBlank: !value.targetBlank });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const onOneClickChanged = () => {
|
||||||
<div className={styles.listItem}>
|
onChange(index, { ...value, oneClick: !value.oneClick });
|
||||||
<Field label="Title">
|
};
|
||||||
<Input value={value.title} onChange={onTitleChange} placeholder="Show details" />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="URL">
|
return (
|
||||||
<DataLinkInput value={value.url} onChange={onUrlChange} suggestions={suggestions} />
|
<div className={styles.listItem}>
|
||||||
</Field>
|
<Field label="Title">
|
||||||
|
<Input value={value.title} onChange={onTitleChange} placeholder="Show details" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field label="Open in new tab">
|
<Field label="URL">
|
||||||
<Switch value={value.targetBlank || false} onChange={onOpenInNewTabChanged} />
|
<DataLinkInput value={value.url} onChange={onUrlChange} suggestions={suggestions} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field label="Open in new tab">
|
||||||
label={t('grafana-ui.data-link-inline-editor.one-click', 'One click')}
|
<Switch value={value.targetBlank || false} onChange={onOpenInNewTabChanged} />
|
||||||
description={t(
|
</Field>
|
||||||
'grafana-ui.data-link-editor-modal.one-click-description',
|
|
||||||
'Only one link can have one click enabled at a time'
|
{showOneClick && (
|
||||||
|
<Field
|
||||||
|
label={t('grafana-ui.data-link-inline-editor.one-click', 'One click')}
|
||||||
|
description={t(
|
||||||
|
'grafana-ui.data-link-editor-modal.one-click-description',
|
||||||
|
'Only one link can have one click enabled at a time'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Switch value={value.oneClick || false} onChange={onOneClickChanged} />
|
||||||
|
</Field>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<Switch value={value.oneClick || false} onChange={onOneClickChanged} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{isLast && (
|
{isLast && (
|
||||||
<div className={styles.infoText}>
|
<div className={styles.infoText}>
|
||||||
<Trans i18nKey="grafana-ui.data-link-editor.info">
|
<Trans i18nKey="grafana-ui.data-link-editor.info">
|
||||||
With data links you can reference data variables like series name, labels and values. Type CMD+Space,
|
With data links you can reference data variables like series name, labels and values. Type CMD+Space,
|
||||||
CTRL+Space, or $ to open variable suggestions.
|
CTRL+Space, or $ to open variable suggestions.
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
DataLinkEditor.displayName = 'DataLinkEditor';
|
DataLinkEditor.displayName = 'DataLinkEditor';
|
||||||
|
@ -14,14 +14,16 @@ interface DataLinkEditorModalContentProps {
|
|||||||
getSuggestions: () => VariableSuggestion[];
|
getSuggestions: () => VariableSuggestion[];
|
||||||
onSave: (index: number, ink: DataLink) => void;
|
onSave: (index: number, ink: DataLink) => void;
|
||||||
onCancel: (index: number) => void;
|
onCancel: (index: number) => void;
|
||||||
|
showOneClick?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DataLinkEditorModalContent = ({
|
export const DataLinkEditorModalContent = ({
|
||||||
link,
|
link,
|
||||||
index,
|
index,
|
||||||
getSuggestions,
|
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
getSuggestions,
|
||||||
|
showOneClick,
|
||||||
}: DataLinkEditorModalContentProps) => {
|
}: DataLinkEditorModalContentProps) => {
|
||||||
const [dirtyLink, setDirtyLink] = useState(link);
|
const [dirtyLink, setDirtyLink] = useState(link);
|
||||||
return (
|
return (
|
||||||
@ -30,10 +32,11 @@ export const DataLinkEditorModalContent = ({
|
|||||||
value={dirtyLink}
|
value={dirtyLink}
|
||||||
index={index}
|
index={index}
|
||||||
isLast={false}
|
isLast={false}
|
||||||
suggestions={getSuggestions()}
|
|
||||||
onChange={(index, link) => {
|
onChange={(index, link) => {
|
||||||
setDirtyLink(link);
|
setDirtyLink(link);
|
||||||
}}
|
}}
|
||||||
|
suggestions={getSuggestions()}
|
||||||
|
showOneClick={showOneClick}
|
||||||
/>
|
/>
|
||||||
<Modal.ButtonRow>
|
<Modal.ButtonRow>
|
||||||
<Button variant="secondary" onClick={() => onCancel(index)} fill="outline">
|
<Button variant="secondary" onClick={() => onCancel(index)} fill="outline">
|
||||||
@ -43,6 +46,7 @@ export const DataLinkEditorModalContent = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSave(index, dirtyLink);
|
onSave(index, dirtyLink);
|
||||||
}}
|
}}
|
||||||
|
disabled={dirtyLink.title.trim() === '' || dirtyLink.url.trim() === ''}
|
||||||
>
|
>
|
||||||
<Trans i18nKey="grafana-ui.data-link-editor-modal.save">Save</Trans>
|
<Trans i18nKey="grafana-ui.data-link-editor-modal.save">Save</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -1,198 +1,26 @@
|
|||||||
import { css } from '@emotion/css';
|
import { DataLink, VariableSuggestion } from '@grafana/data';
|
||||||
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
|
|
||||||
import { cloneDeep } from 'lodash';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { DataFrame, DataLink, GrafanaTheme2, VariableSuggestion } from '@grafana/data';
|
|
||||||
|
|
||||||
import { useStyles2 } from '../../../themes';
|
|
||||||
import { Trans } from '../../../utils/i18n';
|
|
||||||
import { Button } from '../../Button';
|
|
||||||
import { Modal } from '../../Modal/Modal';
|
|
||||||
|
|
||||||
import { DataLinkEditorModalContent } from './DataLinkEditorModalContent';
|
import { DataLinkEditorModalContent } from './DataLinkEditorModalContent';
|
||||||
import { DataLinksListItem } from './DataLinksListItem';
|
import { DataLinksInlineEditorBase, DataLinksInlineEditorBaseProps } from './DataLinksInlineEditorBase';
|
||||||
|
|
||||||
interface DataLinksInlineEditorProps {
|
type DataLinksInlineEditorProps = Omit<DataLinksInlineEditorBaseProps<DataLink>, 'children' | 'type' | 'items'> & {
|
||||||
links?: DataLink[];
|
links?: DataLink[];
|
||||||
onChange: (links: DataLink[]) => void;
|
|
||||||
getSuggestions: () => VariableSuggestion[];
|
|
||||||
data: DataFrame[];
|
|
||||||
showOneClick?: boolean;
|
showOneClick?: boolean;
|
||||||
}
|
getSuggestions: () => VariableSuggestion[];
|
||||||
|
|
||||||
export const DataLinksInlineEditor = ({
|
|
||||||
links,
|
|
||||||
onChange,
|
|
||||||
getSuggestions,
|
|
||||||
data,
|
|
||||||
showOneClick = false,
|
|
||||||
}: DataLinksInlineEditorProps) => {
|
|
||||||
const [editIndex, setEditIndex] = useState<number | null>(null);
|
|
||||||
const [isNew, setIsNew] = useState(false);
|
|
||||||
|
|
||||||
const [linksSafe, setLinksSafe] = useState<DataLink[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLinksSafe(links ?? []);
|
|
||||||
}, [links]);
|
|
||||||
|
|
||||||
const styles = useStyles2(getDataLinksInlineEditorStyles);
|
|
||||||
const isEditing = editIndex !== null;
|
|
||||||
|
|
||||||
const onDataLinkChange = (index: number, link: DataLink) => {
|
|
||||||
if (isNew) {
|
|
||||||
if (link.title.trim() === '' && link.url.trim() === '') {
|
|
||||||
setIsNew(false);
|
|
||||||
setEditIndex(null);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
setEditIndex(null);
|
|
||||||
setIsNew(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (link.oneClick === true) {
|
|
||||||
linksSafe.forEach((link) => {
|
|
||||||
if (link.oneClick) {
|
|
||||||
link.oneClick = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = cloneDeep(linksSafe);
|
|
||||||
update[index] = link;
|
|
||||||
onChange(update);
|
|
||||||
setEditIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDataLinkAdd = () => {
|
|
||||||
let update = cloneDeep(linksSafe);
|
|
||||||
setEditIndex(update.length);
|
|
||||||
setIsNew(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDataLinkCancel = (index: number) => {
|
|
||||||
if (isNew) {
|
|
||||||
setIsNew(false);
|
|
||||||
}
|
|
||||||
setEditIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDataLinkRemove = (index: number) => {
|
|
||||||
const update = cloneDeep(linksSafe);
|
|
||||||
update.splice(index, 1);
|
|
||||||
onChange(update);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragEnd = (result: DropResult) => {
|
|
||||||
if (!links || !result.destination) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = cloneDeep(linksSafe);
|
|
||||||
const link = update[result.source.index];
|
|
||||||
|
|
||||||
update.splice(result.source.index, 1);
|
|
||||||
update.splice(result.destination.index, 0, link);
|
|
||||||
|
|
||||||
setLinksSafe(update);
|
|
||||||
onChange(update);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
{/* one-link placeholder */}
|
|
||||||
{showOneClick && linksSafe.length > 0 && (
|
|
||||||
<div className={styles.oneClickOverlay}>
|
|
||||||
<span className={styles.oneClickSpan}>
|
|
||||||
<Trans i18nKey="grafana-ui.data-links-inline-editor.one-click-link">One-click link</Trans>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
|
||||||
<Droppable droppableId="sortable-links" direction="vertical">
|
|
||||||
{(provided) => (
|
|
||||||
<div
|
|
||||||
className={styles.wrapper}
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.droppableProps}
|
|
||||||
style={{ paddingTop: showOneClick && linksSafe.length > 0 ? '28px' : '0px' }}
|
|
||||||
>
|
|
||||||
{linksSafe.map((link, idx) => {
|
|
||||||
const key = `${link.title}/${idx}`;
|
|
||||||
return (
|
|
||||||
<DataLinksListItem
|
|
||||||
key={key}
|
|
||||||
index={idx}
|
|
||||||
link={link}
|
|
||||||
onChange={onDataLinkChange}
|
|
||||||
onEdit={() => setEditIndex(idx)}
|
|
||||||
onRemove={() => onDataLinkRemove(idx)}
|
|
||||||
data={data}
|
|
||||||
itemKey={key}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{provided.placeholder}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
</DragDropContext>
|
|
||||||
|
|
||||||
{isEditing && editIndex !== null && (
|
|
||||||
<Modal
|
|
||||||
title="Edit link"
|
|
||||||
isOpen={true}
|
|
||||||
closeOnBackdropClick={false}
|
|
||||||
onDismiss={() => {
|
|
||||||
onDataLinkCancel(editIndex);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DataLinkEditorModalContent
|
|
||||||
index={editIndex}
|
|
||||||
link={isNew ? { title: '', url: '' } : linksSafe[editIndex]}
|
|
||||||
data={data}
|
|
||||||
onSave={onDataLinkChange}
|
|
||||||
onCancel={onDataLinkCancel}
|
|
||||||
getSuggestions={getSuggestions}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button size="sm" icon="plus" onClick={onDataLinkAdd} variant="secondary" className={styles.button}>
|
|
||||||
<Trans i18nKey="grafana-ui.data-links-inline-editor.add-link">Add link</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDataLinksInlineEditorStyles = (theme: GrafanaTheme2) => ({
|
export const DataLinksInlineEditor = ({ links, getSuggestions, showOneClick, ...rest }: DataLinksInlineEditorProps) => (
|
||||||
container: css({
|
<DataLinksInlineEditorBase<DataLink> type="link" items={links} {...rest}>
|
||||||
position: 'relative',
|
{(item, index, onSave, onCancel) => (
|
||||||
}),
|
<DataLinkEditorModalContent
|
||||||
wrapper: css({
|
index={index}
|
||||||
marginBottom: theme.spacing(2),
|
link={item ?? { title: '', url: '' }}
|
||||||
display: 'flex',
|
data={rest.data}
|
||||||
flexDirection: 'column',
|
onSave={onSave}
|
||||||
}),
|
onCancel={onCancel}
|
||||||
oneClickOverlay: css({
|
getSuggestions={getSuggestions}
|
||||||
border: `2px dashed ${theme.colors.text.link}`,
|
showOneClick={showOneClick ?? true}
|
||||||
fontSize: 10,
|
/>
|
||||||
color: theme.colors.text.primary,
|
)}
|
||||||
marginBottom: theme.spacing(1),
|
</DataLinksInlineEditorBase>
|
||||||
position: 'absolute',
|
);
|
||||||
width: '100%',
|
|
||||||
height: '92px',
|
|
||||||
}),
|
|
||||||
oneClickSpan: css({
|
|
||||||
padding: 10,
|
|
||||||
// Negates the padding on the span from moving the underlying link
|
|
||||||
marginBottom: -10,
|
|
||||||
display: 'inline-block',
|
|
||||||
}),
|
|
||||||
button: css({
|
|
||||||
marginLeft: theme.spacing(1),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
@ -0,0 +1,191 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
|
||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Action, DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
|
import { useStyles2 } from '../../../themes';
|
||||||
|
import { t } from '../../../utils/i18n';
|
||||||
|
import { Button } from '../../Button';
|
||||||
|
import { Modal } from '../../Modal/Modal';
|
||||||
|
|
||||||
|
import { DataLinksListItemBase } from './DataLinksListItemBase';
|
||||||
|
|
||||||
|
export interface DataLinksInlineEditorBaseProps<T extends DataLink | Action> {
|
||||||
|
type: 'link' | 'action';
|
||||||
|
items?: T[];
|
||||||
|
onChange: (items: T[]) => void;
|
||||||
|
data: DataFrame[];
|
||||||
|
children: (
|
||||||
|
item: T,
|
||||||
|
index: number,
|
||||||
|
onSave: (index: number, item: T) => void,
|
||||||
|
onCancel: (index: number) => void
|
||||||
|
) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
export function DataLinksInlineEditorBase<T extends DataLink | Action>({
|
||||||
|
type,
|
||||||
|
items,
|
||||||
|
onChange,
|
||||||
|
data,
|
||||||
|
children,
|
||||||
|
}: DataLinksInlineEditorBaseProps<T>) {
|
||||||
|
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||||
|
const [isNew, setIsNew] = useState(false);
|
||||||
|
|
||||||
|
const [itemsSafe, setItemsSafe] = useState<T[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItemsSafe(items ?? []);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const styles = useStyles2(getDataLinksInlineEditorStyles);
|
||||||
|
const isEditing = editIndex !== null;
|
||||||
|
|
||||||
|
const _onChange = (index: number, item: T) => {
|
||||||
|
if (isNew) {
|
||||||
|
const title = item.title;
|
||||||
|
// @ts-ignore - https://github.com/microsoft/TypeScript/issues/27808
|
||||||
|
const url = item.url ?? item.fetch?.url ?? '';
|
||||||
|
|
||||||
|
if (title.trim() === '' && url.trim() === '') {
|
||||||
|
setIsNew(false);
|
||||||
|
setEditIndex(null);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
setEditIndex(null);
|
||||||
|
setIsNew(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.oneClick === true) {
|
||||||
|
itemsSafe.forEach((item) => {
|
||||||
|
if (item.oneClick) {
|
||||||
|
item.oneClick = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = cloneDeep(itemsSafe);
|
||||||
|
update[index] = item;
|
||||||
|
onChange(update);
|
||||||
|
setEditIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _onCancel = (index: number) => {
|
||||||
|
if (isNew) {
|
||||||
|
setIsNew(false);
|
||||||
|
}
|
||||||
|
setEditIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataLinkAdd = () => {
|
||||||
|
let update = cloneDeep(itemsSafe);
|
||||||
|
setEditIndex(update.length);
|
||||||
|
setIsNew(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataLinkRemove = (index: number) => {
|
||||||
|
const update = cloneDeep(itemsSafe);
|
||||||
|
update.splice(index, 1);
|
||||||
|
onChange(update);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnd = (result: DropResult) => {
|
||||||
|
if (items == null || result.destination == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = cloneDeep(itemsSafe);
|
||||||
|
const link = update[result.source.index];
|
||||||
|
|
||||||
|
update.splice(result.source.index, 1);
|
||||||
|
update.splice(result.destination.index, 0, link);
|
||||||
|
|
||||||
|
setItemsSafe(update);
|
||||||
|
onChange(update);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getItemText = (action: 'edit' | 'add') => {
|
||||||
|
let text = '';
|
||||||
|
switch (type) {
|
||||||
|
case 'link':
|
||||||
|
text =
|
||||||
|
action === 'edit'
|
||||||
|
? t('grafana-ui.data-links-inline-editor.edit-link', 'Edit link')
|
||||||
|
: t('grafana-ui.data-links-inline-editor.add-link', 'Add link');
|
||||||
|
break;
|
||||||
|
case 'action':
|
||||||
|
text =
|
||||||
|
action === 'edit'
|
||||||
|
? t('grafana-ui.action-editor.inline.edit-action', 'Edit action')
|
||||||
|
: t('grafana-ui.action-editor.inline.add-action', 'Add action');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
|
<Droppable droppableId="sortable-links" direction="vertical">
|
||||||
|
{(provided) => (
|
||||||
|
<div className={styles.wrapper} ref={provided.innerRef} {...provided.droppableProps}>
|
||||||
|
{itemsSafe.map((item, idx) => {
|
||||||
|
const key = `${item.title}/${idx}`;
|
||||||
|
return (
|
||||||
|
<DataLinksListItemBase<T>
|
||||||
|
key={key}
|
||||||
|
index={idx}
|
||||||
|
item={item}
|
||||||
|
onChange={_onChange}
|
||||||
|
onEdit={() => setEditIndex(idx)}
|
||||||
|
onRemove={() => onDataLinkRemove(idx)}
|
||||||
|
data={data}
|
||||||
|
itemKey={key}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
|
||||||
|
{isEditing && editIndex !== null && (
|
||||||
|
<Modal
|
||||||
|
title={getItemText(isNew ? 'add' : 'edit')}
|
||||||
|
isOpen={true}
|
||||||
|
closeOnBackdropClick={false}
|
||||||
|
onDismiss={() => {
|
||||||
|
_onCancel(editIndex);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children(itemsSafe[editIndex], editIndex, _onChange, _onCancel)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="sm" icon="plus" onClick={onDataLinkAdd} variant="secondary" className={styles.button}>
|
||||||
|
{getItemText('add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDataLinksInlineEditorStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
container: css({
|
||||||
|
position: 'relative',
|
||||||
|
}),
|
||||||
|
wrapper: css({
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}),
|
||||||
|
button: css({
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
}),
|
||||||
|
});
|
@ -13,7 +13,7 @@ const baseLink = {
|
|||||||
function setupTestContext(options: Partial<DataLinksListItemProps>) {
|
function setupTestContext(options: Partial<DataLinksListItemProps>) {
|
||||||
const defaults: DataLinksListItemProps = {
|
const defaults: DataLinksListItemProps = {
|
||||||
index: 0,
|
index: 0,
|
||||||
link: baseLink,
|
item: baseLink,
|
||||||
data: [],
|
data: [],
|
||||||
onChange: jest.fn(),
|
onChange: jest.fn(),
|
||||||
onEdit: jest.fn(),
|
onEdit: jest.fn(),
|
||||||
@ -42,11 +42,11 @@ function setupTestContext(options: Partial<DataLinksListItemProps>) {
|
|||||||
describe('DataLinksListItem', () => {
|
describe('DataLinksListItem', () => {
|
||||||
describe('when link has title', () => {
|
describe('when link has title', () => {
|
||||||
it('then the link title should be visible', () => {
|
it('then the link title should be visible', () => {
|
||||||
const link = {
|
const item = {
|
||||||
...baseLink,
|
...baseLink,
|
||||||
title: 'Some Data Link Title',
|
title: 'Some Data Link Title',
|
||||||
};
|
};
|
||||||
setupTestContext({ link });
|
setupTestContext({ item });
|
||||||
|
|
||||||
expect(screen.getByText(/some data link title/i)).toBeInTheDocument();
|
expect(screen.getByText(/some data link title/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -54,62 +54,14 @@ describe('DataLinksListItem', () => {
|
|||||||
|
|
||||||
describe('when link has url', () => {
|
describe('when link has url', () => {
|
||||||
it('then the link url should be visible', () => {
|
it('then the link url should be visible', () => {
|
||||||
const link = {
|
const item = {
|
||||||
...baseLink,
|
...baseLink,
|
||||||
url: 'http://localhost:3000',
|
url: 'http://localhost:3000',
|
||||||
};
|
};
|
||||||
setupTestContext({ link });
|
setupTestContext({ item });
|
||||||
|
|
||||||
expect(screen.getByText(/http:\/\/localhost\:3000/i)).toBeInTheDocument();
|
expect(screen.getByText(/http:\/\/localhost\:3000/i)).toBeInTheDocument();
|
||||||
expect(screen.getByTitle(/http:\/\/localhost\:3000/i)).toBeInTheDocument();
|
expect(screen.getByTitle(/http:\/\/localhost\:3000/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when link is missing title', () => {
|
|
||||||
it('then the link title should be replaced by [Data link title not provided]', () => {
|
|
||||||
const link = {
|
|
||||||
...baseLink,
|
|
||||||
title: undefined as unknown as string,
|
|
||||||
};
|
|
||||||
setupTestContext({ link });
|
|
||||||
|
|
||||||
expect(screen.getByText(/data link title not provided/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when link is missing url', () => {
|
|
||||||
it('then the link url should be replaced by [Data link url not provided]', () => {
|
|
||||||
const link = {
|
|
||||||
...baseLink,
|
|
||||||
url: undefined as unknown as string,
|
|
||||||
};
|
|
||||||
setupTestContext({ link });
|
|
||||||
|
|
||||||
expect(screen.getByText(/data link url not provided/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when link title is empty', () => {
|
|
||||||
it('then the link title should be replaced by [Data link title not provided]', () => {
|
|
||||||
const link = {
|
|
||||||
...baseLink,
|
|
||||||
title: ' ',
|
|
||||||
};
|
|
||||||
setupTestContext({ link });
|
|
||||||
|
|
||||||
expect(screen.getByText(/data link title not provided/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when link url is empty', () => {
|
|
||||||
it('then the link url should be replaced by [Data link url not provided]', () => {
|
|
||||||
const link = {
|
|
||||||
...baseLink,
|
|
||||||
url: ' ',
|
|
||||||
};
|
|
||||||
setupTestContext({ link });
|
|
||||||
|
|
||||||
expect(screen.getByText(/data link url not provided/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -1,119 +1,6 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { DataLink } from '@grafana/data';
|
||||||
import { Draggable } from '@hello-pangea/dnd';
|
|
||||||
|
|
||||||
import { DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data';
|
import { DataLinksListItemBase, DataLinksListItemBaseProps } from './DataLinksListItemBase';
|
||||||
|
|
||||||
import { useStyles2 } from '../../../themes';
|
export const DataLinksListItem = DataLinksListItemBase<DataLink>;
|
||||||
import { t } from '../../../utils/i18n';
|
export type DataLinksListItemProps = DataLinksListItemBaseProps<DataLink>;
|
||||||
import { Badge } from '../../Badge/Badge';
|
|
||||||
import { Icon } from '../../Icon/Icon';
|
|
||||||
import { IconButton } from '../../IconButton/IconButton';
|
|
||||||
|
|
||||||
export interface DataLinksListItemProps {
|
|
||||||
index: number;
|
|
||||||
link: DataLink;
|
|
||||||
data: DataFrame[];
|
|
||||||
onChange: (index: number, link: DataLink) => void;
|
|
||||||
onEdit: () => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
isEditing?: boolean;
|
|
||||||
itemKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DataLinksListItem = ({ link, onEdit, onRemove, index, itemKey }: DataLinksListItemProps) => {
|
|
||||||
const styles = useStyles2(getDataLinkListItemStyles);
|
|
||||||
const { title = '', url = '', oneClick = false } = link;
|
|
||||||
|
|
||||||
const hasTitle = title.trim() !== '';
|
|
||||||
const hasUrl = url.trim() !== '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Draggable key={itemKey} draggableId={itemKey} index={index}>
|
|
||||||
{(provided) => (
|
|
||||||
<div
|
|
||||||
className={cx(styles.wrapper, styles.dragRow)}
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.draggableProps}
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<div className={styles.linkDetails}>
|
|
||||||
<div className={cx(styles.url, !hasUrl && styles.notConfigured)}>
|
|
||||||
{hasTitle ? title : 'Data link title not provided'}
|
|
||||||
</div>
|
|
||||||
<div className={cx(styles.url, !hasUrl && styles.notConfigured)} title={url}>
|
|
||||||
{hasUrl ? url : 'Data link url not provided'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.icons}>
|
|
||||||
{oneClick && (
|
|
||||||
<Badge
|
|
||||||
color="blue"
|
|
||||||
text={t('grafana-ui.data-links-inline-editor.one-click', 'One click')}
|
|
||||||
tooltip={t('grafana-ui.data-links-inline-editor.one-click-enabled', 'One click enabled')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit data link" />
|
|
||||||
<IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove data link" />
|
|
||||||
<div className={styles.dragIcon} {...provided.dragHandleProps}>
|
|
||||||
<Icon name="draggabledots" size="lg" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDataLinkListItemStyles = (theme: GrafanaTheme2) => {
|
|
||||||
return {
|
|
||||||
wrapper: css({
|
|
||||||
display: 'flex',
|
|
||||||
flexGrow: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '5px 0 5px 10px',
|
|
||||||
borderRadius: theme.shape.radius.default,
|
|
||||||
background: theme.colors.background.secondary,
|
|
||||||
gap: 8,
|
|
||||||
}),
|
|
||||||
linkDetails: css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
flexGrow: 1,
|
|
||||||
maxWidth: `calc(100% - 100px)`,
|
|
||||||
}),
|
|
||||||
notConfigured: css({
|
|
||||||
fontStyle: 'italic',
|
|
||||||
}),
|
|
||||||
title: css({
|
|
||||||
color: theme.colors.text.primary,
|
|
||||||
fontSize: theme.typography.size.sm,
|
|
||||||
fontWeight: theme.typography.fontWeightMedium,
|
|
||||||
}),
|
|
||||||
url: css({
|
|
||||||
color: theme.colors.text.secondary,
|
|
||||||
fontSize: theme.typography.size.sm,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}),
|
|
||||||
dragRow: css({
|
|
||||||
position: 'relative',
|
|
||||||
margin: '8px',
|
|
||||||
}),
|
|
||||||
icons: css({
|
|
||||||
display: 'flex',
|
|
||||||
padding: 6,
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
}),
|
|
||||||
dragIcon: css({
|
|
||||||
cursor: 'grab',
|
|
||||||
color: theme.colors.text.secondary,
|
|
||||||
margin: theme.spacing(0, 0.5),
|
|
||||||
}),
|
|
||||||
icon: css({
|
|
||||||
color: theme.colors.text.secondary,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
@ -1,27 +1,41 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { Draggable } from '@hello-pangea/dnd';
|
import { Draggable } from '@hello-pangea/dnd';
|
||||||
|
|
||||||
import { Action, DataFrame, GrafanaTheme2 } from '@grafana/data';
|
import { Action, DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Icon } from '@grafana/ui/src/components/Icon/Icon';
|
|
||||||
import { IconButton } from '@grafana/ui/src/components/IconButton/IconButton';
|
|
||||||
import { useStyles2 } from '@grafana/ui/src/themes';
|
|
||||||
|
|
||||||
export interface ActionsListItemProps {
|
import { useStyles2 } from '../../../themes';
|
||||||
|
import { t } from '../../../utils/i18n';
|
||||||
|
import { Badge } from '../../Badge/Badge';
|
||||||
|
import { Icon } from '../../Icon/Icon';
|
||||||
|
import { IconButton } from '../../IconButton/IconButton';
|
||||||
|
|
||||||
|
export interface DataLinksListItemBaseProps<T extends DataLink | Action> {
|
||||||
index: number;
|
index: number;
|
||||||
action: Action;
|
item: T;
|
||||||
data: DataFrame[];
|
data: DataFrame[];
|
||||||
onChange: (index: number, action: Action) => void;
|
onChange: (index: number, item: T) => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
isEditing?: boolean;
|
isEditing?: boolean;
|
||||||
itemKey: string;
|
itemKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: ActionsListItemProps) => {
|
/** @internal */
|
||||||
const styles = useStyles2(getActionListItemStyles);
|
export function DataLinksListItemBase<T extends DataLink | Action>({
|
||||||
const { title = '' } = action;
|
item,
|
||||||
|
onEdit,
|
||||||
|
onRemove,
|
||||||
|
index,
|
||||||
|
itemKey,
|
||||||
|
}: DataLinksListItemBaseProps<T>) {
|
||||||
|
const styles = useStyles2(getDataLinkListItemStyles);
|
||||||
|
const { title = '', oneClick = false } = item;
|
||||||
|
|
||||||
|
// @ts-ignore - https://github.com/microsoft/TypeScript/issues/27808
|
||||||
|
const url = item.url ?? item.fetch?.url ?? '';
|
||||||
|
|
||||||
const hasTitle = title.trim() !== '';
|
const hasTitle = title.trim() !== '';
|
||||||
|
const hasUrl = url.trim() !== '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Draggable key={itemKey} draggableId={itemKey} index={index}>
|
<Draggable key={itemKey} draggableId={itemKey} index={index}>
|
||||||
@ -34,12 +48,32 @@ export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: Act
|
|||||||
>
|
>
|
||||||
<div className={styles.linkDetails}>
|
<div className={styles.linkDetails}>
|
||||||
<div className={cx(styles.url, !hasTitle && styles.notConfigured)}>
|
<div className={cx(styles.url, !hasTitle && styles.notConfigured)}>
|
||||||
{hasTitle ? title : 'Action title not provided'}
|
{hasTitle ? title : t('grafana-ui.data-links-inline-editor.title-not-provided', 'Title not provided')}
|
||||||
|
</div>
|
||||||
|
<div className={cx(styles.url, !hasUrl && styles.notConfigured)} title={url}>
|
||||||
|
{hasUrl ? url : t('grafana-ui.data-links-inline-editor.url-not-provided', 'Data link url not provided')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.icons}>
|
<div className={styles.icons}>
|
||||||
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit action" />
|
{oneClick && (
|
||||||
<IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove action" />
|
<Badge
|
||||||
|
color="blue"
|
||||||
|
text={t('grafana-ui.data-links-inline-editor.one-click', 'One click')}
|
||||||
|
tooltip={t('grafana-ui.data-links-inline-editor.one-click-enabled', 'One click enabled')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
name="pen"
|
||||||
|
onClick={onEdit}
|
||||||
|
className={styles.icon}
|
||||||
|
tooltip={t('grafana-ui.data-links-inline-editor.tooltip-edit', 'Edit')}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
name="trash-alt"
|
||||||
|
onClick={onRemove}
|
||||||
|
className={styles.icon}
|
||||||
|
tooltip={t('grafana-ui.data-links-inline-editor.tooltip-remove', 'Remove')}
|
||||||
|
/>
|
||||||
<div className={styles.dragIcon} {...provided.dragHandleProps}>
|
<div className={styles.dragIcon} {...provided.dragHandleProps}>
|
||||||
<Icon name="draggabledots" size="lg" />
|
<Icon name="draggabledots" size="lg" />
|
||||||
</div>
|
</div>
|
||||||
@ -48,9 +82,9 @@ export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: Act
|
|||||||
)}
|
)}
|
||||||
</Draggable>
|
</Draggable>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const getActionListItemStyles = (theme: GrafanaTheme2) => {
|
const getDataLinkListItemStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
wrapper: css({
|
wrapper: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -66,6 +100,7 @@ const getActionListItemStyles = (theme: GrafanaTheme2) => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
|
maxWidth: `calc(100% - 100px)`,
|
||||||
}),
|
}),
|
||||||
errored: css({
|
errored: css({
|
||||||
color: theme.colors.error.text,
|
color: theme.colors.error.text,
|
||||||
@ -85,15 +120,6 @@ const getActionListItemStyles = (theme: GrafanaTheme2) => {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
maxWidth: `calc(100% - 100px)`,
|
|
||||||
}),
|
|
||||||
dragIcon: css({
|
|
||||||
cursor: 'grab',
|
|
||||||
color: theme.colors.text.secondary,
|
|
||||||
margin: theme.spacing(0, 0.5),
|
|
||||||
}),
|
|
||||||
icon: css({
|
|
||||||
color: theme.colors.text.secondary,
|
|
||||||
}),
|
}),
|
||||||
dragRow: css({
|
dragRow: css({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@ -105,5 +131,13 @@ const getActionListItemStyles = (theme: GrafanaTheme2) => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
}),
|
}),
|
||||||
|
dragIcon: css({
|
||||||
|
cursor: 'grab',
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
margin: theme.spacing(0, 0.5),
|
||||||
|
}),
|
||||||
|
icon: css({
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
@ -169,6 +169,10 @@ export { MenuGroup, type MenuItemsGroup, type MenuGroupProps } from './Menu/Menu
|
|||||||
export { MenuItem, type MenuItemProps } from './Menu/MenuItem';
|
export { MenuItem, type MenuItemProps } from './Menu/MenuItem';
|
||||||
export { WithContextMenu } from './ContextMenu/WithContextMenu';
|
export { WithContextMenu } from './ContextMenu/WithContextMenu';
|
||||||
export { DataLinksInlineEditor } from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditor';
|
export { DataLinksInlineEditor } from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditor';
|
||||||
|
export {
|
||||||
|
DataLinksInlineEditorBase,
|
||||||
|
type DataLinksInlineEditorBaseProps,
|
||||||
|
} from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditorBase';
|
||||||
export { DataLinkInput } from './DataLinks/DataLinkInput';
|
export { DataLinkInput } from './DataLinks/DataLinkInput';
|
||||||
export {
|
export {
|
||||||
DataLinksContextMenu,
|
DataLinksContextMenu,
|
||||||
|
@ -2,6 +2,8 @@ import { css } from '@emotion/css';
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { Action, GrafanaTheme2, httpMethodOptions, HttpRequestMethod, VariableSuggestion } from '@grafana/data';
|
import { Action, GrafanaTheme2, httpMethodOptions, HttpRequestMethod, VariableSuggestion } from '@grafana/data';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import { Switch } from '@grafana/ui/';
|
||||||
import { Field } from '@grafana/ui/src/components/Forms/Field';
|
import { Field } from '@grafana/ui/src/components/Forms/Field';
|
||||||
import { InlineField } from '@grafana/ui/src/components/Forms/InlineField';
|
import { InlineField } from '@grafana/ui/src/components/Forms/InlineField';
|
||||||
import { InlineFieldRow } from '@grafana/ui/src/components/Forms/InlineFieldRow';
|
import { InlineFieldRow } from '@grafana/ui/src/components/Forms/InlineFieldRow';
|
||||||
@ -19,11 +21,12 @@ interface ActionEditorProps {
|
|||||||
value: Action;
|
value: Action;
|
||||||
onChange: (index: number, action: Action) => void;
|
onChange: (index: number, action: Action) => void;
|
||||||
suggestions: VariableSuggestion[];
|
suggestions: VariableSuggestion[];
|
||||||
|
showOneClick?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LABEL_WIDTH = 13;
|
const LABEL_WIDTH = 13;
|
||||||
|
|
||||||
export const ActionEditor = memo(({ index, value, onChange, suggestions }: ActionEditorProps) => {
|
export const ActionEditor = memo(({ index, value, onChange, suggestions, showOneClick }: ActionEditorProps) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const onTitleChange = (title: string) => {
|
const onTitleChange = (title: string) => {
|
||||||
@ -34,6 +37,10 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions }: Actio
|
|||||||
onChange(index, { ...value, confirmation });
|
onChange(index, { ...value, confirmation });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onOneClickChanged = () => {
|
||||||
|
onChange(index, { ...value, oneClick: !value.oneClick });
|
||||||
|
};
|
||||||
|
|
||||||
const onUrlChange = (url: string) => {
|
const onUrlChange = (url: string) => {
|
||||||
onChange(index, {
|
onChange(index, {
|
||||||
...value,
|
...value,
|
||||||
@ -101,6 +108,8 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions }: Actio
|
|||||||
value.fetch.method !== HttpRequestMethod.GET &&
|
value.fetch.method !== HttpRequestMethod.GET &&
|
||||||
value.fetch.headers?.some(([name, value]) => name === 'Content-Type' && value === 'application/json');
|
value.fetch.headers?.some(([name, value]) => name === 'Content-Type' && value === 'application/json');
|
||||||
|
|
||||||
|
const action = config.featureToggles.vizActions ? 'or action' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.listItem}>
|
<div className={styles.listItem}>
|
||||||
<Field label={t('grafana-ui.action-editor.modal.action-title', 'Title')} className={styles.inputField}>
|
<Field label={t('grafana-ui.action-editor.modal.action-title', 'Title')} className={styles.inputField}>
|
||||||
@ -133,6 +142,19 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions }: Actio
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{showOneClick && (
|
||||||
|
<Field
|
||||||
|
label={t('grafana-ui.data-link-inline-editor.one-click', 'One click')}
|
||||||
|
description={t(
|
||||||
|
'grafana-ui.action-editor.modal.one-click-description',
|
||||||
|
'Only one link {{ action }} can have one click enabled at a time',
|
||||||
|
{ action }
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Switch value={value.oneClick || false} onChange={onOneClickChanged} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
<InlineFieldRow>
|
<InlineFieldRow>
|
||||||
<InlineField
|
<InlineField
|
||||||
label={t('grafana-ui.action-editor.modal.action-method', 'Method')}
|
label={t('grafana-ui.action-editor.modal.action-method', 'Method')}
|
||||||
|
@ -14,6 +14,7 @@ interface ActionEditorModalContentProps {
|
|||||||
onSave: (index: number, action: Action) => void;
|
onSave: (index: number, action: Action) => void;
|
||||||
onCancel: (index: number) => void;
|
onCancel: (index: number) => void;
|
||||||
getSuggestions: () => VariableSuggestion[];
|
getSuggestions: () => VariableSuggestion[];
|
||||||
|
showOneClick: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActionEditorModalContent = ({
|
export const ActionEditorModalContent = ({
|
||||||
@ -22,6 +23,7 @@ export const ActionEditorModalContent = ({
|
|||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
getSuggestions,
|
getSuggestions,
|
||||||
|
showOneClick,
|
||||||
}: ActionEditorModalContentProps) => {
|
}: ActionEditorModalContentProps) => {
|
||||||
const [dirtyAction, setDirtyAction] = useState(action);
|
const [dirtyAction, setDirtyAction] = useState(action);
|
||||||
|
|
||||||
@ -34,6 +36,7 @@ export const ActionEditorModalContent = ({
|
|||||||
setDirtyAction(action);
|
setDirtyAction(action);
|
||||||
}}
|
}}
|
||||||
suggestions={getSuggestions()}
|
suggestions={getSuggestions()}
|
||||||
|
showOneClick={showOneClick}
|
||||||
/>
|
/>
|
||||||
<Modal.ButtonRow>
|
<Modal.ButtonRow>
|
||||||
<Button variant="secondary" onClick={() => onCancel(index)} fill="outline">
|
<Button variant="secondary" onClick={() => onCancel(index)} fill="outline">
|
||||||
|
@ -1,193 +1,26 @@
|
|||||||
import { css } from '@emotion/css';
|
import { Action, defaultActionConfig, VariableSuggestion } from '@grafana/data';
|
||||||
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
|
import { DataLinksInlineEditorBase, DataLinksInlineEditorBaseProps } from '@grafana/ui';
|
||||||
import { cloneDeep } from 'lodash';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { Action, DataFrame, GrafanaTheme2, defaultActionConfig, VariableSuggestion } from '@grafana/data';
|
|
||||||
import { Button } from '@grafana/ui/src/components/Button';
|
|
||||||
import { Modal } from '@grafana/ui/src/components/Modal/Modal';
|
|
||||||
import { useStyles2 } from '@grafana/ui/src/themes';
|
|
||||||
import { Trans } from 'app/core/internationalization';
|
|
||||||
|
|
||||||
import { ActionEditorModalContent } from './ActionEditorModalContent';
|
import { ActionEditorModalContent } from './ActionEditorModalContent';
|
||||||
import { ActionListItem } from './ActionsListItem';
|
|
||||||
|
|
||||||
interface ActionsInlineEditorProps {
|
type DataLinksInlineEditorProps = Omit<DataLinksInlineEditorBaseProps<Action>, 'children' | 'type' | 'items'> & {
|
||||||
actions?: Action[];
|
actions: Action[];
|
||||||
onChange: (actions: Action[]) => void;
|
|
||||||
data: DataFrame[];
|
|
||||||
getSuggestions: () => VariableSuggestion[];
|
|
||||||
showOneClick?: boolean;
|
showOneClick?: boolean;
|
||||||
}
|
getSuggestions: () => VariableSuggestion[];
|
||||||
|
|
||||||
export const ActionsInlineEditor = ({
|
|
||||||
actions,
|
|
||||||
onChange,
|
|
||||||
data,
|
|
||||||
getSuggestions,
|
|
||||||
showOneClick = false,
|
|
||||||
}: ActionsInlineEditorProps) => {
|
|
||||||
const [editIndex, setEditIndex] = useState<number | null>(null);
|
|
||||||
const [isNew, setIsNew] = useState(false);
|
|
||||||
|
|
||||||
const [actionsSafe, setActionsSafe] = useState<Action[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActionsSafe(actions ?? []);
|
|
||||||
}, [actions]);
|
|
||||||
|
|
||||||
const styles = useStyles2(getActionsInlineEditorStyle);
|
|
||||||
const isEditing = editIndex !== null;
|
|
||||||
|
|
||||||
const onActionChange = (index: number, action: Action) => {
|
|
||||||
if (isNew) {
|
|
||||||
if (action.title.trim() === '') {
|
|
||||||
setIsNew(false);
|
|
||||||
setEditIndex(null);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
setEditIndex(null);
|
|
||||||
setIsNew(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const update = cloneDeep(actionsSafe);
|
|
||||||
update[index] = action;
|
|
||||||
onChange(update);
|
|
||||||
|
|
||||||
setEditIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onActionAdd = () => {
|
|
||||||
let update = cloneDeep(actionsSafe);
|
|
||||||
setEditIndex(update.length);
|
|
||||||
setIsNew(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onActionCancel = (index: number) => {
|
|
||||||
if (isNew) {
|
|
||||||
setIsNew(false);
|
|
||||||
}
|
|
||||||
setEditIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onActionRemove = (index: number) => {
|
|
||||||
const update = cloneDeep(actionsSafe);
|
|
||||||
update.splice(index, 1);
|
|
||||||
onChange(update);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragEnd = (result: DropResult) => {
|
|
||||||
if (!actions || !result.destination) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = cloneDeep(actionsSafe);
|
|
||||||
const action = update[result.source.index];
|
|
||||||
|
|
||||||
update.splice(result.source.index, 1);
|
|
||||||
update.splice(result.destination.index, 0, action);
|
|
||||||
|
|
||||||
setActionsSafe(update);
|
|
||||||
onChange(update);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
{/* one-link placeholder */}
|
|
||||||
{showOneClick && actionsSafe.length > 0 && (
|
|
||||||
<div className={styles.oneClickOverlay}>
|
|
||||||
<span className={styles.oneClickSpan}>
|
|
||||||
<Trans i18nKey="actions-editor.inline.one-click-action">One-click action</Trans>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
|
||||||
<Droppable droppableId="sortable-actions" direction="vertical">
|
|
||||||
{(provided) => (
|
|
||||||
<div
|
|
||||||
className={styles.wrapper}
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.droppableProps}
|
|
||||||
style={{ paddingTop: showOneClick && actionsSafe.length > 0 ? '28px' : '0px' }}
|
|
||||||
>
|
|
||||||
{actionsSafe.map((action, idx) => {
|
|
||||||
const key = `${action.title}/${idx}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionListItem
|
|
||||||
key={key}
|
|
||||||
index={idx}
|
|
||||||
action={action}
|
|
||||||
onChange={onActionChange}
|
|
||||||
onEdit={() => setEditIndex(idx)}
|
|
||||||
onRemove={() => onActionRemove(idx)}
|
|
||||||
data={data}
|
|
||||||
itemKey={key}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{provided.placeholder}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
</DragDropContext>
|
|
||||||
|
|
||||||
{isEditing && editIndex !== null && (
|
|
||||||
<Modal
|
|
||||||
title="Edit action"
|
|
||||||
isOpen={true}
|
|
||||||
closeOnBackdropClick={false}
|
|
||||||
onDismiss={() => {
|
|
||||||
onActionCancel(editIndex);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActionEditorModalContent
|
|
||||||
index={editIndex}
|
|
||||||
action={isNew ? defaultActionConfig : actionsSafe[editIndex]}
|
|
||||||
data={data}
|
|
||||||
onSave={onActionChange}
|
|
||||||
onCancel={onActionCancel}
|
|
||||||
getSuggestions={getSuggestions}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button size="sm" icon="plus" onClick={onActionAdd} variant="secondary" className={styles.button}>
|
|
||||||
<Trans i18nKey="actions-editor.inline.add-button">Add action</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActionsInlineEditorStyle = (theme: GrafanaTheme2) => ({
|
export const ActionsInlineEditor = ({ actions, getSuggestions, showOneClick, ...rest }: DataLinksInlineEditorProps) => (
|
||||||
container: css({
|
<DataLinksInlineEditorBase<Action> type="action" items={actions} {...rest}>
|
||||||
position: 'relative',
|
{(item, index, onSave, onCancel) => (
|
||||||
}),
|
<ActionEditorModalContent
|
||||||
wrapper: css({
|
index={index}
|
||||||
marginBottom: theme.spacing(2),
|
action={item ?? defaultActionConfig}
|
||||||
display: 'flex',
|
data={rest.data}
|
||||||
flexDirection: 'column',
|
onSave={onSave}
|
||||||
}),
|
onCancel={onCancel}
|
||||||
oneClickOverlay: css({
|
getSuggestions={getSuggestions}
|
||||||
border: `2px dashed ${theme.colors.text.link}`,
|
showOneClick={showOneClick ?? false}
|
||||||
fontSize: 10,
|
/>
|
||||||
color: theme.colors.text.primary,
|
)}
|
||||||
marginBottom: theme.spacing(1),
|
</DataLinksInlineEditorBase>
|
||||||
position: 'absolute',
|
);
|
||||||
width: '100%',
|
|
||||||
height: '89px',
|
|
||||||
}),
|
|
||||||
oneClickSpan: css({
|
|
||||||
padding: 10,
|
|
||||||
// Negates the padding on the span from moving the underlying link
|
|
||||||
marginBottom: -10,
|
|
||||||
display: 'inline-block',
|
|
||||||
}),
|
|
||||||
itemWrapper: css({
|
|
||||||
padding: '4px 8px 8px 8px',
|
|
||||||
}),
|
|
||||||
button: css({
|
|
||||||
marginLeft: theme.spacing(1),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
@ -1,20 +1,26 @@
|
|||||||
import { StandardEditorProps, OneClickMode, Action, VariableSuggestionsScope } from '@grafana/data';
|
import { StandardEditorProps, Action, VariableSuggestionsScope } from '@grafana/data';
|
||||||
|
import { ActionsInlineEditor } from 'app/features/actions/ActionsInlineEditor';
|
||||||
import { CanvasElementOptions } from 'app/features/canvas/element';
|
import { CanvasElementOptions } from 'app/features/canvas/element';
|
||||||
|
|
||||||
import { ActionsInlineEditor } from '../../../../../features/actions/ActionsInlineEditor';
|
|
||||||
|
|
||||||
type Props = StandardEditorProps<Action[], CanvasElementOptions>;
|
type Props = StandardEditorProps<Action[], CanvasElementOptions>;
|
||||||
|
|
||||||
export function ActionsEditor({ value, onChange, item, context }: Props) {
|
export function ActionsEditor({ value, onChange, item, context }: Props) {
|
||||||
const oneClickMode = item.settings?.oneClickMode;
|
const dataLinks = item.settings?.links || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionsInlineEditor
|
<ActionsInlineEditor
|
||||||
actions={value}
|
actions={value}
|
||||||
onChange={onChange}
|
onChange={(actions) => {
|
||||||
|
if (actions.some(({ oneClick }) => oneClick === true)) {
|
||||||
|
dataLinks.forEach((link) => {
|
||||||
|
link.oneClick = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onChange(actions);
|
||||||
|
}}
|
||||||
getSuggestions={() => (context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])}
|
getSuggestions={() => (context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])}
|
||||||
data={[]}
|
data={[]}
|
||||||
showOneClick={oneClickMode === OneClickMode.Action}
|
showOneClick={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,19 +1,26 @@
|
|||||||
import { StandardEditorProps, DataLink, VariableSuggestionsScope, OneClickMode } from '@grafana/data';
|
import { StandardEditorProps, DataLink, VariableSuggestionsScope } from '@grafana/data';
|
||||||
import { DataLinksInlineEditor } from '@grafana/ui';
|
import { DataLinksInlineEditor } from '@grafana/ui';
|
||||||
import { CanvasElementOptions } from 'app/features/canvas/element';
|
import { CanvasElementOptions } from 'app/features/canvas/element';
|
||||||
|
|
||||||
type Props = StandardEditorProps<DataLink[], CanvasElementOptions>;
|
type Props = StandardEditorProps<DataLink[], CanvasElementOptions>;
|
||||||
|
|
||||||
export function DataLinksEditor({ value, onChange, item, context }: Props) {
|
export function DataLinksEditor({ value, onChange, item, context }: Props) {
|
||||||
const oneClickMode = item.settings?.oneClickMode;
|
const actions = item.settings?.actions || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataLinksInlineEditor
|
<DataLinksInlineEditor
|
||||||
links={value}
|
links={value}
|
||||||
onChange={onChange}
|
onChange={(links) => {
|
||||||
|
if (links.some(({ oneClick }) => oneClick === true)) {
|
||||||
|
actions.forEach((action) => {
|
||||||
|
action.oneClick = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onChange(links);
|
||||||
|
}}
|
||||||
getSuggestions={() => (context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])}
|
getSuggestions={() => (context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])}
|
||||||
data={[]}
|
data={[]}
|
||||||
showOneClick={oneClickMode === OneClickMode.Link}
|
showOneClick={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -34,12 +34,6 @@
|
|||||||
"save-button": "Save"
|
"save-button": "Save"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"actions-editor": {
|
|
||||||
"inline": {
|
|
||||||
"add-button": "Add action",
|
|
||||||
"one-click-action": "One-click action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"admin": {
|
"admin": {
|
||||||
"anon-users": {
|
"anon-users": {
|
||||||
"not-found": "No anonymous users found."
|
"not-found": "No anonymous users found."
|
||||||
@ -1574,12 +1568,17 @@
|
|||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"confirm-action": "Confirm action"
|
"confirm-action": "Confirm action"
|
||||||
},
|
},
|
||||||
|
"inline": {
|
||||||
|
"add-action": "Add action",
|
||||||
|
"edit-action": "Edit action"
|
||||||
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"action-body": "Body",
|
"action-body": "Body",
|
||||||
"action-method": "Method",
|
"action-method": "Method",
|
||||||
"action-query-params": "Query parameters",
|
"action-query-params": "Query parameters",
|
||||||
"action-title": "Title",
|
"action-title": "Title",
|
||||||
"action-title-placeholder": "Action title"
|
"action-title-placeholder": "Action title",
|
||||||
|
"one-click-description": "Only one link {{ action }} can have one click enabled at a time"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auto-save-field": {
|
"auto-save-field": {
|
||||||
@ -1606,9 +1605,13 @@
|
|||||||
},
|
},
|
||||||
"data-links-inline-editor": {
|
"data-links-inline-editor": {
|
||||||
"add-link": "Add link",
|
"add-link": "Add link",
|
||||||
|
"edit-link": "Edit link",
|
||||||
"one-click": "One click",
|
"one-click": "One click",
|
||||||
"one-click-enabled": "One click enabled",
|
"one-click-enabled": "One click enabled",
|
||||||
"one-click-link": "One-click link"
|
"title-not-provided": "Title not provided",
|
||||||
|
"tooltip-edit": "Edit",
|
||||||
|
"tooltip-remove": "Remove",
|
||||||
|
"url-not-provided": "Data link url not provided"
|
||||||
},
|
},
|
||||||
"data-source-http-settings": {
|
"data-source-http-settings": {
|
||||||
"access-help": "Help <1></1>",
|
"access-help": "Help <1></1>",
|
||||||
|
@ -34,12 +34,6 @@
|
|||||||
"save-button": "Ŝävę"
|
"save-button": "Ŝävę"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"actions-editor": {
|
|
||||||
"inline": {
|
|
||||||
"add-button": "Åđđ äčŧįőʼn",
|
|
||||||
"one-click-action": "Øʼnę-čľįčĸ äčŧįőʼn"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"admin": {
|
"admin": {
|
||||||
"anon-users": {
|
"anon-users": {
|
||||||
"not-found": "Ńő äʼnőʼnymőūş ūşęřş ƒőūʼnđ."
|
"not-found": "Ńő äʼnőʼnymőūş ūşęřş ƒőūʼnđ."
|
||||||
@ -1574,12 +1568,17 @@
|
|||||||
"confirm": "Cőʼnƒįřm",
|
"confirm": "Cőʼnƒįřm",
|
||||||
"confirm-action": "Cőʼnƒįřm äčŧįőʼn"
|
"confirm-action": "Cőʼnƒįřm äčŧįőʼn"
|
||||||
},
|
},
|
||||||
|
"inline": {
|
||||||
|
"add-action": "Åđđ äčŧįőʼn",
|
||||||
|
"edit-action": "Ēđįŧ äčŧįőʼn"
|
||||||
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"action-body": "ßőđy",
|
"action-body": "ßőđy",
|
||||||
"action-method": "Męŧĥőđ",
|
"action-method": "Męŧĥőđ",
|
||||||
"action-query-params": "Qūęřy päřämęŧęřş",
|
"action-query-params": "Qūęřy päřämęŧęřş",
|
||||||
"action-title": "Ŧįŧľę",
|
"action-title": "Ŧįŧľę",
|
||||||
"action-title-placeholder": "Åčŧįőʼn ŧįŧľę"
|
"action-title-placeholder": "Åčŧįőʼn ŧįŧľę",
|
||||||
|
"one-click-description": "Øʼnľy őʼnę ľįʼnĸ {{ action }} čäʼn ĥävę őʼnę čľįčĸ ęʼnäþľęđ äŧ ä ŧįmę"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auto-save-field": {
|
"auto-save-field": {
|
||||||
@ -1606,9 +1605,13 @@
|
|||||||
},
|
},
|
||||||
"data-links-inline-editor": {
|
"data-links-inline-editor": {
|
||||||
"add-link": "Åđđ ľįʼnĸ",
|
"add-link": "Åđđ ľįʼnĸ",
|
||||||
|
"edit-link": "Ēđįŧ ľįʼnĸ",
|
||||||
"one-click": "Øʼnę čľįčĸ",
|
"one-click": "Øʼnę čľįčĸ",
|
||||||
"one-click-enabled": "Øʼnę čľįčĸ ęʼnäþľęđ",
|
"one-click-enabled": "Øʼnę čľįčĸ ęʼnäþľęđ",
|
||||||
"one-click-link": "Øʼnę-čľįčĸ ľįʼnĸ"
|
"title-not-provided": "Ŧįŧľę ʼnőŧ přővįđęđ",
|
||||||
|
"tooltip-edit": "Ēđįŧ",
|
||||||
|
"tooltip-remove": "Ŗęmővę",
|
||||||
|
"url-not-provided": "Đäŧä ľįʼnĸ ūřľ ʼnőŧ přővįđęđ"
|
||||||
},
|
},
|
||||||
"data-source-http-settings": {
|
"data-source-http-settings": {
|
||||||
"access-help": "Ħęľp <1></1>",
|
"access-help": "Ħęľp <1></1>",
|
||||||
|
Loading…
Reference in New Issue
Block a user