Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
137cc0d
feat: FIT-1123: Add resizer between Preview and left-side Panels in T…
ricardoantoniocm Dec 15, 2025
06bb1d3
Implement resizer. WIP.
ricardoantoniocm Dec 16, 2025
d919ee9
Linter fixes.
ricardoantoniocm Dec 17, 2025
11e5185
Fixes.
ricardoantoniocm Dec 17, 2025
3d6a6ce
fix: Fix linting errors for Config resizer
ricardoantoniocm Dec 17, 2025
e5b3671
Merge branch 'develop' into 'fb-fit-1123'
ricardoantoniocm Jan 5, 2026
34a9249
Improvements.
ricardoantoniocm Jan 5, 2026
524c41a
Adjusts minimum width for Preview.
ricardoantoniocm Jan 5, 2026
55eb3da
Adds date-ranger-picker to humansignal/ui library.
ricardoantoniocm Jan 5, 2026
3a92b6a
Remove UI from "UI Preview" and adjust copy under "Code" tab.
ricardoantoniocm Jan 6, 2026
9208c8b
Adjust max-height of columns in Create Project wizard.
ricardoantoniocm Jan 6, 2026
270b762
Merge branch 'develop' into 'fb-fit-1123'
ricardoantoniocm Jan 6, 2026
ef8f442
fixing resizeObservable issue + infinite summary requests issue
yyassi-heartex Jan 7, 2026
84696c3
eliminating resize observable error reporting
yyassi-heartex Jan 7, 2026
cb65c7d
Adjusts "Use <object> from" field to match LSE implementation for con…
ricardoantoniocm Jan 7, 2026
f602358
Resets column width when the screen is smaller than the width of the …
ricardoantoniocm Jan 7, 2026
c73ec72
Refactoring.
ricardoantoniocm Jan 7, 2026
148fa4e
Rename ConfigResizer to EditorResizer. Change jsx to tsx.
ricardoantoniocm Jan 9, 2026
d204aa2
Removing bem.
ricardoantoniocm Jan 9, 2026
9f3468c
Linter fixes.
ricardoantoniocm Jan 9, 2026
cbda392
Merge branch 'develop' into 'fb-fit-1123'
ricardoantoniocm Jan 9, 2026
cbbe2d9
Fixed ResizeObserver callback with requestAnimationFrame. Removed err…
ricardoantoniocm Jan 9, 2026
c2092de
Revert adding date-range-picker to humansignal/ui library.
ricardoantoniocm Jan 9, 2026
ea9722c
Merge branch 'develop' into 'fb-fit-1123'
ricardoantoniocm Jan 12, 2026
4173b3a
feat: FIT-1123: Add resizer between Preview and left-side Panels in T…
ricardoantoniocm Dec 15, 2025
1262b99
Implement resizer. WIP.
ricardoantoniocm Dec 16, 2025
742d69d
Linter fixes.
ricardoantoniocm Dec 17, 2025
a2e1b2a
Fixes.
ricardoantoniocm Dec 17, 2025
95478a9
fix: Fix linting errors for Config resizer
ricardoantoniocm Dec 17, 2025
aafd16f
Improvements.
ricardoantoniocm Jan 5, 2026
61cb317
Adjusts minimum width for Preview.
ricardoantoniocm Jan 5, 2026
5ce346f
Adds date-ranger-picker to humansignal/ui library.
ricardoantoniocm Jan 5, 2026
537c390
Remove UI from "UI Preview" and adjust copy under "Code" tab.
ricardoantoniocm Jan 6, 2026
3dc7064
Adjust max-height of columns in Create Project wizard.
ricardoantoniocm Jan 6, 2026
9509320
fixing resizeObservable issue + infinite summary requests issue
yyassi-heartex Jan 7, 2026
34b3009
eliminating resize observable error reporting
yyassi-heartex Jan 7, 2026
d067d3b
Adjusts "Use <object> from" field to match LSE implementation for con…
ricardoantoniocm Jan 7, 2026
b86b0fe
Resets column width when the screen is smaller than the width of the …
ricardoantoniocm Jan 7, 2026
0bd5a7b
Refactoring.
ricardoantoniocm Jan 7, 2026
19742fc
Rename ConfigResizer to EditorResizer. Change jsx to tsx.
ricardoantoniocm Jan 9, 2026
3ec5a80
Removing bem.
ricardoantoniocm Jan 9, 2026
77a9992
Linter fixes.
ricardoantoniocm Jan 9, 2026
4dede38
Fixed ResizeObserver callback with requestAnimationFrame. Removed err…
ricardoantoniocm Jan 9, 2026
bfac194
Revert adding date-range-picker to humansignal/ui library.
ricardoantoniocm Jan 9, 2026
2b72b05
Merge branch 'fb-fit-1123-2' of https://github.com/heartexlabs/label-…
ricardoantoniocm Jan 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 172 additions & 117 deletions web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useState, useRef } from "react";
import CM from "codemirror";
import { Button, cnm } from "@humansignal/ui";
import { IconTrash } from "@humansignal/icons";
Expand All @@ -18,6 +18,8 @@ import tags from "@humansignal/core/lib/utils/schema/tags.json";
import { UnsavedChanges } from "./UnsavedChanges";
import { Checkbox, CodeEditor, Select } from "@humansignal/ui";
import snakeCase from "lodash/snakeCase";
import { useConfigResizer } from "./useConfigResizer";
import { EditorResizer } from "./EditorResizer";

