Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: DIA-2044: Add label config to "import sample data" #7227

Open
wants to merge 33 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
bbce2de
Adding sample select block
nick-skriabin Mar 7, 2025
d53cf37
Merge branch 'develop' into fb-dia-1924/sample-imports
nick-skriabin Mar 10, 2025
307a3ad
Merge remote-tracking branch 'origin/develop' into fb-dia-1924/sample…
matt-bernstein Mar 10, 2025
e28abfe
Merge remote-tracking branch 'origin/develop' into fb-dia-1924/sample…
matt-bernstein Mar 11, 2025
e524a31
Uploading data samples
nick-skriabin Mar 11, 2025
b539c7d
Format
nick-skriabin Mar 12, 2025
866cc83
Add sample import to import dialog within the project
nick-skriabin Mar 12, 2025
974b50f
feat: OPTIC-1746: Improve global error message handling by showing to…
bmartel Mar 11, 2025
cc617d3
fix: DIA-2028: [BE] Membership API call for organization with ID=2574…
nick-skriabin Mar 11, 2025
d2afd79
fix: DIA-2028: [BE] Membership API call for organization with ID=2574…
nick-skriabin Mar 11, 2025
8b826a7
feat: OPTIC-1265: Improve error pages (#7196)
mcanu Mar 11, 2025
13afe5b
fix: DIA-2026: allow annotators/reviewers to view jwt settings (#7208)
pakelley Mar 11, 2025
20008e0
fix: OPTIC-1749: Limit only sentry_force error logs to go to Sentry (…
mcanu Mar 11, 2025
4425120
Merge remote-tracking branch 'origin/develop' into fb-dia-1924/sample…
matt-bernstein Mar 12, 2025
96926ba
remove redundant sample
matt-bernstein Mar 12, 2025
45d07f4
turn ff off by default for safety
matt-bernstein Mar 13, 2025
b5d8f7a
Select styles
nick-skriabin Mar 14, 2025
e421f23
upload samples
matt-bernstein Mar 14, 2025
ff267ec
feat: DIA-2044: Add label config to "import sample data"
matt-bernstein Mar 14, 2025
4014976
add warning message to samples dropdown
matt-bernstein Mar 14, 2025
0ed24ba
add label config to samples
matt-bernstein Mar 14, 2025
abccd04
update label config on saving sample
matt-bernstein Mar 14, 2025
813be08
fix trash icon
matt-bernstein Mar 14, 2025
c5c5d6e
Merge remote-tracking branch 'origin/develop' into fb-dia-2044
matt-bernstein Mar 19, 2025
f2f730a
move warning to the right of the dropdown
matt-bernstein Mar 19, 2025
d9032e0
biome
matt-bernstein Mar 19, 2025
63fd114
Merge remote-tracking branch 'origin/develop' into fb-dia-2044
matt-bernstein Mar 24, 2025
8911eef
Organize imports
nick-skriabin Mar 25, 2025
8e7c6f5
Remove excess
nick-skriabin Mar 25, 2025
1e8f41a
Formatting
nick-skriabin Mar 25, 2025
3f1cc5c
Remove excess function
nick-skriabin Mar 25, 2025
f8c6802
Use correct FF check
nick-skriabin Mar 25, 2025
e2c0b2d
Organize imports
nick-skriabin Mar 25, 2025
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
47 changes: 40 additions & 7 deletions web/apps/labelstudio/src/pages/CreateProject/CreateProject.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EnterpriseBadge } from "@humansignal/ui";
import React from "react";
import React, { useCallback } from "react";
import { useHistory } from "react-router";
import { Button, ToggleItems } from "../../components";
import { Modal } from "../../components/Modal/Modal";
Expand All @@ -8,8 +8,9 @@ import { HeidiTips } from "../../components/HeidiTips/HeidiTips";
import { useAPI } from "../../providers/ApiProvider";
import { cn } from "../../utils/bem";
import { ConfigPage } from "./Config/Config";
import { EMPTY_CONFIG } from "./Config/Template";
import "./CreateProject.scss";
import { ImportPage } from "./Import/Import";
import { importFiles, ImportPage } from "./Import/Import";
import { useImportPage } from "./Import/useImportPage";
import { useDraftProject } from "./utils/useDraftProject";
import { Input, Select, TextArea } from "../../components/Form";
Expand Down Expand Up @@ -89,7 +90,9 @@ export const CreateProject = ({ onClose, redirect = true }) => {
const [name, setName] = React.useState("");
const [error, setError] = React.useState();
const [description, setDescription] = React.useState("");
const [config, setConfig] = React.useState("<View></View>");
const [config, setConfig] = React.useState(EMPTY_CONFIG);
const [sample, setSample] = React.useState(null);

const setStep = React.useCallback((step) => {
_setStep(step);
const eventNameMap = {
Expand All @@ -104,7 +107,7 @@ export const CreateProject = ({ onClose, redirect = true }) => {
setError(null);
}, [name]);

const { columns, uploading, uploadDisabled, finishUpload, pageProps } = useImportPage(project);
const { columns, uploading, uploadDisabled, finishUpload, pageProps } = useImportPage(project, sample);

const rootClass = cn("create-project");
const tabClass = rootClass.elem("tab");
Expand All @@ -122,9 +125,21 @@ export const CreateProject = ({ onClose, redirect = true }) => {
() => ({
title: name,
description,
label_config: config,
label_config: (sample && sample.label_config) ? sample.label_config : config,
}),
[name, description, config],
[name, description, config, sample],
);
const uploadSample = useCallback(
async (sample) => {
const url = sample.url;
const body = new URLSearchParams({ url });
await importFiles({
files: [{ name: url }],
body,
project,
});
},
[project],
);

const onCreate = React.useCallback(async () => {
Expand All @@ -133,6 +148,10 @@ export const CreateProject = ({ onClose, redirect = true }) => {
if (!imported) return;

setWaitingStatus(true);

if (sample) {
await uploadSample(sample);
}
const response = await api.callApi("updateProject", {
params: {
pk: project.id,
Expand Down Expand Up @@ -209,7 +228,21 @@ export const CreateProject = ({ onClose, redirect = true }) => {
setDescription={setDescription}
show={step === "name"}
/>
<ImportPage project={project} show={step === "import"} {...pageProps} />
<ImportPage
project={project}
show={step === "import"}
sample={sample}
onSampleDatasetSelect={(selectedSample) => {
setSample(selectedSample);
if (selectedSample?.label_config) {
setConfig(selectedSample.label_config);
}
}}
hasLabelConfig={
config && config !== EMPTY_CONFIG
}
{...pageProps}
/>
<ConfigPage
project={project}
onUpdate={setConfig}
Expand Down
117 changes: 91 additions & 26 deletions web/apps/labelstudio/src/pages/CreateProject/Import/Import.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { Modal } from "../../../components/Modal/Modal";
import { SampleDatasetSelect } from "@humansignal/core/blocks/SampleDatasetSelect/SampleDatasetSelect";
import samples from "./samples.json";
import { cn } from "../../../utils/bem";
import { cn as scn } from "@humansignal/shad/utils";
import { Badge } from "@humansignal/shad/components/ui/badge";
import { unique } from "../../../utils/helpers";
import "./Import.scss";
import { EMPTY_CONFIG } from "../Config/Template";
import { IconError, IconInfo, IconUpload } from "../../../assets/icons";
import { useAPI } from "../../../providers/ApiProvider";
import { API, useAPI } from "../../../providers/ApiProvider";
import Input from "libs/datamanager/src/components/Common/Input/Input";
import { Button } from "apps/labelstudio/src/components";
import { ff } from "@humansignal/core";
import { IconTrash } from "libs/editor/src/assets/icons";

const importClass = cn("upload_page");
const dropzoneClass = cn("dropzone");
Expand Down Expand Up @@ -57,6 +63,36 @@ function traverseFileTree(item, path) {
});
}

export const importFiles = async ({
files,
body,
project,
onUploadStart,
onUploadFinish,
onFinish,
onError,
dontCommitToProject,
}) => {
onUploadStart?.(files);

const query = dontCommitToProject ? { commit_to_project: "false" } : {};

const contentType =
body instanceof FormData
? "multipart/form-data" // usual multipart for usual files
: "application/x-www-form-urlencoded"; // chad urlencoded for URL uploads
const res = await API.invoke(
"importFiles",
{ pk: project.id, ...query },
{ headers: { "Content-Type": contentType }, body },
);

if (res && !res.error) onFinish?.(res);
else onError?.(res?.response);

onUploadFinish?.(files);
};

function getFiles(files) {
// @todo this can be not a files, but text or any other draggable stuff
return new Promise((resolve) => {
Expand Down Expand Up @@ -149,14 +185,17 @@ const ErrorMessage = ({ error }) => {

export const ImportPage = ({
project,
sample,
show = true,
onWaiting,
onFileListUpdate,
onSampleDatasetSelect,
highlightCsvHandling,
dontCommitToProject = false,
csvHandling,
setCsvHandling,
addColumns,
hasLabelConfig,
}) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
Expand All @@ -182,7 +221,7 @@ export const ImportPage = ({
};

const [files, dispatch] = useReducer(processFiles, { uploaded: [], uploading: [], ids: [] });
const showList = Boolean(files.uploaded?.length || files.uploading?.length);
const showList = Boolean(files.uploaded?.length || files.uploading?.length || sample);

const loadFilesList = useCallback(
async (file_upload_ids) => {
Expand Down Expand Up @@ -238,27 +277,18 @@ export const ImportPage = ({
[addColumns, loadFilesList, setLoading],
);

const importFiles = useCallback(
const importFilesImmediately = useCallback(
async (files, body) => {
dispatch({ sending: files });

const query = dontCommitToProject ? { commit_to_project: "false" } : {};
// @todo use json for dataset uploads by URL
const contentType =
body instanceof FormData
? "multipart/form-data" // usual multipart for usual files
: "application/x-www-form-urlencoded"; // chad urlencoded for URL uploads
const res = await api.callApi("importFiles", {
params: { pk: project.id, ...query },
headers: { "Content-Type": contentType },
importFiles({
files,
body,
errorFilter: () => true,
project,
onError,
onFinish,
onUploadStart: (files) => dispatch({ sending: files }),
onUploadFinish: (files) => dispatch({ sent: files }),
dontCommitToProject,
});

if (res && !res.error) onFinish?.(res);
else onError?.(res?.response);

dispatch({ sent: files });
},
[project, onFinish],
);
Expand All @@ -277,9 +307,9 @@ export const ImportPage = ({
}
fd.append(f.name, f);
}
return importFiles(files, fd);
return importFilesImmediately(files, fd);
},
[importFiles, onStart],
[importFilesImmediately, onStart],
);

const onUpload = useCallback(
Expand All @@ -304,9 +334,9 @@ export const ImportPage = ({
onWaiting?.(true);
const body = new URLSearchParams({ url });

importFiles([{ name: url }], body);
importFilesImmediately([{ name: url }], body);
},
[importFiles],
[importFilesImmediately],
);

useEffect(() => {
Expand Down Expand Up @@ -337,7 +367,7 @@ export const ImportPage = ({
{highlightCsvHandling && <div className={importClass.elem("csv-splash")} />}
<input id="file-input" type="file" name="file" multiple onChange={onUpload} style={{ display: "none" }} />

<header>
<header className="flex gap-4">
<form className={`${importClass.elem("url-form")} inline-flex`} method="POST" onSubmit={onLoadURL}>
<Input placeholder="Dataset URL" name="url" ref={urlRef} style={{ height: 40 }} />
<Button type="submit" look="primary">
Expand All @@ -353,6 +383,18 @@ export const ImportPage = ({
<IconUpload width="16" height="16" className={importClass.elem("upload-icon")} />
Upload {files.uploaded.length ? "More " : ""}Files
</Button>
{ff.isFF(ff.FF_SAMPLE_DATASETS) && (
<SampleDatasetSelect
samples={samples}
sample={sample}
onSampleApplied={onSampleDatasetSelect}
warningMessage={
hasLabelConfig || (project?.label_config && project.label_config !== EMPTY_CONFIG)
? "Selecting a sample dataset will overwrite your current labeling configuration." :
undefined
}
/>
)}
<div
className={importClass.elem("csv-handling").mod({ highlighted: highlightCsvHandling, hidden: !csvHandling })}
>
Expand Down Expand Up @@ -415,10 +457,33 @@ export const ImportPage = ({
{showList && (
<table>
<tbody>
{sample && (
<tr key={sample.url}>
<td>
<div className="flex items-center gap-2">
{sample.title}
<Badge variant="info" className="h-5 text-xs rounded-sm">
Sample
</Badge>
</div>
</td>
<td>{sample.description}</td>
<td>
<Button
size="icon"
look="destructive"
style={{ height: 26, width: 26, padding: 0 }}
onClick={() => onSampleDatasetSelect(undefined)}
>
<IconTrash style={{ width: 12, height: 12 }} />
</Button>
</td>
</tr>
)}
{files.uploading.map((file, idx) => (
<tr key={`${idx}-${file.name}`}>
<td>{file.name}</td>
<td>
<td colSpan={2}>
<span className={importClass.elem("file-status").mod({ uploading: true })} />
</td>
</tr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
}

& + span {
margin: 0 16px;
color: var(--sand_600);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ProjectProvider, useProject } from "../../../providers/ProjectProvider"
import { useFixedLocation } from "../../../providers/RoutesProvider";
import { Elem } from "../../../utils/bem";
import { useRefresh } from "../../../utils/hooks";
import { ImportPage } from "./Import";
import { importFiles, ImportPage } from "./Import";
import { useImportPage } from "./useImportPage";

export const Inner = () => {
Expand All @@ -18,9 +18,10 @@ export const Inner = () => {
const refresh = useRefresh();
const { project } = useProject();
const [waiting, setWaitingStatus] = useState(false);
const [sample, setSample] = useState(null);
const api = useAPI();

const { uploading, uploadDisabled, finishUpload, fileIds, pageProps } = useImportPage(project);
const { uploading, uploadDisabled, finishUpload, fileIds, pageProps } = useImportPage(project, sample);

const backToDM = useCallback(() => {
const path = location.pathname.replace(ImportModal.path, "");
Expand All @@ -30,6 +31,22 @@ export const Inner = () => {
return refresh(pathname);
}, [location, history]);

const uploadSample = useCallback(
async (sample) => {
if (!sample) return;
setWaitingStatus(true);
const url = sample.url;
const body = new URLSearchParams({ url });
await importFiles({
files: [{ name: url }],
body,
project,
});
setWaitingStatus(false);
},
[project],
);

const onCancel = useCallback(async () => {
setWaitingStatus(true);
await api.callApi("deleteFileUploads", {
Expand All @@ -46,11 +63,25 @@ export const Inner = () => {
}, [modal, project, fileIds, backToDM]);

const onFinish = useCallback(async () => {
await uploadSample(sample);
const imported = await finishUpload();

if (!imported) return;

// If we have a sample with a label_config, update the project's label config
if (sample && sample.label_config) {
await api.callApi("updateProject", {
params: {
pk: project.id,
},
body: {
label_config: sample.label_config,
},
});
}

backToDM();
}, [backToDM, finishUpload]);
}, [backToDM, finishUpload, sample, api, project]);

return (
<Modal
Expand All @@ -76,7 +107,7 @@ export const Inner = () => {
</Button>
</Space>
</Modal.Header>
<ImportPage project={project} {...pageProps} />
<ImportPage project={project} sample={sample} onSampleDatasetSelect={setSample} {...pageProps} />
</Modal>
);
};
Expand Down
Loading
Loading