Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: LEAP-1454: Custom action buttons in LSF #6411

Merged
merged 11 commits into from
Sep 26, 2024
3 changes: 3 additions & 0 deletions web/libs/editor/src/common/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export interface ButtonProps extends HTMLButtonProps {
tooltip?: string;
tooltipTheme?: "light" | "dark";
nopadding?: boolean;
// Block props
// @todo can be imported/infered from Block
mod?: Record<string, any>;
}

export interface ButtonGroupProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,44 @@
*/

import { inject, observer } from "mobx-react";
import { type Instance } from "mobx-state-tree";
import React, { memo, ReactNode, useCallback, useState } from "react";

import { IconBan, LsChevron } from "../../assets/icons";
import { Button } from "../../common/Button/Button";
import { Dropdown } from "../../common/Dropdown/Dropdown";
import { Tooltip } from "../../common/Tooltip/Tooltip";
import { CustomButton } from "../../stores/CustomButton";
import { Block, cn, Elem } from "../../utils/bem";
import { isDefined } from "../../utils/utilities";
import { IconBan } from "../../assets/icons";
import { FF_REVIEWER_FLOW, isFF } from "../../utils/feature-flags";
import { isDefined } from "../../utils/utilities";

import "./Controls.scss";
import { useCallback, useMemo, useState } from "react";
import { LsChevron } from "../../assets/icons";
import { Dropdown } from "../../common/Dropdown/DropdownComponent";

const TOOLTIP_DELAY = 0.8;