const wizardClass = cn("wizard");
const configClass = cn("configure");
Expand Down Expand Up @@ -238,7 +240,9 @@ const ConfigureColumn = ({ template, obj, columns }) => {
const [newValue, setNewValue] = useState(`$${value}`);

// update local state when external value changes
useEffect(() => setNewValue(`$${value}`), [value]);
useEffect(() => {
setNewValue(`$${value}`);
}, [value]);

const updateValue = (value) => {
const newValue = value.replace(/^\$/, "");
Expand Down Expand Up @@ -276,40 +280,35 @@ const ConfigureColumn = ({ template, obj, columns }) => {
}
};

const columnsList = useMemo(() => {
const cols = (columns ?? []).map((col) => {
return {
value: col,
label: col === DEFAULT_COLUMN ? "<imported file>" : `$${col}`,
};
});
const options = useMemo(() => {
const columnOptions =
columns?.map((column) => ({
value: column,
label: column === DEFAULT_COLUMN ? "<imported file>" : `$${column}`,
})) ?? [];
if (!columns?.length) {
cols.push({ value, label: "<imported file>" });
columnOptions.push({ value, label: "<imported file>" });
}
cols.push({ value: "-", label: "<set manually>" });
return cols;
}, [columns, DEFAULT_COLUMN, value]);
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={columnsList}
options={options}
isInline={true}
label={
<>
Use {obj.tagName.toLowerCase()}
{template.objects > 1 && ` for ${obj.getAttribute("name")}`}
{" from "}
{columns?.length > 0 && columns[0] !== DEFAULT_COLUMN && "field "}
</>
}
labelProps={{ className: "inline-flex" }}
dataTestid={`select-trigger-use-image-from-field-${isManual ? "-" : value}`}
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>
);
};

Expand Down Expand Up @@ -352,6 +351,40 @@ const Configurator = ({
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();
Expand Down Expand Up @@ -474,106 +507,128 @@ const Configurator = ({

const extra = (
<p className={configClass.elem("tags-link")}>
Configure the labeling interface with tags.
<br />
Configure the labeling interface with tags.&nbsp;
<a href="https://labelstud.io/tags/" target="_blank" rel="noreferrer">
See all available tags
See all tags
</a>
.
</p>
);

return (
<div className={configClass}>
<div className={configClass.elem("container")}>
<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")}>
{configure === "code" && (
<div className={configClass.elem("code")} style={{ display: configure === "code" ? undefined : "none" }}>
<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",
},
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={configClass.elem("visual")}
style={{ display: configure === "visual" ? undefined : "none" }}
>
{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>
)}
<div
className={configClass.elem("container")}
ref={containerRef}
style={{
display: "grid",
gridTemplateColumns: `${editorWidthPixels}px minmax(516px, 1fr)`,
position: "relative",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
}}
>
<h1>Labeling Interface{hasChanges ? " *" : ""}</h1>
<header>
<Button
type="button"
data-leave={true}
onClick={onBrowse}
size="small"
className="w-[120px]"
onClick={onSave}
waiting={waiting}
aria-label="Save configuration"
look="outlined"
aria-label="Browse templates"
>
{waiting ? "Saving..." : "Save"}
Browse Templates
</Button>
{isFF(FF_UNSAVED_CHANGES) && <UnsavedChanges hasChanges={hasChanges} onSave={onSave} />}
</Form.Actions>
)}
<ToggleItems items={{ code: "Code", visual: "Visual" }} active={configure} onSelect={onSelect} />
</header>
<div className={configClass.elem("editor")}>
{configure === "code" && (
<div className={configClass.elem("code")} style={{ display: configure === "code" ? undefined : "none" }}>
<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",
},
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={configClass.elem("visual")}
style={{ display: configure === "visual" ? undefined : "none" }}
>
{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
style={{
position: "relative",
}}
>
<EditorResizer
containerRef={containerRef}
editorWidthPixels={editorWidthPixels}
onResize={setEditorWidthPixels}
constraints={constraints}
/>
<Preview
config={configToDisplay}
data={data}
project={project}
loading={loading}
error={parserError || error || (configure === "code" && warning)}
/>
</div>
</div>
<Preview
config={configToDisplay}
data={data}
project={project}
loading={loading}
error={parserError || error || (configure === "code" && warning)}
/>
</div>
);
};
Expand Down Expand Up @@ -629,11 +684,11 @@ export const ConfigPage = ({
if (externalColumns?.length) setColumns(externalColumns);
}, [externalColumns]);

const [warning, setWarning] = React.useState();
const [warning, _setWarning] = React.useState();

React.useEffect(() => {
const fetchData = async () => {
if (!externalColumns || (project && !columns)) {
if (!externalColumns && project?.id && !columns) {
const res = await api.callApi("dataSummary", {
params: { pk: project.id },
// 404 is ok, and errors here don't matter
Expand All @@ -644,9 +699,9 @@ export const ConfigPage = ({
setColumns(res.common_data_columns);
}
}
fetchData();
};
}, [columns, project]);
fetchData();
}, [project?.id, externalColumns]);

const onSelectRecipe = React.useCallback((recipe) => {
if (!recipe) {
Expand Down
Loading
Loading