Skip to content

Commit

Permalink
#103 - Improves metrics and layouts UX
Browse files Browse the repository at this point in the history
Details:
- Adds Transition.tsx and _transition.scss to handle mount and unmount
transitions for React components
- Adds MessageTooltip as a generic component to display tooltip messages
- Fixes various minor issues
- In layouts and metrics panels, adds a success tooltip message on the
left of the footer when data has properly been saved to the graph
- Replaces warning messages for metric attributes by a tooltip message
on the right of the attribute name input
  • Loading branch information
jacomyal committed Jul 7, 2023
1 parent 033c7a9 commit b5b9919
Show file tree
Hide file tree
Showing 10 changed files with 246 additions and 54 deletions.
68 changes: 68 additions & 0 deletions src/components/MessageTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React, { FC, ReactNode, useEffect, useRef, useState } from "react";
import { AiFillWarning, AiOutlineCheckCircle, AiOutlineInfoCircle } from "react-icons/ai";
import { IconType } from "react-icons";
import cx from "classnames";

import Tooltip, { TooltipAPI } from "./Tooltip";

const DEFAULT_ICONS = {
success: AiOutlineCheckCircle,
info: AiOutlineInfoCircle,
warning: AiFillWarning,
error: AiFillWarning,
} as const;
type MessageType = keyof typeof DEFAULT_ICONS;

