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-1492: More flexible Custom buttons #6454

Merged
merged 11 commits into from
Oct 7, 2024
104 changes: 73 additions & 31 deletions web/libs/editor/src/components/BottomBar/Controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,40 +12,44 @@ import { useCallback, useState } from "react";
import { IconBan, LsChevron } from "../../assets/icons";
import { Button } from "../../common/Button/Button";
import { Dropdown } from "../../common/Dropdown/Dropdown";
import type { CustomButton } from "../../stores/CustomButton";
import { CustomButton } from "../../stores/CustomButton";
import { Block, cn, Elem } from "../../utils/bem";
import { FF_REVIEWER_FLOW, isFF } from "../../utils/feature-flags";
import { isDefined } from "../../utils/utilities";
import { AcceptButton, ButtonTooltip, controlsInjector, RejectButton, SkipButton, UnskipButton } from "./buttons";
import { AcceptButton, ButtonTooltip, controlsInjector, RejectButtonDefinition, SkipButton, UnskipButton } from "./buttons";

import "./Controls.scss";

type CustomControlProps = {
button: Instance<typeof CustomButton>;
type CustomButtonType = Instance<typeof CustomButton>;
// @todo should be Instance<typeof AppStore>["customButtons"] but it doesn't fit to itself
type CustomButtonsField = Map<string, CustomButtonType | string | Array<CustomButtonType | string>>;
hlomzik marked this conversation as resolved.
Show resolved Hide resolved
type ControlButtonProps = {
button: CustomButtonType;
disabled: boolean;
onClick?: (name: string) => void;
onClick: (e: React.MouseEvent) => void;
};

/** If given one element, wrap it in an array */
function toArray<T>(arg: undefined | T | (T | undefined)[]): T[] {
return (Array.isArray(arg) ? arg : [arg]).filter(v => v !== undefined);
}
hlomzik marked this conversation as resolved.
Show resolved Hide resolved

/**
* Custom action button component, rendering buttons from store.customButtons
*/
const CustomControl = observer(({ button, disabled, onClick }: CustomControlProps) => {
const ControlButton = observer(({ button, disabled, onClick }: ControlButtonProps) => {
const look = button.disabled || disabled ? "disabled" : button.look;
const [waiting, setWaiting] = useState(false);
const clickHandler = useCallback(async () => {
if (!onClick) return;
setWaiting(true);
await onClick?.(button.name);
setWaiting(false);
}, []);
// @todo do we need waiting? all buttons should utilize isSubmitting
hlomzik marked this conversation as resolved.
Show resolved Hide resolved
// const [waiting, setWaiting] = useState(false);
hlomzik marked this conversation as resolved.
Show resolved Hide resolved

return (
<ButtonTooltip title={button.tooltip ?? ""}>
<Button
aria-label={button.ariaLabel}
disabled={button.disabled || disabled || waiting}
disabled={button.disabled || disabled}// || waiting}
hlomzik marked this conversation as resolved.
Show resolved Hide resolved
look={look}
onClick={clickHandler}
waiting={waiting}
onClick={onClick}
// waiting={waiting}
hlomzik marked this conversation as resolved.
Show resolved Hide resolved
>
{button.title}
</Button>
Expand All @@ -60,14 +64,16 @@ export const Controls = controlsInjector<{ annotation: MSTAnnotation }>(
const historySelected = isDefined(store.annotationStore.selectedHistory);
const { userGenerate, sentUserGenerate, versions, results, editable: annotationEditable } = annotation;
const dropdownTrigger = cn("dropdown").elem("trigger").toClassName();
const customButtons: CustomButtonsField = store.customButtons;
const buttons = [];

const [isInProgress, setIsInProgress] = useState(false);
const disabled = !annotationEditable || store.isSubmitting || historySelected || isInProgress;
const submitDisabled = store.hasInterface("annotations:deny-empty") && results.length === 0;

const buttonHandler = useCallback(
async (e: React.MouseEvent, callback: () => any, tooltipMessage: string) => {
/** Check all things related to comments and then call the action if all is good */
const handleActionWithComments = useCallback(
async (e: React.MouseEvent, callback: () => any, errorMessage: string) => {
const { addedCommentThisSession, currentComment, commentFormSubmit } = store.commentStore;

if (isInProgress) return;
Expand All @@ -84,7 +90,7 @@ export const Controls = controlsInjector<{ annotation: MSTAnnotation }>(
await commentFormSubmit();
callback();
} else {
store.commentStore.setTooltipMessage(tooltipMessage);
store.commentStore.setTooltipMessage(errorMessage);
}
setIsInProgress(false);
},
Expand All @@ -98,29 +104,65 @@ export const Controls = controlsInjector<{ annotation: MSTAnnotation }>(
],
);

// 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 ?? []) {
const buttonsBefore = customButtons.get("_before");
const buttonsReplacement = customButtons.get("_replace");
hlomzik marked this conversation as resolved.
Show resolved Hide resolved
const firstToRender = buttonsReplacement ?? buttonsBefore;

// either we render _before buttons and then the rest, or we render only _replace buttons
if (firstToRender) {
const allButtons = Array.isArray(firstToRender) ? firstToRender : [firstToRender];
hlomzik marked this conversation as resolved.
Show resolved Hide resolved
for (const customButton of allButtons) {
// @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} />);
Gondragos marked this conversation as resolved.
Show resolved Hide resolved
// string buttons is a way to render internal buttons
if (typeof customButton === "string") {
if (customButton === "accept") {
hlomzik marked this conversation as resolved.
Show resolved Hide resolved
// just an example of internal button usage
// @todo move buttons to separate components
buttons.push(<AcceptButton disabled={disabled} history={history} store={store} />);
}
} else {
buttons.push(
<CustomControl
<ControlButton
key={customButton.name}
disabled={disabled}
button={customButton}
onClick={store.handleCustomButton}
onClick={() => store.handleCustomButton?.(customButton.name)}
/>,
);
}
}
}

if (buttonsReplacement) {
// do nothing as all custom buttons are rendered already and we don't need internal buttons
} else if (isReview) {
const onRejectWithComment = (e: React.MouseEvent, action: () => any) => {
buttonHandler(e, action, "Please enter a comment before rejecting");
};
const customRejectButtons = toArray(customButtons.get("reject"));
const hasCustomReject = customRejectButtons.length > 0;
const originalRejectButton = RejectButtonDefinition;
const rejectButtons: CustomButtonType[] = hasCustomReject
// @todo implement reuse of internal buttons later (they are set as strings)
? customRejectButtons.filter(button => typeof button !== "string")
: [originalRejectButton];

rejectButtons.forEach(button => {
const action = hasCustomReject
? () => store.handleCustomButton?.(button.name)
: () => store.rejectAnnotation({});

const onReject = async (e: React.MouseEvent) => {
const selected = store.annotationStore?.selected;

if (store.hasInterface("comments:reject")) {
handleActionWithComments(e, action, "Please enter a comment before rejecting");
} else {
selected?.submissionInProgress();
await store.commentStore.commentFormSubmit();
action();
}
}

buttons.push(<RejectButton disabled={disabled} store={store} onRejectWithComment={onRejectWithComment} />);
buttons.push(<ControlButton button={button} disabled={disabled} onClick={onReject} />);
});
buttons.push(<AcceptButton disabled={disabled} history={history} store={store} />);
} else if (annotation.skipped) {
buttons.push(
Expand All @@ -132,7 +174,7 @@ export const Controls = controlsInjector<{ annotation: MSTAnnotation }>(
} else {
if (store.hasInterface("skip")) {
const onSkipWithComment = (e: React.MouseEvent, action: () => any) => {
buttonHandler(e, action, "Please enter a comment before skipping");
handleActionWithComments(e, action, "Please enter a comment before skipping");
};

buttons.push(<SkipButton disabled={disabled} store={store} onSkipWithComment={onSkipWithComment} />);
Expand Down
44 changes: 9 additions & 35 deletions web/libs/editor/src/components/BottomBar/buttons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,43 +72,17 @@ export const AcceptButton = memo(
}),
);

type RejectButtonProps = {
disabled: boolean;
store: MSTStore;
/**
* Handler wrapper for reject with required comment,
* conditions are checked in wrapper and if all good the `action` is called.
**/
onRejectWithComment: (event: React.MouseEvent, action: () => any) => void;
export const RejectButtonDefinition = {
id: "reject",
name: "reject",
title: "Reject",
look: undefined,
ariaLabel: "reject-annotation",
tooltip: "Reject annotation: [ Ctrl+Space ]",
// @todo we need this for types compatibility, but better to fix CustomButtonType
disabled: false,
};

export const RejectButton = memo(
observer(({ disabled, store, onRejectWithComment }: RejectButtonProps) => {
return (
<ButtonTooltip key="reject" title="Reject annotation: [ Ctrl+Space ]">
<Button
aria-label="reject-annotation"
disabled={disabled}
onClick={async (e) => {
const action = () => store.rejectAnnotation({});
const selected = store.annotationStore?.selected;

if (store.hasInterface("comments:reject") ?? true) {
onRejectWithComment(e, action);
} else {
selected?.submissionInProgress();
await store.commentStore.commentFormSubmit();
action();
}
}}
>
Reject
</Button>
</ButtonTooltip>
);
}),
);

type SkipButtonProps = {
disabled: boolean;
store: MSTStore;
Expand Down
6 changes: 5 additions & 1 deletion web/libs/editor/src/stores/AppStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,11 @@ export default types

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

customButtons: types.array(CustomButton, []),
customButtons: types.map(types.union(
types.string,
CustomButton,
types.array(types.union(types.string, 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
11 changes: 3 additions & 8 deletions web/libs/editor/src/stores/CustomButton.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { applySnapshot, getSnapshot, types } from "mobx-state-tree";
import { types } from "mobx-state-tree";
import { guidGenerator } from "../utils/unique";

/**
Expand All @@ -10,16 +10,11 @@ export const CustomButton = types
.model("CustomButton", {
id: types.optional(types.identifier, guidGenerator),
name: types.string,
title: types.maybe(types.string),
title: 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));
},
}));
});
3 changes: 2 additions & 1 deletion web/libs/editor/src/stores/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ type MSTCommentStore = {
};

type MSTStore = {
customButtons: Instance<typeof CustomButton>[];
// @todo we can't import CustomButton store here and use it type :(
customButtons: any;
hlomzik marked this conversation as resolved.
Show resolved Hide resolved
settings: Record<string, boolean>;
isSubmitting: boolean;
// @todo WHAT IS THIS?
Expand Down
Loading