/
vshmidt
/
label-studio
Обзор
Документация
Войти
/
vshmidt
/
label-studio
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx
893 строки
29 KB
Raul Martin
feat: Migrate SCSS to plain CSS with PostCSS, replace Stylelint with Biome (#9584)
12 мар 2026, 20:57
Не верифицирован
12 мар 2026, 20:57
3f31128
Код
Авторство
О чём код?
import React, { useEffect, useMemo, useState, useRef } from "react"; import CM from "codemirror"; import { Button, cnm, Typography } from "@humansignal/ui"; import { IconTrash, IconInfoOutline } from "@humansignal/icons"; import { ToggleItems } from "../../../components"; import { Form, Input } from "../../../components/Form"; import { useAPI } from "../../../providers/ApiProvider"; import { cn } from "../../../utils/bem"; import { Palette } from "../../../utils/colors"; import { FF_UNSAVED_CHANGES, isFF } from "../../../utils/feature-flags"; import { colorNames } from "./colors"; import "./Config.prefix.css"; import { Preview } from "./Preview"; import { LARGE_CONFIG_MESSAGE, LARGE_CONFIG_TAG_THRESHOLD, countConfigTags } from "@humansignal/core"; import { DEFAULT_COLUMN, EMPTY_CONFIG, isEmptyConfig, Template } from "./Template"; import { TemplatesList } from "./TemplatesList"; import tags from "@humansignal/core/lib/utils/schema/tags.json"; import { UnsavedChanges } from "./UnsavedChanges"; import { Checkbox, CodeEditor, Select } from "@humansignal/ui"; import { snakeCase } from "@humansignal/core"; import { useConfigResizer } from "./useConfigResizer"; import { EditorResizer } from "./EditorResizer"; const wizardClass = cn("wizard"); const configClass = cn("configure"); /** * AdaptivePreview - Shows manual update banner for large configs, * normal auto-updating preview for smaller configs. * * When config has >= 200 tags, shows a banner with "Update Preview" button * instead of auto-updating (avoids slow re-renders on every keystroke). * * Uses editorConfig when provided to detect large pasted content immediately. * * Wrapped in React.memo to prevent unnecessary re-renders when parent re-renders. */ const AdaptivePreview = React.memo( ({ config, editorConfig, hasPendingUpdate, onUpdatePreview, isUpdating, previewKey, ...previewProps }) => { // Use editor config for tag count when provided so banner appears as soon as user pastes large content const configForTagCount = editorConfig !== undefined ? editorConfig : config; const tagCount = useMemo(() => countConfigTags(configForTagCount || ""), [configForTagCount]); const isLargeConfig = tagCount >= LARGE_CONFIG_TAG_THRESHOLD; const showManualUpdateBanner = isLargeConfig; const previewKeyProp = previewKey !== undefined ? previewKey : config; if (showManualUpdateBanner) { return ( <div className={configClass.elem("preview-container").toClassName()}> <div className={configClass.elem("preview-info-banner").toClassName()}> <IconInfoOutline width={16} height={16} /> <span>{LARGE_CONFIG_MESSAGE}</span> <Button size="small" onClick={onUpdatePreview} waiting={isUpdating} disabled={isUpdating}> {isUpdating ? "Updating..." : "Update Preview"} </Button> </div> <Preview key={previewKeyProp} config={config} {...previewProps} /> </div> ); } return <Preview key={previewKeyProp} config={config} {...previewProps} />; }, ); const EmptyConfigPlaceholder = () => ( <div className={configClass.elem("empty-config").toClassName()}> <p>Your labeling configuration is empty. It is required to label your data.</p> <p> Start from one of our predefined templates or create your own config on the Code panel. The labeling config is XML-based and you can{" "} <a href="https://labelstud.io/tags/" target="_blank" rel="noreferrer"> read about the available tags in our documentation </a> . </p> </div> ); const Label = ({ label, template, color }) => { const value = label.getAttribute("value"); return ( <li className={cnm( configClass .elem("label") .mod({ choice: label.tagName === "Choice" }) .toClassName(), "group", )} > <span className={cnm(configClass.elem("label-text").toClassName(), "flex")}> <label style={{ background: color }}> <Input type="color" className={configClass.elem("label-color").toClassName()} value={colorNames[color] || color} onChange={(e) => template.changeLabel(label, { background: e.target.value })} /> </label> <span>{value}</span> </span> <Button type="button" look="string" size="smaller" variant="negative" onClick={() => template.removeLabel(label)} aria-label="delete label" className="hidden !p-0 z-10 absolute right-0 [&_span]:!p-0 group-hover:inline-flex" leading={<IconTrash className="w-4 h-4 fill-[currentColor]" />} /> </li> ); }; const ConfigureControl = ({ control, template }) => { const refLabels = React.useRef(); const tagname = control.tagName; if (tagname !== "Choices" && !tagname.endsWith("Labels")) return null; const palette = Palette(); const onAddLabels = () => { if (!refLabels.current) return; template.addLabels(control, refLabels.current.value); refLabels.current.value = ""; }; const onKeyPress = (e) => { if (e.key === "Enter" && e.ctrlKey) { e.preventDefault(); onAddLabels(); } }; return ( <div className={configClass.elem("labels").toClassName()}> <form className={configClass.elem("add-labels").toClassName()} action=""> <h4>{tagname === "Choices" ? "Add choices" : "Add label names"}</h4> <span>Use new line as a separator to add multiple labels</span> <textarea name="labels" id="" cols="50" rows="5" ref={refLabels} onKeyPress={onKeyPress} className="lsf-textarea-ls p-2 px-3" /> <Button type="button" size="small" look="outlined" onClick={onAddLabels} aria-label="Add labels"> Add </Button> </form> <div className={configClass.elem("current-labels").toClassName()}> <h3> {tagname === "Choices" ? "Choices" : "Labels"} ({control.children.length}) </h3> <ul> {Array.from(control.children).map((label) => ( <Label label={label} template={template} key={label.getAttribute("value")} color={label.getAttribute("background") || palette.next().value} /> ))} </ul> </div> </div> ); }; const ConfigureSettings = ({ template }) => { const { settings } = template; if (!settings) return null; const keys = Object.keys(settings); const items = keys.map((key) => { const options = settings[key]; const type = Array.isArray(options.type) ? Array : options.type; const $object = options.object; const $tag = options.control ? options.control : $object; if (!$tag) return null; if (options.when && !options.when($tag)) return; let value = false; if (options.value) value = options.value($tag); else if (typeof options.param === "string") value = $tag.getAttribute(options.param); if (value === "true") value = true; if (value === "false") value = false; let onChange; let size; switch (type) { case Array: onChange = (val) => { if (typeof options.param === "function") { options.param($tag, val); } else { $object.setAttribute(options.param, val); } template.render(); }; return ( <li key={key}> <Select triggerClassName="border" value={value} onChange={onChange} options={options.type} label={options.title} isInline={true} dataTestid={`select-trigger-${options.title.replace(/\s+/g, "-").replace(":", "").toLowerCase()}-${value}`} /> </li> ); case Boolean: onChange = (e) => { if (typeof options.param === "function") { options.param($tag, e.target.checked); } else { $object.setAttribute(options.param, e.target.checked ? "true" : "false"); } template.render(); }; return ( <li key={key}> <Checkbox checked={value} onChange={onChange}> {options.title} </Checkbox> </li> ); case String: case Number: size = options.type === Number ? 5 : undefined; onChange = (e) => { if (typeof options.param === "function") { options.param($tag, e.target.value); } else { $object.setAttribute(options.param, e.target.value); } template.render(); }; return ( <li key={key}> <label> {options.title} <Input type="text" onInput={onChange} value={value} size={size} /> </label> </li> ); } }); // check for active settings if (!items.filter(Boolean).length) return null; return ( <ul className={configClass.elem("settings").toClassName()}> <li> <h4>Configure settings</h4> <ul className={configClass.elem("object-settings").toClassName()}>{items}</ul> </li> </ul> ); }; // configure value source for `obj` object tag const ConfigureColumn = ({ template, obj, columns }) => { const valueAttr = obj.hasAttribute("valueList") ? "valueList" : "value"; const value = obj.getAttribute(valueAttr)?.replace(/^\$/, ""); // if there is a value set already and it's not in the columns // or data was not uploaded yet const [isManual, setIsManual] = useState(!!value && !columns?.includes(value)); // value is stored in state to make input conrollable // changes will be sent by Enter and blur const [newValue, setNewValue] = useState(`$${value}`); // update local state when external value changes useEffect(() => { setNewValue(`$${value}`); }, [value]); const updateValue = (value) => { const newValue = value.replace(/^\$/, ""); obj.setAttribute(valueAttr, `$${newValue}`); template.render(); }; const selectValue = (value) => { if (value === "-") { setIsManual(true); return; } if (isManual) { setIsManual(false); } updateValue(value); }; const handleChange = (e) => { const newValue = e.target.value.replace(/^\$/, ""); setNewValue(`$${newValue}`); }; const handleBlur = () => { updateValue(newValue); }; const handleKeyDown = (e) => { if (e.key === "Enter") { e.preventDefault(); updateValue(e.target.value); } }; const options = useMemo(() => { const columnOptions = columns?.map((column) => ({ value: column, label: column === DEFAULT_COLUMN ? "<imported file>" : `$${column}`, })) ?? []; if (!columns?.length) { columnOptions.push({ value, label: "<imported file>" }); } columnOptions.push({ value: "-", label: "<set manually>" }); return columnOptions; }, [columns, value]); return ( <p> Use {obj.tagName.toLowerCase()} {template.objects > 1 && ` for ${obj.getAttribute("name")}`} {" from "} {columns?.length > 0 && columns[0] !== DEFAULT_COLUMN && "field "} <Select triggerClassName="border" onChange={selectValue} value={isManual ? "-" : value} options={options} isInline={true} dataTestid={`select-trigger-use-${obj.tagName.toLowerCase().replace(/\s/g, "-")}-from-field-${isManual ? "-" : value}`} /> {isManual && <Input value={newValue} onChange={handleChange} onBlur={handleBlur} onKeyDown={handleKeyDown} />} </p> ); }; const ConfigureColumns = ({ columns, template }) => { if (!template.objects.length) return null; return ( <div className={configClass.elem("object").toClassName()}> <h4>Configure data</h4> {template.objects.length > 1 && columns?.length > 0 && columns.length < template.objects.length && ( <p className={configClass.elem("object-error").toClassName()}> This template requires more data then you have for now </p> )} {columns?.length === 0 && ( <p className={configClass.elem("object-error").toClassName()}> To select which field(s) to label you need to upload the data. Alternatively, you can provide it using Code mode. </p> )} {template.objects.map((obj) => ( <ConfigureColumn key={obj.getAttribute("name")} {...{ obj, template, columns }} /> ))} </div> ); }; const Configurator = ({ columns, config, project, template, setTemplate, onBrowse, onSaveClick, onValidate, disableSaveButton, warning, hasChanges, }) => { const [configure, setConfigure] = React.useState(isEmptyConfig(config) ? "code" : "visual"); const [visualLoaded, loadVisual] = React.useState(configure === "visual"); const [waiting, setWaiting] = React.useState(false); const [saved, setSaved] = React.useState(false); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(undefined); // Resizer hook const { editorWidthPixels, setEditorWidthPixels, constraints } = useConfigResizer({ projectId: project?.id, containerWidth, }); // Track container width for resizer constraints // Observes container dimensions to provide the resizer hook with container width for calculating valid min/max bounds useEffect(() => { if (!containerRef.current) return; let rafId; const updateWidth = () => { rafId && cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { if (containerRef.current) { setContainerWidth(containerRef.current.clientWidth); } }); }; const resizeObserver = new ResizeObserver(updateWidth); resizeObserver.observe(containerRef.current); updateWidth(); return () => { rafId && cancelAnimationFrame(rafId); resizeObserver.disconnect(); }; }, []); // config update is debounced because of user input const [configToCheck, setConfigToCheck] = React.useState(); // then we wait for validation and sample data for this config const [error, setError] = React.useState(); const [parserError, setParserError] = React.useState(); const [data, setData] = React.useState(); const [loading, setLoading] = useState(false); // and only with them we'll update config in preview const [configToDisplay, setConfigToDisplay] = React.useState(config); // Track if we're in manual update mode (for large configs) // Once enabled, stays enabled until user clicks "Update Preview" const [manualUpdateMode, setManualUpdateMode] = React.useState(false); // Track if config has changed since last preview update const [hasPendingChanges, setHasPendingChanges] = React.useState(false); // Track the last config that was successfully validated and displayed const lastValidatedConfig = React.useRef(null); // Increment when configToDisplay changes so Preview remounts and LSF initializes with new config (avoids stale/empty main area) const [previewKey, setPreviewKey] = React.useState(0); const debounceTimer = React.useRef(); const api = useAPI(); React.useEffect(() => { const tagCount = countConfigTags(config); const isLargeConfig = tagCount >= LARGE_CONFIG_TAG_THRESHOLD; const hasCompletedFirstValidation = lastValidatedConfig.current !== null; // Always validate if we haven't completed the first validation yet if (!hasCompletedFirstValidation) { if (isLargeConfig) { setManualUpdateMode(true); } debounceTimer.current = window.setTimeout(() => { setConfigToCheck(config); }, 300); return () => window.clearTimeout(debounceTimer.current); } // After first validation: if we're in manual mode, either update (when config became small) or mark pending if (manualUpdateMode) { if (!isLargeConfig) { setManualUpdateMode(false); setHasPendingChanges(false); debounceTimer.current = window.setTimeout(() => { setConfigToCheck(config); }, 300); return () => window.clearTimeout(debounceTimer.current); } setHasPendingChanges(true); return; } // Enter manual mode for large configs (no auto-update; user clicks "Update Preview") if (isLargeConfig) { setManualUpdateMode(true); setHasPendingChanges(true); return; } // Normal debounced auto-update for small configs debounceTimer.current = window.setTimeout(() => { setConfigToCheck(config); }, 300); return () => window.clearTimeout(debounceTimer.current); }, [config, manualUpdateMode]); // Handler for manual preview update (used for large configs) const handleManualUpdate = React.useCallback(() => { const tagCount = countConfigTags(config); const isLargeConfig = tagCount >= LARGE_CONFIG_TAG_THRESHOLD; setManualUpdateMode(isLargeConfig); setHasPendingChanges(false); setConfigToCheck(config); }, [config]); React.useEffect(() => { const validate = async () => { if (!configToCheck) return; setLoading(true); const validation = await api.callApi("validateConfig", { params: { pk: project.id }, body: { label_config: configToCheck }, errorFilter: () => true, }); if (validation?.error) { setError(validation.response); setLoading(false); return; } setError(null); onValidate?.(validation); const sample = await api.callApi("createSampleTask", { params: { pk: project.id }, body: { label_config: configToCheck }, errorFilter: () => true, }); setLoading(false); if (sample && !sample.error) { setData(sample.sample_task); setConfigToDisplay(configToCheck); setPreviewKey((k) => k + 1); // Track that we've completed a successful validation lastValidatedConfig.current = configToCheck; } else { // @todo validation can be done in this place, // @todo but for now it's extremely slow in /sample-task endpoint setError(sample?.response); } }; validate(); }, [configToCheck]); // code should be reloaded on every render because of uncontrolled codemirror // visuals should be always rendered after first render // so load it on the first access, then just show/hide const onSelect = (value) => { setConfigure(value); if (value === "visual") loadVisual(true); }; const onChange = React.useCallback( (config) => { try { setParserError(null); setTemplate(config); } catch (e) { setParserError({ detail: "Parser error", validation_errors: [e.message], }); } }, [setTemplate], ); const onSave = async () => { setError(null); setWaiting(true); const res = await onSaveClick(); setWaiting(false); if (res === true) { setSaved(true); setTimeout(() => setSaved(false), 1500); } else { setError(res); } return res; }; function completeAfter(cm, pred) { if (!pred || pred()) { setTimeout(() => { if (!cm.state.completionActive) cm.showHint({ completeSingle: false }); }, 100); } return CM.Pass; } function completeIfInTag(cm) { return completeAfter(cm, () => { const token = cm.getTokenAt(cm.getCursor()); if (token.type === "string" && (!/['"]$/.test(token.string) || token.string.length === 1)) return false; const inner = CM.innerMode(cm.getMode(), token.state).state; return inner.tagName; }); } // Memoize error to prevent AdaptivePreview re-renders when error hasn't changed const previewError = useMemo( () => parserError || error || (configure === "code" && warning) || null, [parserError, error, configure, warning], ); // When validating a different config, show placeholder to avoid race: old preview + spinners / empty state const showUpdatingPlaceholder = loading && configToCheck != null && configToCheck !== configToDisplay; const extra = ( <p className={configClass.elem("tags-link").toClassName()}> Configure the labeling interface with tags. <a href="https://labelstud.io/tags/" target="_blank" rel="noreferrer"> See all tags </a> . </p> ); return ( <div className={configClass}> <div className={configClass.elem("container").toClassName()} ref={containerRef} style={{ gridTemplateColumns: `${editorWidthPixels}px minmax(516px, 1fr)`, }} > <div className="flex flex-col"> <h1>Labeling Interface{hasChanges ? " *" : ""}</h1> <header> <Button type="button" data-leave={true} onClick={onBrowse} size="small" look="outlined" aria-label="Browse templates" > Browse Templates </Button> <ToggleItems items={{ code: "Code", visual: "Visual" }} active={configure} onSelect={onSelect} /> </header> <div className={configClass.elem("editor").toClassName()}> {configure === "code" && ( <div className={cnm(configClass.elem("code").toClassName(), configure !== "code" ? "!hidden" : "")}> <CodeEditor name="code" id="edit_code" value={config} autoCloseTags={true} smartIndent={true} detach border extensions={["hint", "xml-hint"]} options={{ mode: "xml", theme: "default", lineNumbers: true, extraKeys: { "'<'": completeAfter, // "'/'": completeIfAfterLt, "' '": completeIfInTag, "'='": completeIfInTag, "Ctrl-Space": "autocomplete", "Ctrl-F": "findPersistent", "Cmd-F": "findPersistent", }, hintOptions: { schemaInfo: tags }, }} // don't close modal with Escape while editing config onKeyDown={(_editor, e) => { if (e.code === "Escape") e.stopPropagation(); }} onChange={(_editor, _data, value) => onChange(value)} /> </div> )} {visualLoaded && ( <div className={cnm(configClass.elem("visual").toClassName(), configure !== "visual" ? "!hidden" : "")}> {isEmptyConfig(config) && <EmptyConfigPlaceholder />} <ConfigureColumns columns={columns} project={project} template={template} /> {template.controls.map((control) => ( <ConfigureControl control={control} template={template} key={control.getAttribute("name")} /> ))} <ConfigureSettings template={template} /> </div> )} </div> {disableSaveButton !== true && onSaveClick && ( <Form.Actions size="small" extra={configure === "code" && extra} valid> {saved && ( <div className={cn("form-indicator").toClassName()}> <span className={cn("form-indicator").elem("item").mod({ type: "success" }).toClassName()}> Saved! </span> </div> )} <Button className="w-[120px]" onClick={onSave} waiting={waiting} aria-label="Save configuration"> {waiting ? "Saving..." : "Save"} </Button> {isFF(FF_UNSAVED_CHANGES) && <UnsavedChanges hasChanges={hasChanges} onSave={onSave} />} </Form.Actions> )} </div> <div className="relative"> <EditorResizer containerRef={containerRef} editorWidthPixels={editorWidthPixels} onResize={setEditorWidthPixels} constraints={constraints} disabled={constraints.minEditorWidth >= constraints.maxEditorWidth} /> {showUpdatingPlaceholder ? ( <div className={configClass.elem("preview-container").toClassName()} data-testid="preview-updating-placeholder" > <div className={configClass.elem("preview-updating").toClassName()}> <Typography color="secondary">Updating preview…</Typography> </div> </div> ) : ( <AdaptivePreview config={configToDisplay} editorConfig={config} data={data} project={project} loading={loading} error={previewError} hasPendingUpdate={manualUpdateMode} onUpdatePreview={handleManualUpdate} isUpdating={loading} previewKey={previewKey} /> )} </div> </div> </div> ); }; export const ConfigPage = ({ config: initialConfig = "", columns: externalColumns, project, onUpdate, onSaveClick, onValidate, disableSaveButton, show = true, hasChanges, }) => { const [config, _setConfig] = React.useState(""); const [mode, setMode] = React.useState("list"); // view | list const [selectedGroup, _setSelectedGroup] = React.useState(null); const [selectedRecipe, setSelectedRecipe] = React.useState(null); const [template, setCurrentTemplate] = React.useState(null); const api = useAPI(); const setSelectedGroup = React.useCallback( (group) => { _setSelectedGroup(group); __lsa(`labeling_setup.list.${snakeCase(group)}`); }, [_setSelectedGroup], ); const setConfig = React.useCallback( (newConfig) => { _setConfig(newConfig); onUpdate(newConfig); }, [_setConfig, onUpdate], ); // setTemplate - handles both config state and Template object creation const setTemplate = React.useCallback( (newConfig) => { setConfig(newConfig); try { const tpl = new Template({ config: newConfig }); tpl.onConfigUpdate = setConfig; setCurrentTemplate(tpl); } catch (e) { console.error("Template parsing error:", e); } }, [setConfig, setCurrentTemplate], ); const [columns, setColumns] = React.useState(); React.useEffect(() => { if (externalColumns?.length) setColumns(externalColumns); }, [externalColumns]); const [warning, _setWarning] = React.useState(); React.useEffect(() => { const fetchData = async () => { if (!externalColumns && project?.id && !columns) { const res = await api.callApi("dataSummary", { params: { pk: project.id }, // 404 is ok, and errors here don't matter errorFilter: () => true, }); if (res?.common_data_columns) { setColumns(res.common_data_columns); } } }; fetchData(); }, [project?.id, externalColumns]); const onSelectRecipe = React.useCallback((recipe) => { if (!recipe) { setSelectedRecipe(null); setMode("list"); __lsa("labeling_setup.view.empty"); } else { setTemplate(recipe.config); setSelectedRecipe(recipe); setMode("view"); __lsa(`labeling_setup.view.${snakeCase(recipe.group)}.${snakeCase(recipe.title)}`); } }); const onCustomTemplate = React.useCallback(() => { setTemplate(EMPTY_CONFIG); setMode("view"); __lsa("labeling_setup.view.custom"); }); const onBrowse = React.useCallback(() => { setMode("list"); __lsa("labeling_setup.list.browse"); }, []); React.useEffect(() => { if (initialConfig) { setTemplate(initialConfig); setMode("view"); } }, []); if (!show) return null; return ( <div className={wizardClass} data-mode="list" id="config-wizard"> {mode === "list" && ( <TemplatesList case="list" selectedGroup={selectedGroup} selectedRecipe={selectedRecipe} onSelectGroup={setSelectedGroup} onSelectRecipe={onSelectRecipe} onCustomTemplate={onCustomTemplate} /> )} {mode === "view" && ( <Configurator case="view" columns={columns} config={config} project={project} selectedRecipe={selectedRecipe} template={template} setTemplate={setTemplate} onBrowse={onBrowse} onValidate={onValidate} disableSaveButton={disableSaveButton} onSaveClick={onSaveClick} warning={warning} hasChanges={hasChanges} /> )} </div> ); };