const MessageTooltip: FC<{
message: ReactNode;
type?: MessageType;
icon?: IconType;
openOnMount?: number;
className?: string;
iconClassName?: string;
}> = ({ message, type = "info", icon: IconComponent = DEFAULT_ICONS[type], openOnMount, className, iconClassName }) => {
const tooltipRef = useRef<TooltipAPI>(null);
const [timeout, setTimeout] = useState<null | number>(null);

useEffect(() => {
let timeoutID: number | undefined;
if (tooltipRef.current && openOnMount && !tooltipRef.current.isOpened()) {
tooltipRef.current.open();
timeoutID = window.setTimeout(() => {
tooltipRef.current?.close();
}, openOnMount);

setTimeout(timeoutID);
}

return () => {
if (timeoutID) window.clearTimeout(timeoutID);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<Tooltip ref={tooltipRef} attachment="top middle" targetAttachment="bottom middle" targetClassName={className}>
<button
type="button"
className="btn p-0 text"
onMouseEnter={() => {
if (timeout) window.clearTimeout(timeout);
}}
>
<IconComponent className={cx(`text-${type}`, iconClassName)} />
</button>
<div
className="tooltip show bs-tooltip-top p-0 mx-2"
role="tooltip"
onMouseEnter={() => {
if (timeout) window.clearTimeout(timeout);
}}
>
<div className={cx("tooltip-inner", `bg-${type} text-bg-${type}`)}>{message}</div>
</div>
</Tooltip>
);
};

export default MessageTooltip;
34 changes: 22 additions & 12 deletions src/components/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,38 @@ import { forwardRef, ReactNode, useEffect, useRef, useState } from "react";
import TetherComponent from "react-tether";
import Tether from "tether";

export type TooltipAPI = { close: () => void; open: () => void };
import Transition from "./Transition";

export type TooltipAPI = { close: () => void; open: () => void; isOpened: () => boolean };

const Tooltip = forwardRef<
TooltipAPI,
{
children: [ReactNode, ReactNode];
hoverable?: boolean;
closeOnClickContent?: boolean;
targetClassName?: string;
} & Partial<
Pick<
Tether.ITetherOptions,
"attachment" | "constraints" | "offset" | "targetAttachment" | "targetOffset" | "targetModifier"
>
>
>(({ children: [target, content], hoverable, closeOnClickContent, ...tether }, ref) => {
>(({ children: [target, content], targetClassName, hoverable, closeOnClickContent, ...tether }, ref) => {
const [showTooltip, setShowTooltip] = useState<null | "click" | "hover">(null);

const targetWrapper = useRef<HTMLDivElement>(null);
const tooltipWrapper = useRef<HTMLDivElement>(null);

useEffect(() => {
const value = { close: () => setShowTooltip(null), open: () => setShowTooltip("click") };
const value = {
close: () => setShowTooltip(null),
open: () => setShowTooltip("click"),
isOpened: () => !!showTooltip,
};
if (typeof ref === "function") ref(value);
else if (ref) ref.current = value;
}, [ref]);
}, [ref, showTooltip]);

// Handle interactions:
useEffect(() => {
Expand Down Expand Up @@ -78,18 +85,21 @@ const Tooltip = forwardRef<
onMouseEnter={() => {
if (!showTooltip && hoverable) setShowTooltip("hover");
}}
className={targetClassName}
>
<div ref={targetWrapper}>{target}</div>
</div>
)}
renderElement={(ref) =>
showTooltip && (
// We use two divs here to allow having "two refs":
<div ref={ref}>
<div ref={tooltipWrapper}>{content}</div>
</div>
)
}
renderElement={(ref) => (
<Transition
ref={ref}
show={showTooltip}
mountTransition="fade-in 0.2s forwards"
unmountTransition="fade-out 0.2s forwards"
>
<div ref={tooltipWrapper}>{content}</div>
</Transition>
)}
/>
);
});
Expand Down
28 changes: 28 additions & 0 deletions src/components/Transition.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React, { forwardRef, PropsWithChildren, useEffect, useState } from "react";
import { Property } from "csstype";

const Transition = forwardRef<
HTMLDivElement,
PropsWithChildren<{ show: unknown; mountTransition?: Property.Animation; unmountTransition?: Property.Animation }>
>(({ children, show, mountTransition, unmountTransition }, ref) => {
const [shouldRender, setRender] = useState(show);

useEffect(() => {
if (show) setRender(true);
else if (!show && !unmountTransition) setRender(false);
}, [show, unmountTransition]);

return show || shouldRender ? (
<div
ref={ref}
style={{ animation: show ? mountTransition : unmountTransition }}
// onAnimationEnd={() => {
// if (!show) setRender(false);
// }}
>
{children}
</div>
) : null;
});

export default Transition;
42 changes: 26 additions & 16 deletions src/components/forms/TypedInputs.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import cx from "classnames";
import Select from "react-select";
import { FC, InputHTMLAttributes, ReactNode, useMemo } from "react";
import Slider from "rc-slider";
import { SliderProps } from "rc-slider/lib/Slider";
import React, { FC, InputHTMLAttributes, ReactNode, useMemo } from "react";
import { clamp } from "lodash";
import * as React from "react";
import Slider from "rc-slider";
import { MarkObj } from "rc-slider/lib/Marks";
import { SliderProps } from "rc-slider/lib/Slider";

import { DEFAULT_SELECT_PROPS } from "../consts";
import MessageTooltip from "../MessageTooltip";

interface BaseTypedInputProps {
id: string;
Expand Down Expand Up @@ -94,22 +94,32 @@ export const SliderInput: FC<
};

export const StringInput: FC<
{ value: string | null; onChange: (v: string) => void } & BaseTypedInputProps &
{ value: string | null; onChange: (v: string) => void; warning?: string } & BaseTypedInputProps &
Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "id">
> = ({ id, label, description, value, onChange, className, ...attrs }) => {
> = ({ id, label, description, value, onChange, className, warning, ...attrs }) => {
return (
<div className="mt-1">
<label htmlFor={id} className="form-check-label small ms-1">
<label htmlFor={id} className="form-check-label small">
{label}
</label>
<input
{...attrs}
type="string"
className={cx("form-control form-control-sm", className)}
id={id}
value={value || ""}
onChange={(e) => onChange(e.target.value)}
/>
<div className="position-relative">
<input
{...attrs}
type="string"
className={cx("form-control form-control-sm", className)}
id={id}
value={value || ""}
onChange={(e) => onChange(e.target.value)}
/>
{warning && (
<MessageTooltip
message={warning}
type="warning"
className="position-absolute end-0 top-0 h-100 me-2 d-flex align-items-center"
iconClassName="fs-4"
/>
)}
</div>
{description && <div className="form-text small text-muted">{description}</div>}
</div>
);
Expand All @@ -128,7 +138,7 @@ export const BooleanInput: FC<
className={cx("form-check-input", className)}
id={id}
checked={value ?? false}
onChange={(e) => onChange(!!e.target.checked)}
onChange={(e) => onChange(e.target.checked)}
/>
<label htmlFor={id} className="form-check-label small ms-1">
{label}
Expand Down
8 changes: 4 additions & 4 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,8 @@
"title": "Statistics",
"compute_one": "Compute metric",
"compute": "Compute metrics",
"success_one": "The {{items}} metric {{metrics}} has been added to the data. It is now available in other panels.",
"success": "The {{items}} metrics {{metrics}} have been added to the data. They are now available in other panels.",
"success_one": "The {{items}} metric \"{{metrics}}\" has been added to the data.",
"success": "The {{items}} metrics \"{{metrics}}\" have been added to the data.",
"description": "This panel allows computing new attributes to the nodes or edges of the graph. These attributes can later be used in the other panels, for appearance or filtering for instance.",
"placeholder": "Select an algorithm",
"attributes_placeholder": "None",
Expand Down Expand Up @@ -461,10 +461,10 @@
},
"layouts": {
"title": "Layout",
"description": "This panel allows computing new coordintaes to nodes of the graph. ",
"description": "This panel allows computing new coordinates to nodes of the graph. ",
"placeholder": "Select a layout algorithm",
"exec": {
"success": "Layout {{layout}} has been applied",
"success": "Layout \"{{layout}}\" has successfully been applied.",
"started": "Layout {{layout}} is running",
"stopped": "Layout {{layout}} has been stopped"
},
Expand Down
File renamed without changes.
16 changes: 16 additions & 0 deletions src/styles/_transition.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
3 changes: 2 additions & 1 deletion src/styles/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,6 @@
@import "user";
@import "filters";
@import "slider";
@import "highlighjs";
@import "highlightjs";
@import "graph-caption";
@import "transition";
43 changes: 37 additions & 6 deletions src/views/graphPage/LayoutsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FC, useMemo, useState, useEffect, useCallback } from "react";
import React, { FC, useMemo, useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import Select from "react-select";
import { FaPlay, FaStop } from "react-icons/fa";
Expand All @@ -20,6 +20,7 @@ import { DEFAULT_SELECT_PROPS } from "../../components/consts";
import { LayoutsIcon, CodeEditorIcon } from "../../components/common-icons";
import { BooleanInput, EnumInput, NumberInput } from "../../components/forms/TypedInputs";
import { FunctionEditorModal } from "./modals/FunctionEditorModal";
import MessageTooltip from "../../components/MessageTooltip";

type LayoutOption = {
value: string;
Expand All @@ -39,9 +40,13 @@ export const LayoutForm: FC<{
const dataset = useGraphDataset();
const sigmaGraph = useSigmaGraph();
const { nodeFields, edgeFields } = dataset;
const [success, setSuccess] = useState<{ date: number; message: string } | null>(null);
// get layout parameter from the session if it exists
const [session, setSession] = useAtom(sessionAtom);
const layoutParameters = session.layoutsParameters[layout.id] || {};
const layoutParameters = useMemo(
() => session.layoutsParameters[layout.id] || {},
[layout.id, session.layoutsParameters],
);

// default layout parameters
const layoutDefaultParameters = useMemo(
Expand Down Expand Up @@ -108,10 +113,24 @@ export const LayoutForm: FC<{
[layout.id, layoutDefaultParameters, setSession],
);

function submit() {
const setSuccessMessage = useCallback((message?: string) => {
if (typeof message === "string") {
setSuccess({ date: Date.now(), message });
} else {
setSuccess(null);
}
}, []);

const submit = useCallback(() => {
if (isRunning) onStop();
else onStart(layoutParameters);
}
else {
try {
onStart(layoutParameters);
if (layout.type === "sync")
setSuccessMessage(t("layouts.exec.success", { layout: t(`layouts.${layout.id}.title`) as string }) as string);
} catch (e) {}
}
}, [isRunning, layout.id, layout.type, layoutParameters, onStart, onStop, setSuccessMessage, t]);

return (
<form
Expand Down Expand Up @@ -230,7 +249,18 @@ export const LayoutForm: FC<{

<hr className="m-0" />

<div className="text-end z-over-loader panel-block">
<div className="z-over-loader panel-block d-flex flex-row align-items-center">
{success && (
<MessageTooltip
openOnMount={2000}
key={success.date}
message={success.message}
type="success"
iconClassName="fs-4"
/>
)}
<div className="flex-grow-1" />

{layout.buttons?.map(({ id, description, getSettings }) => (
<button
key={id}
Expand Down Expand Up @@ -311,6 +341,7 @@ export const LayoutsPanel: FC = () => {
<>
<hr className="m-0" />
<LayoutForm
key={option.layout.id}
layout={option.layout}
onStart={(params) => {
start(option.layout.id, params);
Expand Down
Loading

0 comments on commit b5b9919

Please sign in to comment.