import { observer } from "mobx-react";
|
import { types } from "mobx-state-tree";
|
|
import RequiredMixin from "../../mixins/Required";
|
import PerRegionMixin from "../../mixins/PerRegion";
|
import InfoModal from "../../components/Infomodal/Infomodal";
|
import Registry from "../../core/Registry";
|
import SelectedModelMixin from "../../mixins/SelectedModel";
|
import VisibilityMixin from "../../mixins/Visibility";
|
import Tree from "../../core/Tree";
|
import Types from "../../core/Types";
|
import { guidGenerator } from "../../core/Helpers";
|
import ControlBase from "./Base";
|
import { AnnotationMixin } from "../../mixins/AnnotationMixin";
|
import { cn } from "../../utils/bem";
|
import "./Choices/Choices.scss";
|
|
import "./Choice";
|
import DynamicChildrenMixin from "../../mixins/DynamicChildrenMixin";
|
import { FF_LSDV_4583, isFF } from "../../utils/feature-flags";
|
import { ReadOnlyControlMixin } from "../../mixins/ReadOnlyMixin";
|
import SelectedChoiceMixin from "../../mixins/SelectedChoiceMixin";
|
import ClassificationBase from "./ClassificationBase";
|
import PerItemMixin from "../../mixins/PerItem";
|
import Infomodal from "../../components/Infomodal/Infomodal";
|
import { useMemo } from "react";
|
import { Select, Tooltip } from "@humansignal/ui";
|
|
/**
|
* The `Choices` tag is used to create a group of choices, with radio buttons or checkboxes. It can be used for single or multi-class classification. Also, it is used for advanced classification tasks where annotators can choose one or multiple answers.
|
*
|
* Choices can have dynamic value to load labels from task. This task data should contain a list of options to create underlying `<Choice>`s. All the parameters from options will be transferred to corresponding tags.
|
*
|
* The `Choices` tag can be used with any data types.
|
*
|
* @example
|
* <!--Basic text classification labeling configuration-->
|
* <View>
|
* <Choices name="gender" toName="txt-1" choice="single-radio">
|
* <Choice alias="M" value="Male" />
|
* <Choice alias="F" value="Female" />
|
* <Choice alias="NB" value="Nonbinary" />
|
* <Choice alias="X" value="Other" />
|
* </Choices>
|
* <Text name="txt-1" value="John went to see Mary" />
|
* </View>
|
*
|
* @example <caption>This config with dynamic labels</caption>
|
* <!--
|
* `Choice`s can be loaded dynamically from task data. It should be an array of objects with attributes.
|
* `html` can be used to show enriched content, it has higher priority than `value`, however `value` will be used in the exported result.
|
* -->
|
* <View>
|
* <Audio name="audio" value="$audio" />
|
* <Choices name="transcription" toName="audio" value="$variants" />
|
* </View>
|
* <!-- {
|
* "data": {
|
* "variants": [
|
* { "value": "Do or doughnut. There is no try.", "html": "<img src='https://labelstud.io/images/logo.png'>" },
|
* { "value": "Do or do not. There is no trial.", "html": "<h1>You can use hypertext here</h2>" },
|
* { "value": "Do or do not. There is no try." },
|
* { "value": "Duo do not. There is no try." }
|
* ]
|
* }
|
* } -->
|
*
|
* @example <caption>is equivalent to this config</caption>
|
* <View>
|
* <Audio name="audio" value="$audio" />
|
* <Choices name="transcription" toName="audio" value="$variants">
|
* <Choice value="Do or doughnut. There is no try." />
|
* <Choice value="Do or do not. There is no trial." />
|
* <Choice value="Do or do not. There is no try." />
|
* <Choice value="Duo do not. There is no try." />
|
* </Choices>
|
* </View>
|
* @name Choices
|
* @meta_title Choices Tag for Multiple Choice Labels
|
* @meta_description Customize Label Studio with multiple choice labels for machine learning and data science projects.
|
* @param {string} name - Name of the group of choices
|
* @param {string} toName - Name of the data item that you want to label
|
* @param {single|single-radio|multiple} [choice=single] - Single or multi-class classification
|
* @param {boolean} [showInline=false] - Show choices in the same visual line
|
* @param {boolean} [required=false] - Validate whether a choice has been selected
|
* @param {string} [requiredMessage] - Show a message if validation fails
|
* @param {region-selected|no-region-selected|choice-selected|choice-unselected} [visibleWhen] - Control visibility of the choices. Can also be used with the `when*` parameters below to narrow down visibility
|
* @param {string} [whenTagName] - Use with `visibleWhen`. Narrow down visibility by name of the tag. For regions, use the name of the object tag, for choices, use the name of the `choices` tag
|
* @param {string} [whenLabelValue] - Use with `visibleWhen="region-selected"`. Narrow down visibility by label value. Multiple values can be separated with commas
|
* @param {string} [whenChoiceValue] - Use with `visibleWhen` (`"choice-selected"` or `"choice-unselected"`) and `whenTagName`, both are required. Narrow down visibility by choice value. Multiple values can be separated with commas
|
* @param {boolean} [perRegion] - Use this tag to select a choice for a specific region instead of the entire task
|
* @param {boolean} [perItem] - Use this tag to select a choice for a specific item inside the object instead of the whole object
|
* @param {string} [value] - Task data field containing a list of dynamically loaded choices (see example below)
|
* @param {boolean} [allowNested] - Allow to use `children` field in dynamic choices to nest them. Submitted result will contain array of arrays, every item is a list of values from topmost parent choice down to selected one.
|
* @param {select|inline|vertical} [layout] - Layout of the choices: `select` for dropdown/select box format, `inline` for horizontal single row display, `vertical` for vertically stacked display (default)
|
*/
|
const TagAttrs = types.model({
|
toname: types.maybeNull(types.string),
|
showinline: types.maybeNull(types.boolean),
|
choice: types.optional(types.enumeration(["single", "single-radio", "multiple"]), "single"),
|
layout: types.optional(types.enumeration(["select", "inline", "vertical"]), "vertical"),
|
value: types.optional(types.string, ""),
|
allownested: types.optional(types.boolean, false),
|
});
|
|
const Model = types
|
.model({
|
pid: types.optional(types.string, guidGenerator),
|
|
visible: types.optional(types.boolean, true),
|
|
type: "choices",
|
children: Types.unionArray(["choice", "view", "header", "hypertext"]),
|
})
|
.views((self) => ({
|
get shouldBeUnselected() {
|
return self.choice === "single" || self.choice === "single-radio";
|
},
|
|
states() {
|
return self.annotation.toNames.get(self.name);
|
},
|
|
get serializableValue() {
|
const choices = self.selectedValues();
|
|
if (choices && choices.length) return { choices };
|
|
return null;
|
},
|
|
get preselectedValues() {
|
return self.tiedChildren.filter((c) => c.selected === true && !c.isSkipped).map((c) => c.resultValue);
|
},
|
|
get selectedLabels() {
|
return self.tiedChildren.filter((c) => c.sel === true && !c.isSkipped);
|
},
|
|
selectedValues() {
|
return self.selectedLabels.map((c) => c.resultValue);
|
},
|
|
get defaultChildType() {
|
return "choice";
|
},
|
|
// perChoiceVisible() {
|
// if (! self.whenchoicevalue) return true;
|
|
// // this is a special check when choices are labeling other choices
|
// // may need to show
|
// if (self.whenchoicevalue) {
|
// const choicesTag = self.annotation.names.get(self.toname);
|
// const ch = choicesTag.findLabel(self.whenchoicevalue);
|
|
// if (ch && ch.selected)
|
// return true;
|
// }
|
|
// return false;
|
// }
|
}))
|
.actions((self) => ({
|
afterCreate() {
|
// TODO depricate showInline
|
if (self.showinline === true) self.layout = "inline";
|
if (self.showinline === false) self.layout = "vertical";
|
},
|
|
needsUpdate() {
|
if (self.result) self.setResult(self.result.mainValue);
|
else self.setResult([]);
|
},
|
|
requiredModal() {
|
InfoModal.warning(self.requiredmessage || `Checkbox "${self.name}" is required.`);
|
},
|
|
// this is not labels, unselect affects result, so don't unselect on random reason
|
unselectAll() {},
|
|
updateFromResult(value) {
|
self.setResult(Array.isArray(value) ? value : [value]);
|
},
|
|
// unselect only during choice toggle
|
resetSelected() {
|
self.selectedLabels.forEach((c) => c.setSelected(false));
|
},
|
|
setResult(values) {
|
self.tiedChildren.forEach((choice) => {
|
let isSelected = false;
|
|
if (!choice.isSkipped) {
|
isSelected = values?.some?.((value) => {
|
if (Array.isArray(value) && Array.isArray(choice.resultValue)) {
|
if (value.length !== choice.resultValue.length) return false;
|
return value.every?.((val, idx) => val === choice.resultValue?.[idx]);
|
}
|
return value === choice.resultValue;
|
});
|
}
|
|
choice.setSelected(isSelected);
|
});
|
},
|
}))
|
.actions((self) => {
|
const Super = {
|
validate: self.validate,
|
};
|
|
return {
|
validate() {
|
if (!Super.validate() || (self.choice !== "multiple" && self.checkResultLength() > 1)) return false;
|
},
|
|
checkResultLength() {
|
const _resultFiltered = self.children.filter((c) => c._sel);
|
|
return _resultFiltered.length;
|
},
|
|
beforeSend() {
|
if (self.choice !== "multiple" && self.checkResultLength() > 1)
|
Infomodal.warning(
|
`The number of options selected (${self.checkResultLength()}) exceed the maximum allowed (1). To proceed, first unselect excess options for:\r\n • Choices (${
|
self.name
|
})`,
|
);
|
},
|
};
|
});
|
|
const ChoicesModel = types.compose(
|
"ChoicesModel",
|
ControlBase,
|
ClassificationBase,
|
SelectedModelMixin.props({ _child: "ChoiceModel" }),
|
RequiredMixin,
|
PerRegionMixin,
|
...(isFF(FF_LSDV_4583) ? [PerItemMixin] : []),
|
ReadOnlyControlMixin,
|
SelectedChoiceMixin,
|
VisibilityMixin,
|
DynamicChildrenMixin,
|
AnnotationMixin,
|
TagAttrs,
|
Model,
|
);
|
|
const ChoicesSelectLayout = observer(({ item }) => {
|
const options = useMemo(
|
() =>
|
item.tiedChildren.map((i) => ({
|
value: i._value,
|
label: (
|
<Tooltip title={i.hint}>
|
<span data-testid="choiceOptionText" className="w-full">
|
{i._value}
|
</span>
|
</Tooltip>
|
),
|
})),
|
[item.tiedChildren],
|
);
|
return (
|
<Select
|
style={{ width: "100%" }}
|
value={item.selectedLabels.map((l) => l._value)}
|
multiple={item.choice === "multiple"}
|
disabled={item.isReadOnly()}
|
onChange={(val) => {
|
if (Array.isArray(val)) {
|
item.resetSelected();
|
val.forEach((v) => item.findLabel(v).setSelected(true));
|
item.updateResult();
|
} else {
|
const c = item.findLabel(val);
|
|
if (c) {
|
c.toggleSelected();
|
}
|
}
|
}}
|
options={options}
|
/>
|
);
|
});
|
|
const HtxChoices = observer(({ item }) => {
|
return (
|
<div
|
className={cn("choices")
|
.mod({ hidden: !item.isVisible || !item.perRegionVisible(), layout: item.layout })
|
.toClassName()}
|
ref={item.elementRef}
|
>
|
{item.layout === "select" ? <ChoicesSelectLayout item={item} /> : Tree.renderChildren(item, item.annotation)}
|
</div>
|
);
|
});
|
|
Registry.addTag("choices", ChoicesModel, HtxChoices);
|
|
export { HtxChoices, ChoicesModel, TagAttrs };
|