Commit 6dbb803b by Dominik Prokop Committed by GitHub

Transformations: enable transformations reordering (#27197)

* Transformations: enable queries reorder by drag and drop

* Satisfy ts

* Update unicons and replace ellipsis with draggabledot

* remove import

* Remove that snap

* Review

* review 2
parent 1a69bcfe
......@@ -103,7 +103,9 @@ export const Components = {
},
TransformTab: {
content: 'Transform editor tab content',
newTransform: (title: string) => `New transform ${title}`,
newTransform: (name: string) => `New transform ${name}`,
transformationEditor: (name: string) => `Transformation editor ${name}`,
transformationEditorDebugger: (name: string) => `Transformation editor debugger ${name}`,
},
Transforms: {
Reduce: {
......@@ -144,4 +146,7 @@ export const Components = {
container: 'Time zone picker select container',
},
QueryField: { container: 'Query field' },
ValuePicker: {
select: (name: string) => `Value picker select ${name}`,
},
};
......@@ -31,7 +31,7 @@
"@grafana/e2e-selectors": "7.2.0-pre.0",
"@grafana/slate-react": "0.22.9-grafana",
"@grafana/tsconfig": "^1.0.0-rc1",
"@iconscout/react-unicons": "^1.0.0",
"@iconscout/react-unicons": "1.1.4",
"@torkelo/react-select": "3.0.8",
"@types/react-beautiful-dnd": "12.1.2",
"@types/react-color": "3.0.1",
......
......@@ -5,6 +5,7 @@ import { Button, ButtonVariant } from '../Button';
import { Select } from '../Select/Select';
import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer';
import { ComponentSize } from '../../types/size';
import { selectors } from '@grafana/e2e-selectors';
interface ValuePickerProps<T> {
/** Label to display on the picker button */
......@@ -42,18 +43,20 @@ export function ValuePicker<T>({
{!isPicking && (isFullWidth ? <FullWidthButtonContainer>{buttonEl}</FullWidthButtonContainer> : buttonEl)}
{isPicking && (
<Select
placeholder={label}
options={options}
isOpen
onCloseMenu={() => setIsPicking(false)}
autoFocus={true}
onChange={value => {
setIsPicking(false);
onChange(value);
}}
menuPlacement={menuPlacement}
/>
<span aria-label={selectors.components.ValuePicker.select(label)}>
<Select
placeholder={label}
options={options}
isOpen
onCloseMenu={() => setIsPicking(false)}
autoFocus={true}
onChange={value => {
setIsPicking(false);
onChange(value);
}}
menuPlacement={menuPlacement}
/>
</span>
)}
</>
);
......
......@@ -114,7 +114,8 @@ export type IconName =
| 'favorite'
| 'line-alt'
| 'sort-amount-down'
| 'cloud';
| 'cloud'
| 'draggabledots';
export const getAvailableIcons = (): IconName[] => [
'fa fa-spinner',
......@@ -228,4 +229,5 @@ export const getAvailableIcons = (): IconName[] => [
'favorite',
'sort-amount-down',
'cloud',
'draggabledots',
];
......@@ -7,7 +7,7 @@ describe('QueryOperationRow', () => {
it('renders', () => {
expect(() =>
shallow(
<QueryOperationRow>
<QueryOperationRow id="test-id" index={0}>
<div>Test</div>
</QueryOperationRow>
)
......@@ -20,7 +20,7 @@ describe('QueryOperationRow', () => {
// @ts-ignore strict null error, you shouldn't use promise like approach with act but I don't know what the intention is here
await act(async () => {
shallow(
<QueryOperationRow onOpen={onOpenSpy}>
<QueryOperationRow onOpen={onOpenSpy} id="test-id" index={0}>
<div>Test</div>
</QueryOperationRow>
);
......@@ -32,7 +32,7 @@ describe('QueryOperationRow', () => {
const onOpenSpy = jest.fn();
const onCloseSpy = jest.fn();
const wrapper = mount(
<QueryOperationRow onOpen={onOpenSpy} onClose={onCloseSpy} isOpen={false}>
<QueryOperationRow onOpen={onOpenSpy} onClose={onCloseSpy} isOpen={false} id="test-id" index={0}>
<div>Test</div>
</QueryOperationRow>
);
......@@ -60,7 +60,7 @@ describe('QueryOperationRow', () => {
it('should render title provided as element', () => {
const title = <div aria-label="test title">Test</div>;
const wrapper = shallow(
<QueryOperationRow title={title}>
<QueryOperationRow title={title} id="test-id" index={0}>
<div>Test</div>
</QueryOperationRow>
);
......@@ -71,7 +71,7 @@ describe('QueryOperationRow', () => {
it('should render title provided as function', () => {
const title = () => <div aria-label="test title">Test</div>;
const wrapper = shallow(
<QueryOperationRow title={title}>
<QueryOperationRow title={title} id="test-id" index={0}>
<div>Test</div>
</QueryOperationRow>
);
......@@ -87,7 +87,7 @@ describe('QueryOperationRow', () => {
return <div aria-label="test title">Test</div>;
};
shallow(
<QueryOperationRow title={title}>
<QueryOperationRow title={title} id="test-id" index={0}>
<div>Test</div>
</QueryOperationRow>
);
......@@ -100,7 +100,7 @@ describe('QueryOperationRow', () => {
it('should render actions provided as element', () => {
const actions = <div aria-label="test actions">Test</div>;
const wrapper = shallow(
<QueryOperationRow actions={actions}>
<QueryOperationRow actions={actions} id="test-id" index={0}>
<div>Test</div>
</QueryOperationRow>
);
......@@ -111,7 +111,7 @@ describe('QueryOperationRow', () => {
it('should render actions provided as function', () => {
const actions = () => <div aria-label="test actions">Test</div>;
const wrapper = shallow(
<QueryOperationRow actions={actions}>
<QueryOperationRow actions={actions} id="test-id" index={0}>
<div>Test</div>
</QueryOperationRow>
);
......@@ -127,7 +127,7 @@ describe('QueryOperationRow', () => {
return <div aria-label="test actions">Test</div>;
};
shallow(
<QueryOperationRow actions={actions}>
<QueryOperationRow actions={actions} id="test-id" index={0}>
<div>Test</div>
</QueryOperationRow>
);
......
import React, { useState } from 'react';
import React, { useState, useCallback } from 'react';
import { HorizontalGroup, Icon, renderOrCallToRender, stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { css } from 'emotion';
import { useUpdateEffect } from 'react-use';
import { Draggable } from 'react-beautiful-dnd';
interface QueryOperationRowProps {
index: number;
id: string;
title?: ((props: { isOpen: boolean }) => React.ReactNode) | React.ReactNode;
headerElement?: React.ReactNode;
actions?:
......@@ -14,6 +17,7 @@ interface QueryOperationRowProps {
onClose?: () => void;
children: React.ReactNode;
isOpen?: boolean;
draggable?: boolean;
}
export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({
......@@ -24,10 +28,16 @@ export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({
onClose,
onOpen,
isOpen,
draggable,
index,
id,
}: QueryOperationRowProps) => {
const [isContentVisible, setIsContentVisible] = useState(isOpen !== undefined ? isOpen : true);
const theme = useTheme();
const styles = getQueryOperationRowStyles(theme);
const onRowToggle = useCallback(() => {
setIsContentVisible(!isContentVisible);
}, [isContentVisible, setIsContentVisible]);
useUpdateEffect(() => {
if (isContentVisible) {
......@@ -54,24 +64,37 @@ export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({
},
});
return (
const rowHeader = (
<div className={styles.header}>
<HorizontalGroup justify="space-between">
<div className={styles.titleWrapper} onClick={onRowToggle} aria-label="Query operation row title">
{draggable && (
<Icon title="Drag and drop to reorder" name="draggabledots" size="lg" className={styles.dragIcon} />
)}
<Icon name={isContentVisible ? 'angle-down' : 'angle-right'} className={styles.collapseIcon} />
{title && <span className={styles.title}>{titleElement}</span>}
{headerElement}
</div>
{actions && actionsElement}
</HorizontalGroup>
</div>
);
return draggable ? (
<Draggable draggableId={id} index={index}>
{provided => {
return (
<>
<div ref={provided.innerRef} className={styles.wrapper} {...provided.draggableProps}>
<div {...provided.dragHandleProps}>{rowHeader}</div>
{isContentVisible && <div className={styles.content}>{children}</div>}
</div>
</>
);
}}
</Draggable>
) : (
<div className={styles.wrapper}>
<div className={styles.header}>
<HorizontalGroup justify="space-between">
<div
className={styles.titleWrapper}
onClick={() => {
setIsContentVisible(!isContentVisible);
}}
aria-label="Query operation row title"
>
<Icon name={isContentVisible ? 'angle-down' : 'angle-right'} className={styles.collapseIcon} />
{title && <span className={styles.title}>{titleElement}</span>}
{headerElement}
</div>
{actions && actionsElement}
</HorizontalGroup>
</div>
{rowHeader}
{isContentVisible && <div className={styles.content}>{children}</div>}
</div>
);
......@@ -92,6 +115,10 @@ const getQueryOperationRowStyles = stylesFactory((theme: GrafanaTheme) => {
align-items: center;
justify-content: space-between;
`,
dragIcon: css`
opacity: 0.4;
cursor: drag;
`,
collapseIcon: css`
color: ${theme.colors.textWeak};
&:hover {
......
import React, { useCallback, useMemo } from 'react';
import { css, cx } from 'emotion';
import { css } from 'emotion';
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd';
import {
DataFrame,
......@@ -10,7 +10,7 @@ import {
TransformerUIProps,
getFieldDisplayName,
} from '@grafana/data';
import { stylesFactory, useTheme, Input, IconButton } from '@grafana/ui';
import { stylesFactory, useTheme, Input, IconButton, Icon } from '@grafana/ui';
import { OrganizeFieldsTransformerOptions } from '@grafana/data/src/transformations/transformers/organize';
import { createOrderFieldsComparer } from '@grafana/data/src/transformations/transformers/order';
......@@ -135,7 +135,7 @@ const DraggableFieldName: React.FC<DraggableFieldProps> = ({
>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--justify-left width-30">
<i className={cx('fa fa-ellipsis-v', styles.draggable)} />
<Icon name="draggabledots" title="Drag and drop to reorder" size="lg" className={styles.draggable} />
<IconButton
className={styles.toggle}
size="md"
......@@ -168,8 +168,6 @@ const getFieldNameStyles = stylesFactory((theme: GrafanaTheme) => ({
color: ${theme.colors.textWeak};
`,
draggable: css`
padding: 0 ${theme.spacing.xs};
font-size: ${theme.typography.size.md};
opacity: 0.4;
&:hover {
color: ${theme.colors.textStrong};
......
......@@ -64,7 +64,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
const { timeIndex, timeField } = getTimeField(dataFrame);
if (timeField) {
// Use the configurd date or standandard time display
// Use the configured date or standard time display
let processor: DisplayProcessor | undefined = timeField.display;
if (!processor) {
processor = getDisplayProcessor({
......@@ -224,6 +224,8 @@ export class InspectDataTab extends PureComponent<Props, State> {
return (
<QueryOperationRow
id="Table data options"
index={0}
title="Table data options"
headerElement={<DetailText>{this.getActiveString()}</DetailText>}
isOpen={false}
......
......@@ -2,6 +2,7 @@ import React, { useContext } from 'react';
import { css } from 'emotion';
import { Icon, JSONFormatter, ThemeContext } from '@grafana/ui';
import { GrafanaTheme, DataFrame } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
interface TransformationEditorProps {
name: string;
......@@ -12,15 +13,18 @@ interface TransformationEditorProps {
debugMode?: boolean;
}
export const TransformationEditor = ({ editor, input, output, debugMode }: TransformationEditorProps) => {
export const TransformationEditor = ({ editor, input, output, debugMode, name }: TransformationEditorProps) => {
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
return (
<div className={styles.editor}>
<div className={styles.editor} aria-label={selectors.components.TransformTab.transformationEditor(name)}>
{editor}
{debugMode && (
<div className={styles.debugWrapper}>
<div
className={styles.debugWrapper}
aria-label={selectors.components.TransformTab.transformationEditorDebugger(name)}
>
<div className={styles.debug}>
<div className={styles.debugTitle}>Transformation input data</div>
<div className={styles.debugJson}>
......
......@@ -6,17 +6,21 @@ import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOp
import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction';
interface TransformationOperationRowProps {
id: string;
index: number;
name: string;
description?: string;
editor?: JSX.Element;
onRemove: () => void;
input: DataFrame[];
output: DataFrame[];
editor?: JSX.Element;
onRemove: () => void;
}
export const TransformationOperationRow: React.FC<TransformationOperationRowProps> = ({
children,
onRemove,
index,
id,
...props
}) => {
const [showDebug, setShowDebug] = useState(false);
......@@ -39,7 +43,7 @@ export const TransformationOperationRow: React.FC<TransformationOperationRowProp
};
return (
<QueryOperationRow title={props.name} actions={renderActions}>
<QueryOperationRow id={id} index={index} title={props.name} draggable actions={renderActions}>
<TransformationEditor {...props} debugMode={showDebug} />
</QueryOperationRow>
);
......
import React from 'react';
import { DataTransformerConfig, standardTransformersRegistry } from '@grafana/data';
import { render, screen, fireEvent } from '@testing-library/react';
import { TransformationsEditor } from './TransformationsEditor';
import { PanelModel } from '../../state';
import { getStandardTransformers } from 'app/core/utils/standardTransformers';
import { selectors } from '@grafana/e2e-selectors';
const setup = (transformations: DataTransformerConfig[] = []) => {
const panel = new PanelModel({});
panel.setTransformations(transformations);
render(<TransformationsEditor panel={panel} />);
};
describe('TransformationsEditor', () => {
standardTransformersRegistry.setInit(getStandardTransformers);
describe('when no transformations configured', () => {
it('renders transformations selection list', () => {
setup();
const cards = screen.getAllByLabelText(/^New transform/i);
expect(cards.length).toEqual(standardTransformersRegistry.list().length);
});
});
describe('when transformations configured', () => {
it('renders transformation editors', () => {
setup([
{
id: 'reduce',
options: {},
},
]);
const editors = screen.getAllByLabelText(/^Transformation editor/g);
expect(editors).toHaveLength(1);
});
});
describe('when Add transformation clicked', () => {
it('renders transformations picker', () => {
const buttonLabel = 'Add transformation';
setup([
{
id: 'reduce',
options: {},
},
]);
const addTransformationButton = screen.getByText(buttonLabel);
fireEvent(
addTransformationButton,
new MouseEvent('click', {
bubbles: true,
cancelable: true,
})
);
const picker = screen.getByLabelText(selectors.components.ValuePicker.select(buttonLabel));
expect(picker).toBeDefined();
});
});
describe('actions', () => {
describe('debug', () => {
it('should show/hide debugger', () => {
setup([
{
id: 'reduce',
options: {},
},
]);
const debuggerSelector = selectors.components.TransformTab.transformationEditorDebugger('Reduce');
expect(screen.queryByLabelText(debuggerSelector)).toBeNull();
const debugButton = screen.getByLabelText(selectors.components.QueryEditorRow.actionButton('Debug'));
fireEvent(
debugButton,
new MouseEvent('click', {
bubbles: true,
cancelable: true,
})
);
expect(screen.getByLabelText(debuggerSelector)).toBeInTheDocument();
});
});
});
});
......@@ -26,28 +26,55 @@ import { selectors } from '@grafana/e2e-selectors';
import { Unsubscribable } from 'rxjs';
import { PanelModel } from '../../state';
import { getDocsLink } from 'app/core/utils/docsLinks';
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
interface Props {
interface TransformationsEditorProps {
panel: PanelModel;
}
interface TransformationsEditorTransformation {
transformation: DataTransformerConfig;
id: string;
}
interface State {
data: DataFrame[];
transformations: DataTransformerConfig[];
transformations: TransformationsEditorTransformation[];
}
export class TransformationsEditor extends React.PureComponent<Props, State> {
export class TransformationsEditor extends React.PureComponent<TransformationsEditorProps, State> {
subscription?: Unsubscribable;
constructor(props: Props) {
constructor(props: TransformationsEditorProps) {
super(props);
const transformations = props.panel.transformations || [];
const ids = this.buildTransformationIds(transformations);
this.state = {
transformations: props.panel.transformations || [],
transformations: transformations.map((t, i) => ({
transformation: t,
id: ids[i],
})),
data: [],
};
}
buildTransformationIds(transformations: DataTransformerConfig[]) {
const transformationCounters: Record<string, number> = {};
const transformationIds: string[] = [];
for (let i = 0; i < transformations.length; i++) {
const transformation = transformations[i];
if (transformationCounters[transformation.id] === undefined) {
transformationCounters[transformation.id] = 0;
} else {
transformationCounters[transformation.id] += 1;
}
transformationIds.push(`${transformations[i].id}-${transformationCounters[transformations[i].id]}`);
}
return transformationIds;
}
componentDidMount() {
this.subscription = this.props.panel
.getQueryRunner()
......@@ -63,19 +90,37 @@ export class TransformationsEditor extends React.PureComponent<Props, State> {
}
}
onChange(transformations: DataTransformerConfig[]) {
this.props.panel.setTransformations(transformations);
onChange(transformations: TransformationsEditorTransformation[]) {
this.setState({ transformations });
this.props.panel.setTransformations(transformations.map(t => t.transformation));
}
// Transformation uid are stored in a name-X form. name is NOT unique hence we need to parse the ids and increase X
// for transformations with the same name
getTransformationNextId = (name: string) => {
const { transformations } = this.state;
let nextId = 0;
const existingIds = transformations.filter(t => t.id.startsWith(name)).map(t => t.id);
if (existingIds.length !== 0) {
nextId = Math.max(...existingIds.map(i => parseInt(i.match(/\d+/)![0], 10))) + 1;
}
return `${name}-${nextId}`;
};
onTransformationAdd = (selectable: SelectableValue<string>) => {
const { transformations } = this.state;
const nextId = this.getTransformationNextId(selectable.value!);
this.onChange([
...transformations,
{
id: selectable.value as string,
options: {},
id: nextId,
transformation: {
id: selectable.value as string,
options: {},
},
},
]);
};
......@@ -83,7 +128,7 @@ export class TransformationsEditor extends React.PureComponent<Props, State> {
onTransformationChange = (idx: number, config: DataTransformerConfig) => {
const { transformations } = this.state;
const next = Array.from(transformations);
next[idx] = config;
next[idx].transformation = config;
this.onChange(next);
};
......@@ -122,48 +167,86 @@ export class TransformationsEditor extends React.PureComponent<Props, State> {
);
};
onDragEnd = (result: DropResult) => {
const { transformations } = this.state;
if (!result || !result.destination) {
return;
}
const startIndex = result.source.index;
const endIndex = result.destination.index;
if (startIndex === endIndex) {
return;
}
const update = Array.from(transformations);
const [removed] = update.splice(startIndex, 1);
update.splice(endIndex, 0, removed);
this.onChange(update);
};
renderTransformationEditors = () => {
const { data, transformations } = this.state;
return (
<>
{transformations.map((t, i) => {
let editor;
const transformationUI = standardTransformersRegistry.getIfExists(t.id);
if (!transformationUI) {
return null;
}
const input = transformDataFrame(transformations.slice(0, i), data);
const output = transformDataFrame(transformations.slice(i), input);
if (transformationUI) {
editor = React.createElement(transformationUI.editor, {
options: { ...transformationUI.transformation.defaultOptions, ...t.options },
input,
onChange: (options: any) => {
this.onTransformationChange(i, {
id: t.id,
options,
});
},
});
}
return (
<TransformationOperationRow
key={`${t.id}-${i}`}
input={input || []}
output={output || []}
onRemove={() => this.onTransformationRemove(i)}
editor={editor}
name={transformationUI ? transformationUI.name : ''}
description={transformationUI ? transformationUI.description : ''}
/>
);
})}
</>
<DragDropContext onDragEnd={this.onDragEnd}>
<Droppable droppableId="transformations-list" direction="vertical">
{provided => {
return (
<div ref={provided.innerRef} {...provided.droppableProps}>
{transformations.map((t, i) => {
// Transformations are not identified uniquely by any property apart from array index.
// For drag and drop to work we need to generate unique ids. This record stores counters for each transformation type
// based on which ids are generated
let editor;
const transformationUI = standardTransformersRegistry.getIfExists(t.transformation.id);
if (!transformationUI) {
return null;
}
const input = transformDataFrame(
transformations.slice(0, i).map(t => t.transformation),
data
);
const output = transformDataFrame(
transformations.slice(i).map(t => t.transformation),
input
);
if (transformationUI) {
editor = React.createElement(transformationUI.editor, {
options: { ...transformationUI.transformation.defaultOptions, ...t.transformation.options },
input,
onChange: (options: any) => {
this.onTransformationChange(i, {
id: t.transformation.id,
options,
});
},
});
}
return (
<TransformationOperationRow
index={i}
id={`${t.id}`}
key={`${t.id}`}
input={input || []}
output={output || []}
onRemove={() => this.onTransformationRemove(i)}
editor={editor}
name={transformationUI.name}
description={transformationUI.description}
/>
);
})}
{provided.placeholder}
</div>
);
}}
</Droppable>
</DragDropContext>
);
};
......
......@@ -31,12 +31,14 @@ interface Props {
data: PanelData;
query: DataQuery;
dashboard: DashboardModel;
dataSourceValue: string | null;
inMixedMode?: boolean;
id: string;
index: number;
onAddQuery: (query?: DataQuery) => void;
onRemoveQuery: (query: DataQuery) => void;
onMoveQuery: (query: DataQuery, direction: number) => void;
onChange: (query: DataQuery) => void;
dataSourceValue: string | null;
inMixedMode?: boolean;
}
interface State {
......@@ -276,7 +278,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
};
render() {
const { query } = this.props;
const { query, id, index } = this.props;
const { datasource } = this.state;
const isDisabled = query.hide;
......@@ -293,7 +295,13 @@ export class QueryEditorRow extends PureComponent<Props, State> {
return (
<div aria-label={selectors.components.QueryEditorRows.rows}>
<QueryOperationRow title={this.renderTitle} actions={this.renderActions} onOpen={this.onOpen}>
<QueryOperationRow
id={id}
index={index}
title={this.renderTitle}
actions={this.renderActions}
onOpen={this.onOpen}
>
<div className={rowClasses}>
<ErrorBoundaryAlert>{editor}</ErrorBoundaryAlert>
</div>
......
......@@ -81,6 +81,8 @@ export class QueryEditorRows extends PureComponent<Props> {
return props.queries.map((query, index) => (
<QueryEditorRow
dataSourceValue={query.datasource || props.datasource.value}
id={query.refId}
index={index}
key={query.refId}
panel={props.panel}
dashboard={props.dashboard}
......
......@@ -307,6 +307,8 @@ export class QueryOptions extends PureComponent<Props, State> {
return (
<QueryOperationRow
id="Query options"
index={0}
title="Query options"
headerElement={this.renderCollapsedText(styles)}
isOpen={isOpen}
......
......@@ -8,10 +8,6 @@ const setup = (isSynced: boolean) => {
};
describe('TimeSyncButton', () => {
it('should render component', () => {
const wrapper = setup(true);
expect(wrapper).toMatchSnapshot();
});
it('should change style when synced', () => {
const wrapper = setup(true);
expect(wrapper.find('button').props()['aria-label']).toEqual('Synced times');
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TimeSyncButton should render component 1`] = `
<TimeSyncButton
isSynced={true}
onClick={[Function]}
>
<Component
content={[Function]}
placement="bottom"
>
<PopoverController
content={[Function]}
placement="bottom"
>
<button
aria-label="Synced times"
className="btn navbar-button navbar-button--attached explore-active-button"
onClick={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<Icon
className="css-1c3xqzq-topPadding icon-brand-gradient"
name="link"
size="lg"
>
<div
className="css-1cvxpvr"
>
<UilLink
className="icon-brand-gradient css-1q2xpj8-topPadding"
color="currentColor"
size={18}
>
<svg
className="icon-brand-gradient css-1q2xpj8-topPadding"
fill="currentColor"
height={18}
viewBox="0 0 24 24"
width={18}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10,17.55,8.23,19.27a2.47,2.47,0,0,1-3.5-3.5l4.54-4.55a2.46,2.46,0,0,1,3.39-.09l.12.1a1,1,0,0,0,1.4-1.43A2.75,2.75,0,0,0,14,9.59a4.46,4.46,0,0,0-6.09.22L3.31,14.36a4.48,4.48,0,0,0,6.33,6.33L11.37,19A1,1,0,0,0,10,17.55ZM20.69,3.31a4.49,4.49,0,0,0-6.33,0L12.63,5A1,1,0,0,0,14,6.45l1.73-1.72a2.47,2.47,0,0,1,3.5,3.5l-4.54,4.55a2.46,2.46,0,0,1-3.39.09l-.12-.1a1,1,0,0,0-1.4,1.43,2.75,2.75,0,0,0,.23.21,4.47,4.47,0,0,0,6.09-.22l4.55-4.55A4.49,4.49,0,0,0,20.69,3.31Z"
/>
</svg>
</UilLink>
</div>
</Icon>
</button>
</PopoverController>
</Component>
</TimeSyncButton>
`;
......@@ -3430,6 +3430,13 @@
resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8"
integrity sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==
"@iconscout/react-unicons@1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@iconscout/react-unicons/-/react-unicons-1.1.4.tgz#30731707e1baa49ab1c3198a5e0121be86b8928a"
integrity sha512-lhTSU3nKvzt1OwsRfFyK194QcQbE1Q4rRm6d5BOnKyZB+SN4qRv7tS4wLQgwk/pQyzn14Qw70nGA+tuKLRqcJg==
dependencies:
react ">=15.0.0 <17.0.0"
"@iconscout/react-unicons@^1.0.0":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@iconscout/react-unicons/-/react-unicons-1.0.1.tgz#b5309fac3b0cc27014da1e48edf29dd3d54a672f"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment