Skip to content

Commit

Permalink
feat: LEAP-1492: More flexible Custom buttons (#6454)
Browse files Browse the repository at this point in the history
- extend the structure of custom buttons definition to allow different modes
- custom buttons now can replace just some buttons, leaving the rest untouched
- start to simplify work with all buttons to use standard button description + additional wrappers for main actions, so any button action will go through mandatory checks

Examples of new structure:
```js
// switch buttons
{ "_replace": ["update", "submit"] }

// add simple custom button
{ "_before": [{ "name": "analyze", "title": "Analyze task" }] }

// split Reject into 2 buttons, leaving normal Accept button;
// these 2 new buttons share part of reject logic
{ "reject": [
  { "name": "hide", "title": "Hide" },
  { "name": "postpone", "title": "Postpone" },
] }
```

Co-authored-by: hlomzik <hlomzik@users.noreply.github.com>
Co-authored-by: Sergey <sergey.koshevarov@humansignal.com>
  • Loading branch information
3 people authored Oct 7, 2024
1 parent 0689e4f commit 65f69dd
Show file tree
Hide file tree
Showing 9 changed files with 165 additions and 98 deletions.
28 changes: 22 additions & 6 deletions web/libs/datamanager/src/sdk/lsf-sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,29 @@ export class LSFWrapper {
* @param {LSFOptions} options
*/
constructor(dm, element, options) {
// we need to pass the rest of the options to LSF below
const {
task,
preload,
isLabelStream,
annotation,
interfacesModifier,
isInteractivePreannotations,
user,
keymap,
messages,
...restOptions
} = options;

this.datamanager = dm;
this.store = dm.store;
this.root = element;
this.task = options.task;
this.preload = options.preload;
this.labelStream = options.isLabelStream ?? false;
this.initialAnnotation = options.annotation;
this.interfacesModifier = options.interfacesModifier;
this.isInteractivePreannotations = options.isInteractivePreannotations ?? false;
this.task = task;
this.preload = preload;
this.labelStream = isLabelStream ?? false;
this.initialAnnotation = annotation;
this.interfacesModifier = interfacesModifier;
this.isInteractivePreannotations = isInteractivePreannotations ?? false;

let interfaces = [...DEFAULT_INTERFACES];

Expand Down Expand Up @@ -192,6 +206,8 @@ export class LSFWrapper {
onSelectAnnotation: this.onSelectAnnotation,
onNextTask: this.onNextTask,
onPrevTask: this.onPrevTask,

...restOptions,
};

this.initLabelStudio(lsfProperties);
Expand Down
118 changes: 82 additions & 36 deletions web/libs/editor/src/components/BottomBar/Controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,43 @@ import { Dropdown } from "../../common/Dropdown/Dropdown";
import type { 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 { isDefined, toArray } from "../../utils/utilities";
import {
AcceptButton,
ButtonTooltip,
controlsInjector,
RejectButtonDefinition,
SkipButton,
UnskipButton,
} from "./buttons";

import "./Controls.scss";

type CustomControlProps = {
button: Instance<typeof CustomButton>;
type CustomButtonType = Instance<typeof CustomButton>;
// these buttons can be reused inside custom buttons or can be replaces with custom buttons
type SupportedInternalButtons = "accept" | "reject";
// special places for custom buttons — before, after or instead of internal buttons
type SpecialPlaces = "_before" | "_after" | "_replace";
// @todo should be Instance<typeof AppStore>["customButtons"] but it doesn't fit to itself
type CustomButtonsField = Map<
SpecialPlaces | SupportedInternalButtons,
CustomButtonType | SupportedInternalButtons | Array<CustomButtonType | SupportedInternalButtons>
>;
type ControlButtonProps = {
button: CustomButtonType;
disabled: boolean;
onClick?: (name: string) => void;
onClick: (e: React.MouseEvent) => void;
};

/**
* 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);
}, []);

return (
<ButtonTooltip title={button.tooltip ?? ""}>
<Button
aria-label={button.ariaLabel}
disabled={button.disabled || disabled || waiting}
look={look}
onClick={clickHandler}
waiting={waiting}
>
<Button aria-label={button.ariaLabel} disabled={button.disabled || disabled} look={look} onClick={onClick}>
{button.title}
</Button>
</ButtonTooltip>
Expand All @@ -60,15 +65,20 @@ 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;
const comment = currentComment[annotation.id];
// accept both old and new comment formats
const commentText = (comment?.text ?? comment)?.trim();

if (isInProgress) return;
setIsInProgress(true);
Expand All @@ -78,13 +88,13 @@ export const Controls = controlsInjector<{ annotation: MSTAnnotation }>(
if (addedCommentThisSession) {
selected?.submissionInProgress();
callback();
} else if (currentComment[annotation.id]?.trim()) {
} else if (commentText) {
e.preventDefault();
selected?.submissionInProgress();
await commentFormSubmit();
callback();
} else {
store.commentStore.setTooltipMessage(tooltipMessage);
store.commentStore.setTooltipMessage(errorMessage);
}
setIsInProgress(false);
},
Expand All @@ -98,29 +108,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");
const firstToRender = buttonsReplacement ?? buttonsBefore;

// either we render _before buttons and then the rest, or we render only _replace buttons
if (firstToRender) {
const allButtons = toArray(firstToRender);
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} />);
// string buttons is a way to render internal buttons
if (typeof customButton === "string") {
if (customButton === "accept") {
// 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;
// @todo implement reuse of internal buttons later (they are set as strings)
const rejectButtons: CustomButtonType[] = hasCustomReject
? 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 +178,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
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const mockStore = {
},
},
},
customButtons: new Map(),
};

const mockHistory = {
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
9 changes: 8 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,9 @@ 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 All @@ -178,6 +180,11 @@ export default types
: [currentUser];
}
}
// fix for old version of custom buttons which were just an array
// @todo remove after a short time
if (Array.isArray(sn.customButtons)) {
sn.customButtons = { _replace: sn.customButtons };
}
return {
...sn,
_autoAnnotation: localStorage.getItem("autoAnnotation") === "true",
Expand Down
30 changes: 12 additions & 18 deletions web/libs/editor/src/stores/CustomButton.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
import { applySnapshot, getSnapshot, types } from "mobx-state-tree";
import { 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));
},
}));
export const CustomButton = types.model("CustomButton", {
id: types.optional(types.identifier, guidGenerator),
name: 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),
});
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;
settings: Record<string, boolean>;
isSubmitting: boolean;
// @todo WHAT IS THIS?
Expand Down
20 changes: 19 additions & 1 deletion web/libs/editor/src/utils/__tests__/utilities.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* global it, describe, expect, test */
import { emailFromCreatedBy, getUrl, isString, isStringEmpty, isStringJSON, toTimeString } from "../utilities";
import { emailFromCreatedBy, toArray, getUrl, isString, isStringEmpty, isStringJSON, toTimeString } from "../utilities";

describe("Helper function emailFromCreatedBy", () => {
expect(emailFromCreatedBy("abc@def.com, 12")).toBe("abc@def.com");
Expand All @@ -14,6 +14,24 @@ describe("Helper function emailFromCreatedBy", () => {
expect(emailFromCreatedBy("ab.c+12@def.com.pt")).toBe("ab.c+12@def.com.pt");
});

describe("Helper function toArray, converting any value to array, skipping undefined values", () => {
test("Empty", () => {
expect(toArray()).toEqual([]);
});

test("Single value", () => {
expect(toArray("value")).toEqual(["value"]);
});

test("Zero", () => {
expect(toArray(0)).toEqual([0]);
});

test("Array", () => {
expect(toArray(["value"])).toEqual(["value"]);
});
});

/**
* isString
*/
Expand Down
Loading

0 comments on commit 65f69dd

Please sign in to comment.