const ButtonTooltip = inject("store")(
type ButtonTooltipProps = {
title: string;
children: JSX.Element;
};

type MixedInParams = {
store: MSTStore;
history: any;
}

function controlsInjector<T extends {}>(fn: (props: T & MixedInParams) => ReactNode) {
const wrapped = inject(({ store }) => {
return {
store,
history: store?.annotationStore?.selected?.history,
};
})(fn);
// inject type doesn't handle the injected props, so we have to force cast it
return wrapped as unknown as (props: T) => ReactNode;
}

const ButtonTooltip = controlsInjector<ButtonTooltipProps>(
observer(({ store, title, children }) => {
return (
<Tooltip title={title} enabled={store.settings.enableTooltips} mouseEnterDelay={TOOLTIP_DELAY}>
Expand All @@ -28,14 +52,42 @@ const ButtonTooltip = inject("store")(
}),
);

const controlsInjector = inject(({ store }) => {
return {
store,
history: store?.annotationStore?.selected?.history,
};
type CustomControlProps = {
button: Instance<typeof CustomButton>;
onClick?: (name: string) => void;
};

/**
* Custom action button component, rendering buttons from store.customButtons
*/
const CustomControl = observer(({ button, onClick }: CustomControlProps) => {
const look = button.disabled ? "disabled" : button.look;
const [waiting, setWaiting] = useState(false);
const clickHandler = useCallback(
async () => {
if (!onClick) return;
setWaiting(true);
await onClick?.(button.name);
setWaiting(false);
},
[button],
);
return (
<ButtonTooltip title={button.tooltip ?? ""}>
<Button
aria-label={button.ariaLabel}
disabled={button.disabled}
look={look}
onClick={clickHandler}
waiting={waiting}
>
{button.title}
</Button>
</ButtonTooltip>
);
});

export const Controls = controlsInjector(
export const Controls = controlsInjector<{ annotation: MSTAnnotation }>(
observer(({ store, history, annotation }) => {
const isReview = store.hasInterface("review") || annotation.canBeReviewed;
const isNotQuickView = store.hasInterface("topbar:prevnext");
Expand All @@ -49,7 +101,7 @@ export const Controls = controlsInjector(
const submitDisabled = store.hasInterface("annotations:deny-empty") && results.length === 0;

const buttonHandler = useCallback(
async (e, callback, tooltipMessage) => {
async (e: React.MouseEvent, callback: () => any, tooltipMessage: string) => {
const { addedCommentThisSession, currentComment, commentFormSubmit } = store.commentStore;

if (isInProgress) return;
Expand Down Expand Up @@ -80,7 +132,7 @@ export const Controls = controlsInjector(
],
);

const RejectButton = useMemo(() => {
const RejectButton = memo(({ disabled, store }: { disabled: boolean, store: MSTStore }) => {
Gondragos marked this conversation as resolved.
Show resolved Hide resolved
return (
<ButtonTooltip key="reject" title="Reject annotation: [ Ctrl+Space ]">
<Button
Expand All @@ -102,12 +154,10 @@ export const Controls = controlsInjector(
</Button>
</ButtonTooltip>
);
}, [disabled, store]);

if (isReview) {
buttons.push(RejectButton);
});

buttons.push(
const AcceptButton = memo(({ disabled, history, store }: { disabled: boolean, history: any, store: MSTStore }) => {
Gondragos marked this conversation as resolved.
Show resolved Hide resolved
return (
<ButtonTooltip key="accept" title="Accept annotation: [ Ctrl+Enter ]">
<Button
aria-label="accept-annotation"
Expand All @@ -123,8 +173,25 @@ export const Controls = controlsInjector(
>
{history.canUndo ? "Fix + Accept" : "Accept"}
</Button>
</ButtonTooltip>,
</ButtonTooltip>
);
});

// custom buttons replace all the internal buttons, but they can be reused if `name` is one of the internal buttons
if (store.customButtons?.length) {
for (const customButton of store.customButtons ?? []) {
// @todo make a list of all internal buttons and use them here to mix custom buttons with internal ones
if (customButton.name === "accept") {
buttons.push(<AcceptButton disabled={disabled} history={history} store={store} />);
} else {
buttons.push(
<CustomControl key={customButton.name} button={customButton} onClick={store.handleCustomButton} />,
);
}
}
} else if (isReview) {
buttons.push(<RejectButton disabled={disabled} store={store} />);
buttons.push(<AcceptButton disabled={disabled} history={history} store={store} />);
} else if (annotation.skipped) {
buttons.push(
<Elem name="skipped-info" key="skipped">
Expand Down Expand Up @@ -179,11 +246,11 @@ export const Controls = controlsInjector(

const useExitOption = !isDisabled && isNotQuickView;

const SubmitOption = ({ isUpdate, onClickMethod }) => {
const SubmitOption = ({ isUpdate, onClickMethod }: { isUpdate: boolean, onClickMethod: () => any}) => {
return (
<Button
name="submit-option"
look="secondary"
look="primary"
onClick={async (event) => {
event.preventDefault();

Expand Down Expand Up @@ -222,15 +289,15 @@ export const Controls = controlsInjector(
look={look}
mod={{ has_icon: useExitOption, disabled: isDisabled }}
onClick={async (event) => {
if (event.target.classList.contains(dropdownTrigger)) return;
if ((event.target as HTMLButtonElement).classList.contains(dropdownTrigger)) return;
const selected = store.annotationStore?.selected;

selected?.submissionInProgress();
await store.commentStore.commentFormSubmit();
store.submitAnnotation();
}}
icon={
useExitOption && (
useExitOption ? (
<Dropdown.Trigger
alignment="top-right"
content={<SubmitOption onClickMethod={store.submitAnnotation} isUpdate={false} />}
Expand All @@ -239,7 +306,7 @@ export const Controls = controlsInjector(
<LsChevron />
</div>
</Dropdown.Trigger>
)
) : undefined
}
>
Submit
Expand All @@ -250,7 +317,7 @@ export const Controls = controlsInjector(
}

if ((userGenerate && sentUserGenerate) || (!userGenerate && store.hasInterface("update"))) {
const isUpdate = isFF(FF_REVIEWER_FLOW) || sentUserGenerate || versions.result;
const isUpdate = Boolean(isFF(FF_REVIEWER_FLOW) || sentUserGenerate || versions.result);
// no changes were made over previously submitted version — no drafts, no pending changes
const noChanges = isFF(FF_REVIEWER_FLOW) && !history.canUndo && !annotation.draftId;
const isUpdateDisabled = isDisabled || noChanges;
Expand All @@ -263,15 +330,15 @@ export const Controls = controlsInjector(
look={look}
mod={{ has_icon: useExitOption, disabled: isUpdateDisabled }}
onClick={async (event) => {
if (event.target.classList.contains(dropdownTrigger)) return;
if ((event.target as HTMLButtonElement).classList.contains(dropdownTrigger)) return;
const selected = store.annotationStore?.selected;

selected?.submissionInProgress();
await store.commentStore.commentFormSubmit();
store.updateAnnotation();
}}
icon={
useExitOption && (
useExitOption ? (
<Dropdown.Trigger
alignment="top-right"
content={<SubmitOption onClickMethod={store.updateAnnotation} isUpdate={isUpdate} />}
Expand All @@ -280,7 +347,7 @@ export const Controls = controlsInjector(
<LsChevron />
</div>
</Dropdown.Trigger>
)
) : undefined
}
>
{isUpdate ? "Update" : "Submit"}
Expand Down
27 changes: 25 additions & 2 deletions web/libs/editor/src/stores/AppStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { destroy, detach, flow, getEnv, getParent, getSnapshot, isRoot, types, w
import uniqBy from "lodash/uniqBy";
import InfoModal from "../components/Infomodal/Infomodal";
import { Hotkey } from "../core/Hotkey";
import { destroy as destroySharedStore } from "../mixins/SharedChoiceStore/mixin";
import ToolsManager from "../tools/Manager";
import Utils from "../utils";
import { guidGenerator } from "../utils/unique";
Expand All @@ -26,7 +27,7 @@ import {
isFF,
} from "../utils/feature-flags";
import { CommentStore } from "./Comment/CommentStore";
import { destroy as destroySharedStore } from "../mixins/SharedChoiceStore/mixin";
import { CustomButton } from "./CustomButton";

const hotkeys = Hotkey("AppStore", "Global Hotkeys");

Expand Down Expand Up @@ -159,6 +160,8 @@ export default types
queueTotal: types.optional(types.number, 0),

queuePosition: types.optional(types.number, 0),

customButtons: types.array(CustomButton, []),
})
.preProcessSnapshot((sn) => {
// This should only be handled if the sn.user value is an object, and converted to a reference id for other
Expand Down Expand Up @@ -547,16 +550,17 @@ export default types
const res = fn();

self.commentStore.setAddedCommentThisSession(false);

// Wait for request, max 5s to not make disabled forever broken button;
// but block for at least 0.2s to prevent from double clicking.

Promise.race([Promise.all([res, delay(200)]), delay(5000)])
.catch((err) => {
showModal(err?.message || err || defaultMessage);
console.error(err);
})
.then(() => self.setFlags({ isSubmitting: false }));
}

function incrementQueuePosition(number = 1) {
self.queuePosition = clamp(self.queuePosition + number, 1, self.queueTotal);
}
Expand Down Expand Up @@ -683,6 +687,24 @@ export default types
}, "Error during reject, try again");
}

function handleCustomButton(button) {
if (self.isSubmitting) return;

handleSubmittingFlag(async () => {
const entity = self.annotationStore.selected;

entity.beforeSend();
// @todo add needsValidation or something like that as a parameter to custom buttons
// if (!entity.validate()) return;

const isDirty = entity.history.canUndo;

await getEnv(self).events.invoke("customButton", self, button, { isDirty, entity });
self.incrementQueuePosition();
entity.dropDraft();
}, `Error during handling ${button} button, try again`);
}

/**
* Exchange storage url for presigned url for task
*/
Expand Down Expand Up @@ -957,6 +979,7 @@ export default types
updateAnnotation,
acceptAnnotation,
rejectAnnotation,
handleCustomButton,
presignUrlForProject,
setUsers,
mergeUsers,
Expand Down
23 changes: 23 additions & 0 deletions web/libs/editor/src/stores/CustomButton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { applySnapshot, getSnapshot, types } from "mobx-state-tree";
import { guidGenerator } from "../utils/unique";

/**
* Custom buttons that can be injected from outside application.
* The only required property is `name`. If the `name` is one of the predefined buttons, it will be rendered as such.
* @see CustomControl in BottomBar/Controls
*/
export const CustomButton = types
.model("CustomButton", {
id: types.optional(types.identifier, guidGenerator),
name: types.string,
title: types.maybe(types.string),
look: types.maybe(types.enumeration(["primary", "danger", "destructive", "alt", "outlined", "active", "disabled"] as const)),
tooltip: types.maybe(types.string),
ariaLabel: types.maybe(types.string),
disabled: types.maybe(types.boolean),
})
.actions((self) => ({
updateProps(newProps: Partial<typeof self>) {
applySnapshot(self, Object.assign({}, getSnapshot(self), newProps));
},
}));
Loading
Loading