Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
229 changes: 142 additions & 87 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 { ConfigResizer } from "./ConfigResizer";

const wizardClass = cn("wizard");
const configClass = cn("configure");
Expand Down Expand Up @@ -352,6 +354,34 @@ 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
useEffect(() => {
if (!containerRef.current) return;

const updateWidth = () => {
if (containerRef.current) {
setContainerWidth(containerRef.current.clientWidth);
}
};

const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(containerRef.current);

updateWidth();

return () => {
resizeObserver.disconnect();
};
}, []);

// config update is debounced because of user input
const [configToCheck, setConfigToCheck] = React.useState();
Expand Down Expand Up @@ -485,95 +515,120 @@ const Configurator = ({

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(400px, 1fr)`,
gap: "16px",
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",
}}
>
<ConfigResizer
containerRef={containerRef}
editorWidthPixels={editorWidthPixels}
onResize={setEditorWidthPixels}
onResizeFinished={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,7 +684,7 @@ export const ConfigPage = ({
if (externalColumns?.length) setColumns(externalColumns);
}, [externalColumns]);

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

React.useEffect(() => {
const fetchData = async () => {
Expand All @@ -644,8 +699,8 @@ export const ConfigPage = ({
setColumns(res.common_data_columns);
}
}
fetchData();
};
fetchData();
}, [columns, project]);

const onSelectRecipe = React.useCallback((recipe) => {
Expand Down
26 changes: 18 additions & 8 deletions web/apps/labelstudio/src/pages/CreateProject/Config/Config.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,26 @@ $scroll-width: 5px;
min-height: 0;
display: flex;
align-items: stretch;
position: relative;

>* {
flex: 50%;
min-width: 0;
}
}

.wizard .configure__container {
display: flex;
flex-direction: column;
padding: 16px 16px - $scroll-width 16px 20px;
flex-direction: row;
padding: 0;
overflow-y: scroll;
background-color: var(--color-neutral-background);
gap: var(--spacing-base);

& > div {
padding: 16px 16px - $scroll-width 16px 20px;
}

&::-webkit-scrollbar {
width: $scroll-width;
}
Expand Down Expand Up @@ -250,10 +256,11 @@ $scroll-width: 5px;
line-height: 1;
}

.configure__container>header {
.configure__container > div > header {
display: flex;
justify-content: flex-end;
align-items: center;
padding: var(--spacing-tight) 0;
}

.configure__editor {
Expand All @@ -272,7 +279,7 @@ $scroll-width: 5px;
}
}

.configure__container>header .toggle-items {
.configure__container > div > header .toggle-items {
margin-left: auto;
}

Expand Down Expand Up @@ -538,18 +545,17 @@ $scroll-width: 5px;
}

.wizard .configure__preview {
background-color: var(--color-neutral-surface);
flex-grow: 10;
min-width: 500px;
padding: 16px 16px 0;
padding: 0;
overflow-y: auto;
border-left: 1px solid var(--color-neutral-border);
display: flex;
flex-direction: column;
color: var(--color-neutral-content);
height: 100%;

h3 {
margin: 8px 0 16px;
padding: var(--spacing-tight) 0;
font-size: 16px;
color: var(--color-neutral-content);
}
Expand All @@ -563,6 +569,10 @@ $scroll-width: 5px;
&-ui {
flex: 1;
min-height: 0;
background-color: var(--color-neutral-surface);
border: 1px solid var(--color-neutral-border);
border-radius: var(--corner-radius-small);
padding: var(--spacing-base);
}

&-error {
Expand Down
Loading
Loading