Chore: Data links and Actions components refactor (#100097)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Adela Almasan 2025-02-07 20:03:05 -06:00 committed by GitHub
parent bce05cd48d
commit f5c049012b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 430 additions and 652 deletions

View File

@ -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"],

View File

@ -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 {

View File

@ -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';

View File

@ -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>

View File

@ -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),
}),
});

View File

@ -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),
}),
});

View File

@ -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();
});
});
}); });

View File

@ -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,
}),
};
};

View File

@ -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,
}),
}; };
}; };

View File

@ -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,

View File

@ -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')}

View File

@ -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">

View File

@ -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),
}),
});

View File

@ -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}
/> />
); );
} }

View File

@ -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}
/> />
); );
} }

View File

@ -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>",

View File

@ -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>",