Actions: Improve drag and drop experience (#94263)

* feat(actions): improve dnd ux

---------

Co-authored-by: Kristina Durivage <kristina.durivage@grafana.com>
This commit is contained in:
Ihor Yeromin 2024-10-23 09:24:06 +02:00 committed by GitHub
parent 8072286daf
commit b189743ca0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 103 additions and 128 deletions

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd'; import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { ReactNode, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { DataFrame, DataLink, GrafanaTheme2, VariableSuggestion } from '@grafana/data'; import { DataFrame, DataLink, GrafanaTheme2, VariableSuggestion } from '@grafana/data';
@ -91,49 +91,40 @@ export const DataLinksInlineEditor = ({
onChange(update); onChange(update);
}; };
const renderFirstLink = (linkJSX: ReactNode, key: string) => { return (
if (showOneClick) { <div className={styles.container}>
return ( {/* one-link placeholder */}
<div className={styles.oneClickOverlay} key={key}> {showOneClick && linksSafe.length > 0 && (
<div className={styles.oneClickOverlay}>
<span className={styles.oneClickSpan}> <span className={styles.oneClickSpan}>
<Trans i18nKey="grafana-ui.data-links-inline-editor.one-click-link">One-click link</Trans> <Trans i18nKey="grafana-ui.data-links-inline-editor.one-click-link">One-click link</Trans>
</span> </span>
{linkJSX}
</div> </div>
); )}
}
return linkJSX;
};
return (
<>
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sortable-links" direction="vertical"> <Droppable droppableId="sortable-links" direction="vertical">
{(provided) => ( {(provided) => (
<div className={styles.wrapper} ref={provided.innerRef} {...provided.droppableProps}> <div
className={styles.wrapper}
ref={provided.innerRef}
{...provided.droppableProps}
style={{ paddingTop: showOneClick && linksSafe.length > 0 ? '28px' : '0px' }}
>
{linksSafe.map((link, idx) => { {linksSafe.map((link, idx) => {
const key = `${link.title}/${idx}`; const key = `${link.title}/${idx}`;
return (
const linkJSX = ( <DataLinksListItem
<div className={styles.itemWrapper} key={key}> key={key}
<DataLinksListItem index={idx}
key={key} link={link}
index={idx} onChange={onDataLinkChange}
link={link} onEdit={() => setEditIndex(idx)}
onChange={onDataLinkChange} onRemove={() => onDataLinkRemove(idx)}
onEdit={() => setEditIndex(idx)} data={data}
onRemove={() => onDataLinkRemove(idx)} itemKey={key}
data={data} />
itemKey={key}
/>
</div>
); );
if (idx === 0) {
return renderFirstLink(linkJSX, key);
}
return linkJSX;
})} })}
{provided.placeholder} {provided.placeholder}
</div> </div>
@ -164,22 +155,27 @@ export const DataLinksInlineEditor = ({
<Button size="sm" icon="plus" onClick={onDataLinkAdd} variant="secondary" className={styles.button}> <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> <Trans i18nKey="grafana-ui.data-links-inline-editor.add-link">Add link</Trans>
</Button> </Button>
</> </div>
); );
}; };
const getDataLinksInlineEditorStyles = (theme: GrafanaTheme2) => ({ const getDataLinksInlineEditorStyles = (theme: GrafanaTheme2) => ({
container: css({
position: 'relative',
}),
wrapper: css({ wrapper: css({
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}), }),
oneClickOverlay: css({ oneClickOverlay: css({
height: 'auto',
border: `2px dashed ${theme.colors.text.link}`, border: `2px dashed ${theme.colors.text.link}`,
fontSize: 10, fontSize: 10,
color: theme.colors.text.primary, color: theme.colors.text.primary,
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
position: 'absolute',
width: '100%',
height: '92px',
}), }),
oneClickSpan: css({ oneClickSpan: css({
padding: 10, padding: 10,
@ -187,9 +183,6 @@ const getDataLinksInlineEditorStyles = (theme: GrafanaTheme2) => ({
marginBottom: -10, marginBottom: -10,
display: 'inline-block', display: 'inline-block',
}), }),
itemWrapper: css({
padding: '4px 8px 8px 8px',
}),
button: css({ button: css({
marginLeft: theme.spacing(1), marginLeft: theme.spacing(1),
}), }),

View File

@ -5,10 +5,9 @@ import { DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../themes'; import { useStyles2 } from '../../../themes';
import { isCompactUrl } from '../../../utils'; import { isCompactUrl } from '../../../utils';
import { Trans } from '../../../utils/i18n';
import { FieldValidationMessage } from '../../Forms/FieldValidationMessage';
import { Icon } from '../../Icon/Icon'; import { Icon } from '../../Icon/Icon';
import { IconButton } from '../../IconButton/IconButton'; import { IconButton } from '../../IconButton/IconButton';
import { Tooltip } from '../../Tooltip/Tooltip';
export interface DataLinksListItemProps { export interface DataLinksListItemProps {
index: number; index: number;
@ -33,40 +32,33 @@ export const DataLinksListItem = ({ link, onEdit, onRemove, index, itemKey }: Da
return ( return (
<Draggable key={itemKey} draggableId={itemKey} index={index}> <Draggable key={itemKey} draggableId={itemKey} index={index}>
{(provided) => ( {(provided) => (
<> <div
<div className={cx(styles.wrapper, styles.dragRow)}
className={cx(styles.wrapper, styles.dragRow)} ref={provided.innerRef}
ref={provided.innerRef} {...provided.draggableProps}
{...provided.draggableProps} key={index}
key={index} >
> <div className={styles.linkDetails}>
<div className={styles.linkDetails}> <div className={cx(styles.url, !hasUrl && styles.notConfigured, isCompactExploreUrl && styles.errored)}>
<div className={cx(styles.url, !hasUrl && styles.notConfigured, isCompactExploreUrl && styles.errored)}> {hasTitle ? title : 'Data link title not provided'}
{hasTitle ? title : 'Data link title not provided'} </div>
</div> <Tooltip content={'Explore data link may not work in the future. Please edit.'} show={isCompactExploreUrl}>
<div <div
className={cx(styles.url, !hasUrl && styles.notConfigured, isCompactExploreUrl && styles.errored)} className={cx(styles.url, !hasUrl && styles.notConfigured, isCompactExploreUrl && styles.errored)}
title={url} title={url}
> >
{hasUrl ? url : 'Data link url not provided'} {hasUrl ? url : 'Data link url not provided'}
</div> </div>
{isCompactExploreUrl && ( </Tooltip>
<FieldValidationMessage> </div>
<Trans i18nKey="grafana-ui.data-links-list-item.compact-explore-disclaimer"> <div className={styles.icons}>
Explore data link may not work in the future. Please edit. <IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit data link" />
</Trans> <IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove data link" />
</FieldValidationMessage> <div className={styles.dragIcon} {...provided.dragHandleProps}>
)} <Icon name="draggabledots" size="lg" />
</div>
<div className={styles.icons}>
<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>
</div> </div>
</> </div>
)} )}
</Draggable> </Draggable>
); );
@ -79,7 +71,6 @@ const getDataLinkListItemStyles = (theme: GrafanaTheme2) => {
flexGrow: 1, flexGrow: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
width: '100%',
padding: '5px 0 5px 10px', padding: '5px 0 5px 10px',
borderRadius: theme.shape.radius.default, borderRadius: theme.shape.radius.default,
background: theme.colors.background.secondary, background: theme.colors.background.secondary,
@ -112,6 +103,7 @@ const getDataLinkListItemStyles = (theme: GrafanaTheme2) => {
}), }),
dragRow: css({ dragRow: css({
position: 'relative', position: 'relative',
margin: '8px',
}), }),
icons: css({ icons: css({
display: 'flex', display: 'flex',

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd'; import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { ReactNode, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Action, DataFrame, GrafanaTheme2, defaultActionConfig, VariableSuggestion } from '@grafana/data'; import { Action, DataFrame, GrafanaTheme2, defaultActionConfig, VariableSuggestion } from '@grafana/data';
import { Button } from '@grafana/ui/src/components/Button'; import { Button } from '@grafana/ui/src/components/Button';
@ -90,46 +90,39 @@ export const ActionsInlineEditor = ({
onChange(update); onChange(update);
}; };
const renderFirstAction = (actionsJSX: ReactNode, key: string) => {
if (showOneClick) {
return (
<div className={styles.oneClickOverlay} key={key}>
<span className={styles.oneClickSpan}>One-click action</span> {actionsJSX}
</div>
);
}
return actionsJSX;
};
return ( return (
<> <div className={styles.container}>
{/* one-link placeholder */}
{showOneClick && actionsSafe.length > 0 && (
<div className={styles.oneClickOverlay}>
<span className={styles.oneClickSpan}>One-click link</span>
</div>
)}
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sortable-actions" direction="vertical"> <Droppable droppableId="sortable-actions" direction="vertical">
{(provided) => ( {(provided) => (
<div className={styles.wrapper} ref={provided.innerRef} {...provided.droppableProps}> <div
className={styles.wrapper}
ref={provided.innerRef}
{...provided.droppableProps}
style={{ paddingTop: showOneClick && actionsSafe.length > 0 ? '28px' : '0px' }}
>
{actionsSafe.map((action, idx) => { {actionsSafe.map((action, idx) => {
const key = `${action.title}/${idx}`; const key = `${action.title}/${idx}`;
const actionsJSX = ( return (
<div className={styles.itemWrapper} key={key}> <ActionListItem
<ActionListItem key={key}
key={key} index={idx}
index={idx} action={action}
action={action} onChange={onActionChange}
onChange={onActionChange} onEdit={() => setEditIndex(idx)}
onEdit={() => setEditIndex(idx)} onRemove={() => onActionRemove(idx)}
onRemove={() => onActionRemove(idx)} data={data}
data={data} itemKey={key}
itemKey={key} />
/>
</div>
); );
if (idx === 0) {
return renderFirstAction(actionsJSX, key);
}
return actionsJSX;
})} })}
{provided.placeholder} {provided.placeholder}
</div> </div>
@ -160,22 +153,27 @@ export const ActionsInlineEditor = ({
<Button size="sm" icon="plus" onClick={onActionAdd} variant="secondary" className={styles.button}> <Button size="sm" icon="plus" onClick={onActionAdd} variant="secondary" className={styles.button}>
Add action Add action
</Button> </Button>
</> </div>
); );
}; };
const getActionsInlineEditorStyle = (theme: GrafanaTheme2) => ({ const getActionsInlineEditorStyle = (theme: GrafanaTheme2) => ({
container: css({
position: 'relative',
}),
wrapper: css({ wrapper: css({
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}), }),
oneClickOverlay: css({ oneClickOverlay: css({
height: 'auto',
border: `2px dashed ${theme.colors.text.link}`, border: `2px dashed ${theme.colors.text.link}`,
fontSize: 10, fontSize: 10,
color: theme.colors.text.primary, color: theme.colors.text.primary,
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
position: 'absolute',
width: '100%',
height: '89px',
}), }),
oneClickSpan: css({ oneClickSpan: css({
padding: 10, padding: 10,

View File

@ -26,27 +26,25 @@ export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: Act
return ( return (
<Draggable key={itemKey} draggableId={itemKey} index={index}> <Draggable key={itemKey} draggableId={itemKey} index={index}>
{(provided) => ( {(provided) => (
<> <div
<div className={cx(styles.wrapper, styles.dragRow)}
className={cx(styles.wrapper, styles.dragRow)} ref={provided.innerRef}
ref={provided.innerRef} {...provided.draggableProps}
{...provided.draggableProps} key={index}
key={index} >
> <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 : 'Action title not provided'}
</div>
</div>
<div className={styles.icons}>
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit action" />
<IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove action" />
<div className={styles.dragIcon} {...provided.dragHandleProps}>
<Icon name="draggabledots" size="lg" />
</div>
</div> </div>
</div> </div>
</> <div className={styles.icons}>
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit action" />
<IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove action" />
<div className={styles.dragIcon} {...provided.dragHandleProps}>
<Icon name="draggabledots" size="lg" />
</div>
</div>
</div>
)} )}
</Draggable> </Draggable>
); );
@ -59,7 +57,6 @@ const getActionListItemStyles = (theme: GrafanaTheme2) => {
flexGrow: 1, flexGrow: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
width: '100%',
padding: '5px 0 5px 10px', padding: '5px 0 5px 10px',
borderRadius: theme.shape.radius.default, borderRadius: theme.shape.radius.default,
background: theme.colors.background.secondary, background: theme.colors.background.secondary,
@ -100,6 +97,7 @@ const getActionListItemStyles = (theme: GrafanaTheme2) => {
}), }),
dragRow: css({ dragRow: css({
position: 'relative', position: 'relative',
margin: '8px',
}), }),
icons: css({ icons: css({
display: 'flex', display: 'flex',

View File

@ -1084,9 +1084,6 @@
"add-link": "Add link", "add-link": "Add link",
"one-click-link": "One-click link" "one-click-link": "One-click link"
}, },
"data-links-list-item": {
"compact-explore-disclaimer": "Explore data link may not work in the future. Please edit."
},
"data-source-http-settings": { "data-source-http-settings": {
"access-help": "Help <1></1>", "access-help": "Help <1></1>",
"access-help-details": "Access mode controls how requests to the data source will be handled.<1> <1>Server</1></1> should be the preferred way if nothing else is stated.", "access-help-details": "Access mode controls how requests to the data source will be handled.<1> <1>Server</1></1> should be the preferred way if nothing else is stated.",

View File

@ -1084,9 +1084,6 @@
"add-link": "Åđđ ľįʼnĸ", "add-link": "Åđđ ľįʼnĸ",
"one-click-link": "Øʼnę-čľįčĸ ľįʼnĸ" "one-click-link": "Øʼnę-čľįčĸ ľįʼnĸ"
}, },
"data-links-list-item": {
"compact-explore-disclaimer": "Ēχpľőřę đäŧä ľįʼnĸ mäy ʼnőŧ ŵőřĸ įʼn ŧĥę ƒūŧūřę. Pľęäşę ęđįŧ."
},
"data-source-http-settings": { "data-source-http-settings": {
"access-help": "Ħęľp <1></1>", "access-help": "Ħęľp <1></1>",
"access-help-details": "Åččęşş mőđę čőʼnŧřőľş ĥőŵ řęqūęşŧş ŧő ŧĥę đäŧä şőūřčę ŵįľľ þę ĥäʼnđľęđ.<1> <1>Ŝęřvęř</1></1> şĥőūľđ þę ŧĥę přęƒęřřęđ ŵäy įƒ ʼnőŧĥįʼnģ ęľşę įş şŧäŧęđ.", "access-help-details": "Åččęşş mőđę čőʼnŧřőľş ĥőŵ řęqūęşŧş ŧő ŧĥę đäŧä şőūřčę ŵįľľ þę ĥäʼnđľęđ.<1> <1>Ŝęřvęř</1></1> şĥőūľđ þę ŧĥę přęƒęřřęđ ŵäy įƒ ʼnőŧĥįʼnģ ęľşę įş şŧäŧęđ.",