diff --git a/src/components/NewTaxonomy/NewTaxonomy.styl b/src/components/NewTaxonomy/NewTaxonomy.styl new file mode 100644 index 0000000000..f034aa4b34 --- /dev/null +++ b/src/components/NewTaxonomy/NewTaxonomy.styl @@ -0,0 +1,3 @@ +:global(.htx-taxonomy-item-color) + padding 4px 4px + border-radius 2px diff --git a/src/components/NewTaxonomy/NewTaxonomy.tsx b/src/components/NewTaxonomy/NewTaxonomy.tsx index 7f97a99e7e..3b91ba4dab 100644 --- a/src/components/NewTaxonomy/NewTaxonomy.tsx +++ b/src/components/NewTaxonomy/NewTaxonomy.tsx @@ -3,6 +3,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Tooltip } from '../../common/Tooltip/Tooltip'; +import './NewTaxonomy.styl'; + type TaxonomyPath = string[]; type onAddLabelCallback = (path: string[]) => any; type onDeleteLabelCallback = (path: string[]) => any; @@ -15,6 +17,7 @@ type TaxonomyItem = { children?: TaxonomyItem[], origin?: 'config' | 'user' | 'session', hint?: string, + color?: string, }; type AntTaxonomyItem = { @@ -57,17 +60,32 @@ const convert = ( options: TaxonomyExtendedOptions, selectedPaths: string[], ): AntTaxonomyItem[] => { + // generate string or component to be the `title` of the item + const enrich = (item: TaxonomyItem) => { + const color = (item: TaxonomyItem) => ( + // no BEM here to make it more lightweight + // global classname to allow to change it in Style tag + + {item.label} + + ); + + if (!item.hint) return item.color ? color(item) : item.label; + + return ( + + {item.color ? color(item) : {item.label}} + + ); + }; + const convertItem = (item: TaxonomyItem): AntTaxonomyItem => { const value = item.path.join(options.pathSeparator); const disabledNode = options.leafsOnly && (item.isLeaf === false || !!item.children); const maxUsagesReached = options.maxUsagesReached && !selectedPaths.includes(value); return { - title: item.hint ? ( - - {item.label} - - ) : item.label, + title: enrich(item), value, key: value, isLeaf: item.isLeaf !== false && !item.children, diff --git a/src/components/Node/Node.tsx b/src/components/Node/Node.tsx index d48cef6709..0785c979af 100644 --- a/src/components/Node/Node.tsx +++ b/src/components/Node/Node.tsx @@ -188,6 +188,9 @@ const NodeMinimal: FC = observer(({ node }) => { }); const useNodeName = (node: any) => { + // @todo sometimes node is control tag, not a region + // @todo and for new taxonomy it can be plain object + if (!node.$treenode) return null; return getType(node).name as keyof typeof NodeViews; }; diff --git a/src/components/Taxonomy/Taxonomy.tsx b/src/components/Taxonomy/Taxonomy.tsx index 4607417d4b..90f7f26503 100644 --- a/src/components/Taxonomy/Taxonomy.tsx +++ b/src/components/Taxonomy/Taxonomy.tsx @@ -9,15 +9,15 @@ import React, { } from 'react'; import { Dropdown, Menu } from 'antd'; +import { LsChevron } from '../../assets/icons'; +import { Tooltip } from '../../common/Tooltip/Tooltip'; import { useToggle } from '../../hooks/useToggle'; +import { CNTagName } from '../../utils/bem'; +import { FF_DEV_4075, FF_PROD_309, isFF } from '../../utils/feature-flags'; import { isArraysEqual } from '../../utils/utilities'; -import { LsChevron } from '../../assets/icons'; import TreeStructure from '../TreeStructure/TreeStructure'; import styles from './Taxonomy.module.scss'; -import { FF_DEV_4075, FF_PROD_309, isFF } from '../../utils/feature-flags'; -import { Tooltip } from '../../common/Tooltip/Tooltip'; -import { CNTagName } from '../../utils/bem'; type TaxonomyPath = string[]; type onAddLabelCallback = (path: string[]) => any; @@ -33,6 +33,7 @@ type TaxonomyItem = { }; type TaxonomyOptions = { + canRemoveItems?: boolean, leafsOnly?: boolean, showFullPath?: boolean, pathSeparator?: string, @@ -504,6 +505,10 @@ const Taxonomy = ({ const setSelected = (path: TaxonomyPath, value: boolean) => { const newSelected = value ? [...selected, path] : selected.filter(current => !isArraysEqual(current, path)); + // don't remove last item when taxonomy is used as labeling tool + // canRemoveItems is undefined when FF is off; false only when region is active + if (options.canRemoveItems === false && !newSelected.length) return; + setInternalSelected(newSelected); onChange && onChange(null, newSelected); }; diff --git a/src/mixins/HighlightMixin.js b/src/mixins/HighlightMixin.js index 7d604be1bf..2223861209 100644 --- a/src/mixins/HighlightMixin.js +++ b/src/mixins/HighlightMixin.js @@ -153,14 +153,8 @@ export const HighlightMixin = types updateSpans() { if (self._hasSpans || (isFF(FF_LSDV_4620_3) && self._spans?.length)) { const lastSpan = self._spans[self._spans.length - 1]; - const label = self.getLabels(); - // label is array, string or null, so check for length - if (!label?.length) { - lastSpan.removeAttribute('data-label'); - } else { - lastSpan.setAttribute('data-label', label); - } + Utils.Selection.applySpanStyles(lastSpan, { label: self.getLabels() }); } }, @@ -274,7 +268,7 @@ export const HighlightMixin = types }, getLabels() { - return self.labeling?.mainValue ?? []; + return (self.labeling?.selectedLabels ?? []).map(label => label.value).join(','); }, getLabelColor() { diff --git a/src/mixins/SelectedChoiceMixin.js b/src/mixins/SelectedChoiceMixin.js index 99f5fb84b7..5ef40a7b95 100644 --- a/src/mixins/SelectedChoiceMixin.js +++ b/src/mixins/SelectedChoiceMixin.js @@ -21,6 +21,19 @@ const SelectedChoiceMixin = types return isDefined(choice1) && isDefined(choice2) && choice1 === choice2; }, + // @todo it's better to only take final values into account + // @todo (meaning alias only, not alias + value when alias is present) + // @todo so this should be the final and simpliest method + hasChoiceSelectionSimple(choiceValue) { + if (choiceValue?.length) { + // grab the string value; for taxonomy, it's the last value in the array + const selectedValues = self.selectedValues().map(s => Array.isArray(s) ? s.at(-1) : s); + + return choiceValue.some(value => selectedValues.includes(value)); + } + + return self.isSelected; + }, hasChoiceSelection(choiceValue, selectedValues = []) { if (choiceValue?.length) { // @todo Revisit this and make it more consistent, and refactor this diff --git a/src/regions/Result.js b/src/regions/Result.js index 4be89f4a53..d1756fa6fd 100644 --- a/src/regions/Result.js +++ b/src/regions/Result.js @@ -140,19 +140,14 @@ const Result = types return self.mainValue?.join(joinstr) || ''; }, + // @todo check all usages of selectedLabels: + // — check usages of non-array values (like `if selectedValues ...`) + // - check empty labels, they should be returned as an array get selectedLabels() { - if (self.type === 'taxonomy') { - const sep = self.from_name.pathseparator; - const join = self.from_name.showfullpath; - - return (self.mainValue || []) - .map(v => join ? v.join(sep) : v.at(-1)) - .map(v => ({ value: v, id: v })); - } if (self.mainValue?.length === 0 && self.from_name.allowempty) { return self.from_name.findLabel(null); } - return self.mainValue?.map(value => self.from_name.findLabel(value)).filter(Boolean); + return self.mainValue?.map(value => self.from_name.findLabel(value)).filter(Boolean) ?? []; }, /** @@ -212,7 +207,7 @@ const Result = types get style() { if (!self.tag) return null; - const fillcolor = self.tag.background || self.tag.parent.fillcolor; + const fillcolor = self.tag.background || self.tag.parent?.fillcolor; if (!fillcolor) return null; const strokecolor = self.tag.background || self.tag.parent.strokecolor; diff --git a/src/tags/control/Choice.js b/src/tags/control/Choice.js index 570ba80849..bfe6143aaf 100644 --- a/src/tags/control/Choice.js +++ b/src/tags/control/Choice.js @@ -44,8 +44,9 @@ import { HintTooltip } from '../../components/Taxonomy/Taxonomy'; * @param {string} [alias] - Alias for the choice. If used, the alias replaces the choice value in the annotation results. Alias does not display in the interface. * @param {style} [style] - CSS style of the checkbox element * @param {string} [hotkey] - Hotkey for the selection - * @param {string} [html] - can be used to show enriched content[^FF_DEV_2007], it has higher priority than `value`, however `value` will be used in the exported result (should be properly escaped) + * @param {string} [html] - Can be used to show enriched content[^FF_DEV_2007], it has higher priority than `value`, however `value` will be used in the exported result (should be properly escaped) * @param {string} [hint] - Hint for choice on hover[^FF_PROD_309] + * @param {string} [color] - Color for Taxonomy item */ const TagAttrs = types.model({ ...(isFF(FF_DEV_3391) ? { id: types.identifier } : {}), @@ -54,6 +55,7 @@ const TagAttrs = types.model({ value: types.maybeNull(types.string), hotkey: types.maybeNull(types.string), style: types.maybeNull(types.string), + color: types.maybeNull(types.string), ...(isFF(FF_DEV_2007) ? { html: types.maybeNull(types.string) } : {}), ...(isFF(FF_PROD_309) ? { hint: types.maybeNull(types.string) } : {}), }); diff --git a/src/tags/control/Taxonomy/Taxonomy.js b/src/tags/control/Taxonomy/Taxonomy.js index 3a70556091..8e6542183a 100644 --- a/src/tags/control/Taxonomy/Taxonomy.js +++ b/src/tags/control/Taxonomy/Taxonomy.js @@ -112,6 +112,7 @@ function traverse(root) { const depth = parents.length; const obj = { label, path, depth, hint }; + if (node.color) obj.color = node.color; if (node.children) { obj.children = visitUnique(node.children, path); } @@ -150,6 +151,10 @@ const TaxonomyLabelingResult = types return self.annotation.results.find(r => r.from_name === self && r.area === area); }, + get canRemoveItems() { + if (!self.isLabeling) return true; + return !self.result; + }, })) .actions(self => { const Super = { @@ -163,6 +168,35 @@ const TaxonomyLabelingResult = types self.result.area.setValue(self); } }, + + /** + * @param {string[]} path saved value from Taxonomy + * @returns quazi-label object to act as Label in most places + */ + findLabel(path) { + let title = ''; + let items = self.items; + let item; + + for (const value of path) { + item = items?.find(item => item.path.at(-1) === value); + + if (!item) return null; + + items = item.children; + title = self.showfullpath && title ? title + self.pathseparator + item.label : item.label; + } + + const label = { value: title, id: path.join(self.pathseparator) }; + + if (item.color) { + // to conform the current format of our Result#style (and it requires parent) + label.background = item.color; + label.parent = {}; + } + + return label; + }, }; }); @@ -411,6 +445,10 @@ const Model = types }, onChange(_node, checked) { + // don't remove last label from region if region is selected (so canRemoveItems is false) + // should be checked only for Taxonomy as labbeling tool + if (self.canRemoveItems === false && !checked.length) return; + self.selected = checked.map(s => s.path ?? s); self.maxUsagesReached = self.selected.length >= self.maxusages; self.updateResult(); @@ -507,6 +545,7 @@ const HtxTaxonomy = observer(({ item }) => { minWidth: item.minwidth, dropdownWidth: item.dropdownwidth, placeholder: item.placeholder, + canRemoveItems: item.canRemoveItems, }; return (