Bin
2025-12-16 9e0b2ba2c317b1a86212f24cbae3195ad1f3dbfa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
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 };