From 378beab4072280045c6512276509dee9c4c9c4f1 Mon Sep 17 00:00:00 2001 From: Michael Malyuk Date: Thu, 17 Jul 2025 22:47:26 -0700 Subject: [PATCH 01/68] updates to store connector experience --- label_studio/io_storages/api.py | 81 ++ label_studio/io_storages/s3/api.py | 5 + label_studio/io_storages/s3/models.py | 107 ++ label_studio/io_storages/urls.py | 2 + web/apps/labelstudio/src/config/ApiConfig.js | 1 + .../StorageSettings/FormDetails/S3.jsx | 170 +++ .../StorageSettings/Steps/ProviderStep.jsx | 128 ++ .../StorageSettings/StorageFormNew.jsx | 1280 +++++++++++++++++ .../Settings/StorageSettings/StorageSet.jsx | 11 +- 9 files changed, 1781 insertions(+), 4 deletions(-) create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.jsx create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/Steps/ProviderStep.jsx create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx diff --git a/label_studio/io_storages/api.py b/label_studio/io_storages/api.py index 6b6e79d1e378..9e0edda0cf85 100644 --- a/label_studio/io_storages/api.py +++ b/label_studio/io_storages/api.py @@ -176,6 +176,87 @@ def create(self, request, *args, **kwargs): return Response() +class ImportStorageListFilesAPI(generics.CreateAPIView): + # permission_required = all_permissions.projects_change + parser_classes = (JSONParser, FormParser, MultiPartParser) + + def create(self, request, *args, **kwargs): + storage_id = request.data.get('id') + instance = None + if storage_id: + instance = generics.get_object_or_404(self.serializer_class.Meta.model.objects.all(), pk=storage_id) + if not instance.has_permission(request.user): + raise PermissionDenied() + + # combine instance fields with request.data + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + # if storage exists, we have to use instance from DB, + # because instance from serializer won't have credentials, they were popped intentionally + if instance: + instance = serializer.update(instance, serializer.validated_data) + else: + instance = serializer.Meta.model(**serializer.validated_data) + + # double check: not all storages validate connection in serializer, just make another explicit check here + try: + params = self._extract_pagination_params(request.data) + all_files = list(instance.iter_keys(**params)) + + # Get files with pagination + files = [ + { + 'key': fl.get('Key'), + 'last_modified': fl.get('LastModified'), + 'size': fl.get('Size') + } + for fl in all_files + ] + + # Try to get continuation token if available + next_token = self._extract_continuation_token(instance.iter_keys, params) + + return Response({ + 'files': files, + 'continuation_token': next_token + }) + except Exception as exc: + raise ValidationError(exc) + + return Response() + + def _extract_pagination_params(self, data): + """Extract and validate pagination parameters.""" + params = {} + + # Process limit parameter + if 'limit' in data: + try: + limit = int(data['limit']) + if limit <= 0: + raise ValidationError({'limit': 'Must be a positive integer'}) + params['limit'] = limit + except (ValueError, TypeError): + raise ValidationError({'limit': 'Must be a valid integer'}) + + # Process pagination token + if data.get('starting_token'): + params['starting_token'] = data['starting_token'] + + # Process sort direction + params['reverse'] = bool(data.get('reverse', False)) + + # Process page size + try: + page_size = int(data.get('page_size', 1000)) + params['page_size'] = max(1, min(page_size, 1000)) # Clamp between1-1000 + except (ValueError, TypeError): + params['page_size'] = 1000 + + return params + + + @extend_schema(exclude=True) class StorageFormLayoutAPI(generics.RetrieveAPIView): diff --git a/label_studio/io_storages/s3/api.py b/label_studio/io_storages/s3/api.py index fb2dc78bbd84..2762289ebb6f 100644 --- a/label_studio/io_storages/s3/api.py +++ b/label_studio/io_storages/s3/api.py @@ -14,6 +14,7 @@ ImportStorageListAPI, ImportStorageSyncAPI, ImportStorageValidateAPI, + ImportStorageListFilesAPI ) from io_storages.s3.models import S3ExportStorage, S3ImportStorage from io_storages.s3.serializers import S3ExportStorageSerializer, S3ImportStorageSerializer @@ -143,6 +144,10 @@ class S3ImportStorageSyncAPI(ImportStorageSyncAPI): serializer_class = S3ImportStorageSerializer +class S3ImportStorageListFilesAPI(ImportStorageListFilesAPI): + serializer_class = S3ImportStorageSerializer + + @method_decorator( name='post', decorator=extend_schema( diff --git a/label_studio/io_storages/s3/models.py b/label_studio/io_storages/s3/models.py index 1aa217598329..750f732e738b 100644 --- a/label_studio/io_storages/s3/models.py +++ b/label_studio/io_storages/s3/models.py @@ -212,6 +212,113 @@ def iterkeys(self): continue yield key + # [TODO] this is a bit of a refactor of the above, we will need to + # add proper error handling for every possible exceptions and + # return readable message back to the user + def iter_keys(self, limit=None, starting_token=None, page_size=1000): + """ + Iterate through S3 object keys with pagination and sorting options. + Args: + limit (int, optional): Maximum number of keys to return. None means no limit. + starting_token (str, optional): Continuation token for pagination. + page_size (int, optional): Number of items per S3 API call (max 1000). + + Yields: + str: S3 object keys matching the criteria + """ + client, bucket = self.get_client_and_bucket() + params = self._build_list_params(page_size, starting_token) + + for key in self._iter_keys_forward(client, params, limit): + yield key + + def _build_list_params(self, page_size, starting_token): + """Build parameters for S3 list_objects_v2 call.""" + params = { + 'Bucket': self.bucket, + 'MaxKeys': min(page_size, 1000), # S3 max is 1000 + } + + if self.prefix: + params['Prefix'] = self.prefix.rstrip('/') + '/' + + if not self.recursive_scan: + params['Delimiter'] = '/' + + if starting_token: + params['ContinuationToken'] = starting_token + + return params + + def _iter_keys_forward(self, client, params, limit): + """Forward iteration implementation.""" + regex = re.compile(str(self.regex_filter)) if self.regex_filter else None + count = 0 + + while limit is None or count < limit: + response = client.list_objects_v2(**params) + + for obj in response.get('Contents', []): + key = obj['Key'] + + # Skip folders and non-matching keys + if key.endswith('/'): + logger.debug(f"{key} is skipped because it is a folder") + continue + + if regex and not regex.match(key): + logger.debug(f"{key} is skipped by regex filter") + continue + + yield obj + count += 1 + if limit is not None and count >= limit: + return + + # Check for more pages + if not response.get('IsTruncated'): + break + + params['ContinuationToken'] = response.get('NextContinuationToken') + + def _iter_keys_reverse(self, client, params, limit): + """Reverse iteration implementation.""" + regex = re.compile(str(self.regex_filter)) if self.regex_filter else None + keys_to_return = [] + max_keys = limit if limit is not None else float('inf') + + # Collect matching keys + while len(keys_to_return) < max_keys: + response = client.list_objects_v2(**params) + + for obj in response.get('Contents', []): + key = obj['Key'] + + # Skip folders and non-matching keys + if key.endswith('/'): + logger.debug(f"{key} is skipped because it is a folder") + continue + + if regex and not regex.match(key): + logger.debug(f"{key} is skipped by regex filter") + continue + + keys_to_return.append(key) + if len(keys_to_return) >= max_keys: + break + + # Check for more pages + if not response.get('IsTruncated') or len(keys_to_return) >= max_key: + break + + params['ContinuationToken'] = response.get('NextContinuationToken') + + # Yield in reverse order + for key in sorted(keys_to_return, reverse=True): + yield key + + + @catch_and_reraise_from_none def scan_and_create_links(self): return self._scan_and_create_links(S3ImportStorageLink) diff --git a/label_studio/io_storages/urls.py b/label_studio/io_storages/urls.py index 8c98b04951c7..077e23211094 100644 --- a/label_studio/io_storages/urls.py +++ b/label_studio/io_storages/urls.py @@ -68,6 +68,7 @@ S3ImportStorageListAPI, S3ImportStorageSyncAPI, S3ImportStorageValidateAPI, + S3ImportStorageListFilesAPI ) app_name = 'storages' @@ -85,6 +86,7 @@ path('s3//sync', S3ImportStorageSyncAPI.as_view(), name='storage-s3-sync'), path('s3/validate', S3ImportStorageValidateAPI.as_view(), name='storage-s3-validate'), path('s3/form', S3ImportStorageFormLayoutAPI.as_view(), name='storage-s3-form'), + path('s3/files', S3ImportStorageListFilesAPI.as_view(), name='storage-s3-list-files'), path('export/s3', S3ExportStorageListAPI.as_view(), name='export-storage-s3-list'), path('export/s3/', S3ExportStorageDetailAPI.as_view(), name='export-storage-s3-detail'), path('export/s3//sync', S3ExportStorageSyncAPI.as_view(), name='export-storage-s3-sync'), diff --git a/web/apps/labelstudio/src/config/ApiConfig.js b/web/apps/labelstudio/src/config/ApiConfig.js index 3acbfd36ad13..62c8f95cb572 100644 --- a/web/apps/labelstudio/src/config/ApiConfig.js +++ b/web/apps/labelstudio/src/config/ApiConfig.js @@ -50,6 +50,7 @@ export const API_CONFIG = { updateStorage: "PATCH:/storages/:target?/:type/:pk", syncStorage: "POST:/storages/:target?/:type/:pk/sync", validateStorage: "POST:/storages/:target?/:type/validate", + storageFiles: "POST:/storages/:target?/:type/files", // ML mlBackends: "GET:/ml", diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.jsx new file mode 100644 index 000000000000..3d5820cedeb5 --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.jsx @@ -0,0 +1,170 @@ +import { useCallback, useContext, useEffect, useRef, useState } from "react"; + +import { Label, Toggle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@humansignal/ui"; +import { Input } from "../../../../components/Form"; + + + +export const S3 = ({ formData, handleChange }) => { + return ( +
+ + {/* Section 2: Bucket Configuration */} +
+
+ + +
+ +
+ + +
+ +
+ + {/* Section 4: Credentials */} +
+
+ + +
+ +
+ + +
+
+ + {/* Section 3: AWS Configuration */} +
+ +
+ + +

Optional prefix to limit files to a specific folder

+
+ +
+ + +

For S3-compatible storage (leave empty for AWS S3)

+
+
+ + +
+ + +

Optional session token for temporary AWS credentials

+
+ + {/* Section 5: Additional Settings */} +
+
+
+
+ +

Generate pre-signed URLs for secure file access

+
+ + setFormData(prev => ({ ...prev, presign: checked })) + } + /> +
+
+ + {formData.presign && ( +
+ +

Minutes until pre-signed URLs expire (1-10080 minutes)

+
+ handleNumberChange('presign_ttl', parseInt(e.target.value))} + className="w-24" + /> + handleNumberChange('presign_ttl', value[0])} + className="flex-1" + /> +
+
+ )} +
+
+ ); +} + +S3.title = "AWS S3 Configuration"; +S3.description = "Configure your AWS S3 connection with all required Label Studio settings"; diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/Steps/ProviderStep.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/Steps/ProviderStep.jsx new file mode 100644 index 000000000000..a9afae38b330 --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/Steps/ProviderStep.jsx @@ -0,0 +1,128 @@ + +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { cn } from "@/lib/utils"; + +const renderBasicInfoStep = () => ( +
+
+

Choose your cloud storage provider

+

Select the cloud storage service where your data is stored

+
+ +
+ + + {errors.connectionName && ( +

{errors.connectionName}

+ )} +
+ +
+ + handleSelectChange("provider", value)} + className="space-y-3" + > + {/* AWS Option */} +
+ +
+ + + + + +
+ +
+ +

Amazon Simple Storage Service

+
+ +
+ {formData.provider === "aws" && ( +
+ )} +
+
+ + {/* GCP Option */} +
+ +
+ + + + + +
+ +
+ +

Unified object storage for developers and enterprises

+
+ +
+ {formData.provider === "gcp" && ( +
+ )} +
+
+ + {/* Azure Option */} +
+ +
+ + + + + + +
+ +
+ +

Microsoft's object storage solution for the cloud

+
+ +
+ {formData.provider === "azure" && ( +
+ )} +
+
+
+ + {errors.provider && ( +

{errors.provider}

+ )} +
+
+); diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx new file mode 100644 index 000000000000..be0849373ccd --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx @@ -0,0 +1,1280 @@ +import { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState, React } from "react"; +import { atom, useAtom } from 'jotai'; +import { cn } from "@humansignal/shad/utils"; +import { z } from 'zod'; + +// import { zodResolver } from '@hookform/resolvers/zod'; + +import { Button } from "@humansignal/ui"; +import { InlineError } from "../../../components/Error/InlineError"; +import { Form, Input } from "../../../components/Form"; +import { Oneof } from "../../../components/Oneof/Oneof"; +import { ApiContext } from "../../../providers/ApiProvider"; +import { Block, Elem } from "../../../utils/bem"; +import { isDefined } from "../../../utils/helpers"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@humansignal/shad/components/ui/card"; + +import { Label, Toggle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@humansignal/ui"; + +import { RadioGroup, RadioGroupItem } from "@humansignal/shad/components/ui/radio-group"; + + +import { IconCross, IconDocument, IconSearch } from "@humansignal/icons"; + +import { S3 } from "./FormDetails/S3"; +import { formatDistanceToNow, format } from 'date-fns'; + +import { Toast, ToastProvider, ToastViewport } from "@radix-ui/react-toast"; + +const Stepper = ({ steps, currentStep }) => { + return ( +
+
+ {/* Step circles and names */} +
+ {steps.map((step, index) => ( +
+
index + ? "w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-200" // completed + : currentStep === index + ? "w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-200" // current + : "w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 bg-white border-2 border-slate-200 text-slate-400" // upcoming + )} + > + {currentStep > index ? ( + + ) : ( + index + 1 + )} +
+ = index ? "text-primary font-sm" : "text-muted-foreground" + )}> + {step.title} + +
+ ))} +
+ + {/* Progress bar */} +
+
+
+
+
+ ); +}; + + +// Step validation schemas +const basicInfoSchema = z.object({ + connectionName: z.string().min(1, "Storage title is required"), + provider: z.enum(["s3", "gcp", "azure"], { + required_error: "Please select a cloud provider" + }) +}); + +const storageDetailsSchema = z.object({ + bucketName: z.string().min(1, "Bucket Name is required"), + bucketPrefix: z.string().optional(), + region: z.string().optional(), + endpoint: z.string().optional(), + accessKeyId: z.string().min(1, "Access Key ID is required"), + secretKey: z.string().min(1, "Secret Access Key is required"), + sessionToken: z.string().optional(), + usePresignedUrls: z.boolean().default(false), +}); + +const previewDetailsSchema = z.object({ + fileFilterRegex: z.string().optional(), + importAllAsDataSources: z.boolean().default(false), + recursiveScan: z.boolean().default(false) + + // apiKey: z.string().min(8, "API key must be at least 8 characters"), + // secretKey: z.string().min(8, "Secret key must be at least 8 characters") +}); + +// Combine all schemas +const formSchema = basicInfoSchema.merge(storageDetailsSchema).merge(previewDetailsSchema); + +// {"project":"106076","title":"asfdsdaf","bucket":"asdfsadfsadf","prefix":"asdfasdfsadf","regex_filter":"","region_name":"asdfsadf","s3_endpoint":"asdfsadf","aws_access_key_id":"got ya, suspisadfasdfcious hacker!","aws_secret_access_key":"got ya, suspasdfasdficious hacker!","aws_session_token":"got sadfsadfya, suspicious hacker!","use_blob_urls":false,"recursive_scan":false,"presign":true,"presign_ttl":"15"} + + const formStateAtom = atom({ + currentStep: 0, + formData: { + project: 0, + + title: "", // Storage title + provider: "", + + bucket: "", // Bucket Name * + prefix: "", // Bucket Prefix + + region_name: "", // Region Name + s3_endpoint: "", // S3 Endpoint + + aws_access_key_id: "", // Access Key ID * + aws_secret_access_key: "", // Secret Access Key * + + aws_session_token: "", + + presign: false, // Use pre-signed URLs + + regex_filter: "", + + use_blob_urls: false, + recursive_scan: true, + }, + isComplete: false + }); + + +// // State atom +// const formStateAtom = atom({ +// currentStep: 0, +// formData: { +// connectionName: "", +// provider: "", +// region: "", +// bucket: "", +// apiKey: "", +// secretKey: "" +// }, +// isComplete: false +// }); + + +export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass, storage, storageTypes, title, onClose = () => {} }, ref) => { + /**@type {import('react').RefObject
} */ + const api = useContext(ApiContext); + const formRef = ref ?? useRef(); + const [type, setType] = useState(); + const [checking, setChecking] = useState(false); + const [connectionValid, setConnectionValid] = useState(null); + const [formFields, setFormFields] = useState([]); + + const [filesPreview, setFilesPreview] = useState(null); + const [ loadingFilesPreiview, setLoadingFilesPreiview ] = useState(false); + + const [nextPreviewToken, setNextPreviewToken] = useState(""); + + useEffect(() => { + api + .callApi("storageForms", { + params: { + target, + type, + }, + }) + .then((formFields) => setFormFields(formFields ?? []));; + }, [type]); + + // Error state + const [errors, setErrors] = useState({}); + + + const [ testingConnection, setTestingConnection ] = useState(false); + const [connectionChecked, setConnectionChecked] = useState(false); + + // const [currentStep, setCurrentStep] = useState(0); + + // // new stuff START + + // Define step components + + // const [formData, setFormData] = useState({ + // connectionName: "", + // provider: "", + // region: "", + // bucket: "", + // apiKey: "", + // secretKey: "" + // }); + + + + const [ formState, setFormState ] = useAtom(formStateAtom); + const { currentStep, formData } = formState; + + useEffect(() => { + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + project: project + } + })); + }, []); // Empty dependency array means this runs once on component mount + + + const setCurrentStep = (step) => { + setFormState((prevState) => ({ + ...prevState, + currentStep: step + })); + } + + const steps = [ + { title: "Select Provider", schema: basicInfoSchema }, + { title: "Configure Connection", schema: storageDetailsSchema }, + { title: "Preview & Import Settings" }, + { title: "Review & Confirm" } + ]; + + // Get current schema based on step + const currentSchema = steps[currentStep].schema || z.object({}); + + const handleChange = (e) => { + const { name, value } = e.target; + + setFormState(prev => ({ + ...prev, + formData: { + ...prev.formData, + [name]: value + } + })); + + // setFormData(prev => ({ + // ...prev, + // [name]: value + // })); + + // Clear error for this field when it changes + if (errors[name]) { + setErrors(prev => { + const newErrors = {...prev}; + delete newErrors[name]; + return newErrors; + }); + } + }; + + // Handle select changes + const handleSelectChange = (name, value) => { + + console.log(name); + console.log(value); + + setType(value); + + setFormState(prev => ({ + ...prev, + formData: { + ...prev.formData, + [name]: value + } + })); + + // setFormState(prevState => ({ + // ...prevState, + // formData: { + // ...prevState.formData, + // provider: "s3" + // } + // })); + + // setFormData(prev => ({ + // ...prev, + // [name]: value + // })); + + // Clear error for this field when it changes + // if (errors[name]) { + // setErrors(prev => { + // const newErrors = {...prev}; + // delete newErrors[name]; + // return newErrors; + // }); + // } + }; + + // Validate current step + const validateStep = () => { + // No validation for review step + if (currentStep === steps.length - 1) return true; + + const schema = steps[currentStep].schema; + try { + // Extract only the fields relevant to current step + const currentData = {}; + Object.keys(schema.shape).forEach(key => { + currentData[key] = formData[key]; + }); + + // Validate with Zod + schema.parse(currentData); + setErrors({}); + return true; + } catch (error) { + if (error instanceof z.ZodError) { + // Transform Zod errors into a field-error map + const newErrors = {}; + error.errors.forEach(err => { + // Remove the first part of the path (which is just the field name) + const fieldName = err.path[0]; + newErrors[fieldName] = err.message; + }); + setErrors(newErrors); + } + return false; + } + }; + + // Setup form with current schema + // const form = useForm({ + // // resolver: zodResolver(currentSchema), + // defaultValues: formData, + // mode: "onChange" + // }); + + const nextStep = () => { + //if (validateStep()) { + if (currentStep < steps.length - 1) { + setCurrentStep(currentStep + 1); + } else { + // Submit the form + console.log("Form submitted with data:", formData); + // Here you would typically make an API call + alert("Form submitted successfully!"); + } + //} + }; + + // Go to previous step + const prevStep = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }; + + + // Format the file size + const formatSize = (bytes) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + + const testStorageConnection = useCallback(async () => { + setConnectionChecked(true); + }, [formState, target, storage]); + + const loadFilesPreview = useCallback(async () => { + setLoadingFilesPreiview(true); + // setChecking(true); + // setConnectionValid(null); + + // Get the form data directly from Atom state + const { formData } = formState; + + // TODO this needs to be dynamic + const type = 's3'; + + // Check if the form data is valid + // You might need to implement a validation logic here + const isFormValid = true; // Replace with actual validation check + + if (isFormValid) { + const body = { ...formData }; + + if (isDefined(storage?.id)) { + body.id = storage.id; + } + + console.log(target, type, body) + + // Use your API service directly instead of form.api + // You might need to adapt this to your actual API service + // const response = await api.callApi("storageFiles", { + // params: { + // limit: 10, + // target, + // type, + // }, + // body, + // }); + + const fl = [ + { "key": "hello/world.jpg", last_modified: "2024", size: 423748 }, + { "key": "yo/hello/world.jpg", last_modified: "2025", size: 3748 } + ] + + for (let i = 0; i<10000; i++) { + fl.push({ "key": "hello/world.jpg", last_modified: "2024", size: 423748 }); + } + + const response = { "files": fl }; + + setFilesPreview(response?.files); + setNextPreviewToken(response?.continuation_token); + setLoadingFilesPreiview(false); + // console.log(response); + // console.log(response); + // if (response?.$meta?.ok) setConnectionValid(true); + // else setConnectionValid(false); + } + + // setChecking(false); + }, [formState, target, storage]); + + // const validateConnection = useCallback(async () => { + // setChecking(true); + // setConnectionValid(null); + + // const form = formRef.current; + + // if (form && form.validateFields()) { + // const body = formData form.assembleFormData({ asJSON: true }); + // const type = form.getField("storage_type").value; + + // if (isDefined(storage?.id)) { + // body.id = storage.id; + // } + + // // we're using api provided by the form to be able to save + // // current api context and render inline erorrs properly + // const response = await form.api.callApi("storageFiles", { + // params: { + // target, + // type, + // }, + // body, + // }); + + // if (response?.$meta?.ok) setConnectionValid(true); + // else setConnectionValid(false); + // } + // setChecking(false); + // }, [formRef, target, type, storage]); + + // const RadioButtonContent = ({ value, label, description }) => { + // // The component will re-render when RadioButton re-renders + // // and will receive the current checked state + // return ( + // + // ); + // }; + + + const renderProviderSelectionStep = () => ( +
+
+

Choose your cloud storage provider

+

Select the cloud storage service where your data is stored

+
+ +
+ + +

This name will help you identify this connection in your project

+
+ +
+ + + handleSelectChange("provider", value)} + +> + + + {/* GCP Option */} + + + {/* Azure Option */} + + + + + + + {errors.provider && ( +

{errors.provider}

+ )} +
+ + + +
+ ); + + + const action = useMemo(() => { + return storage ? "updateStorage" : "createStorage"; + }, [storage]); + + + const renderProviderDetails = () => { + return ( +
+
+

AWS S3 Configuration

+

Configure your AWS S3 connection with all required Label Studio settings

+
+ + + + + + + + + + {errors.provider && ( +

{errors.provider}

+ )} +
+ ); + }; + + + const renderPreviewStep = () => { + return ( +
+
+

Configure Import Settings & Preview Data

+

Set up filters for your files and preview what will be synchronized

+
+ +
+ {/* Left Column Header */} +

Import Configuration

+ + {/* Right Column Header with Button */} +
+

Files Preview

+
+ + {/* Left Column: Configuration */} +
+
+ +
+ {/* File Filter Section */} +
+ +
+ + +
+ Common filters: + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\.(jpe?g|png|gif)$" + } + })); + }} + > + Images + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.(mp4|avi|mov|wmv|webm)$" + } + })); + }} + > + Videos + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.(mp3|wav|ogg|flac)$" + } + })); + }} + > + Audio + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.(csv|tsv)$" + } + })); + }} + > + Tabular + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.(txt|html|xml)$" + } + })); + }} + > + Text + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.pdf$" + } + })); + }} + > + PDFs + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.json$" + } + })); + }} + > + JSON + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*" + } + })); + }} + > + All Files + +
+
+
+ + {/* Import Options */} + +
+
+ + + Files will be imported as source data (images, text, etc.) rather than annotation tasks (JSON) + +
+ + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + use_blob_urls: checked + } + })) + } + /> +
+ +
+
+ +

+ Scan all subdirectories within the bucket or folder path +

+
+ + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + recursive_scan: checked + } + })) + } + /> +
+
+
+
+ + {/* Right Column: Preview Files */} +
+
+ {filesPreview === null ? ( + // No API response yet +
+
+ +
+

No Preview Available

+

+ Configure your import settings and click "Load Preview" to see a sample of files that will be imported. +

+
+ ) : filesPreview.length === 0 ? ( + // API returned empty array +
+
+ +
+

No Files Found

+

+ No files matching your current criteria were found. Try adjusting your filter settings and reload the preview. +

+
+ ) : ( + // Files available - display in a table format with fixed height and scrolling +
+
+ {filesPreview.map((file, index) => ( +
+
{file.key}
+
+ {formatDistanceToNow(new Date(file.last_modified), {addSuffix: true})} + + {formatSize(file.size)} +
+
+ ))} +
+
+ )} +
+
+
+
+ ); +}; + + + const renderReviewStep = () => { + return ( +
+
+

Ready to Connect

+

+ Review your connection details and confirm to start importing +

+
+ + {/* Connection Details Section */} +
+
+

Provider

+

Amazon S3

+
+ +
+

Bucket

+

asdfasdf

+
+ +
+

Files to import

+

1247 files

+
+ +
+

Total size

+

2.3 GB

+
+
+ + {/* Import Process Section */} +
+

Import Process

+

+ Files will be imported in the background. You can continue working while the import is in progress. +

+
+
+ ); + }; + + const renderStepContent = () => { + switch (currentStep) { + case 0: + return renderProviderSelectionStep(); + case 1: + return renderProviderDetails(); + case 2: + return renderPreviewStep(); + case 3: + return renderReviewStep(); + default: + return null; + } + }; + + const handleClose = () => { + onClose(); + modalInstance?.close(); + }; + + return ( +
+ + {/* Custom header with title, subtitle and close button */} +
+
+

+ {title} +

+ {true && ( +
+ {"Import your data from cloud storage providers"} +
+ )} +
+
+ + + + +
+ { renderStepContent() } +
+ +
+ + + +
+ {currentStep === 1 && ( + + + + )} + + {currentStep === 2 && ( + + + + + )} + + +
+ +
+
+ ); + + return ( + + + + + Add Cloud Storage Connection + + + + { renderStepContent() } + + + + + + + + + ); + +}); + + +// import { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +// import { Button } from "../../../components"; +// import { InlineError } from "../../../components/Error/InlineError"; +// import { Form, Input } from "../../../components/Form"; +// import { Oneof } from "../../../components/Oneof/Oneof"; +// import { ApiContext } from "../../../providers/ApiProvider"; +// import { Block, Elem } from "../../../utils/bem"; +// import { isDefined } from "../../../utils/helpers"; + +// // import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@humansignal/shad/components/ui/card"; + +// // Custom Card Components +// // const Card = ({ className, ...props }) => { +// // return ( +// //
+// // ) +// // } + +// // const CardHeader = ({ className, ...props }) => { +// // return ( +// //
+// // ) +// // } + +// // const CardTitle = ({ className, ...props }) => { +// // return ( +// //

+// // ) +// // } + +// // const CardContent = ({ className, ...props }) => { +// // return
+// // } + +// // const CardFooter = ({ className, ...props }) => { +// // return ( +// //
+// // ) +// // } + +// export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass, storage, storageTypes }, ref) => { +// /**@type {import('react').RefObject
} */ +// const api = useContext(ApiContext); +// const formRef = ref ?? useRef(); +// const [type, setType] = useState(storage?.type ?? storageTypes?.[0]?.name ?? "s3"); +// const [checking, setChecking] = useState(false); +// const [connectionValid, setConnectionValid] = useState(null); +// const [formFields, setFormFields] = useState([]); + +// useEffect(() => { +// api +// .callApi("storageForms", { +// params: { +// target, +// type, +// }, +// }) +// .then((formFields) => setFormFields(formFields ?? [])); +// }, [type]); + +// const storageTypeSelect = { +// columnCount: 1, +// fields: [ +// { +// skip: true, +// type: "select", +// name: "storage_type", +// label: "Storage Type", +// disabled: !!storage, +// options: storageTypes.map(({ name, title }) => ({ +// value: name, +// label: title, +// })), +// value: storage?.type ?? type, +// onChange: setType, +// }, +// ], +// }; + +// const validateStorageConnection = useCallback(async () => { +// setChecking(true); +// setConnectionValid(null); + +// const form = formRef.current; + +// if (form && form.validateFields()) { +// const body = form.assembleFormData({ asJSON: true }); +// const type = form.getField("storage_type").value; + +// if (isDefined(storage?.id)) { +// body.id = storage.id; +// } + +// // we're using api provided by the form to be able to save +// // current api context and render inline erorrs properly +// const response = await form.api.callApi("validateStorage", { +// params: { +// target, +// type, +// }, +// body, +// }); + +// if (response?.$meta?.ok) setConnectionValid(true); +// else setConnectionValid(false); +// } +// setChecking(false); +// }, [formRef, target, type, storage]); + +// const action = useMemo(() => { +// return storage ? "updateStorage" : "createStorage"; +// }, [storage]); + +// return ( +// +// +// +// +// +// +// Successfully connected! +// +// +// Connection failed +// +// +// +// ) +// } +// > +// +// +// +// +// +// + +// +// +// +// ); +// }); + diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx index 65c8265d377a..51fbed96597b 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx @@ -7,6 +7,7 @@ import { ApiContext } from "../../../providers/ApiProvider"; import { projectAtom } from "../../../providers/ProjectProvider"; import { StorageCard } from "./StorageCard"; import { StorageForm } from "./StorageForm"; +import { StorageFormNew } from "./StorageFormNew"; import { useAtomValue } from "jotai"; import { useStorageCard } from "./hooks/useStorageCard"; @@ -32,16 +33,18 @@ export const StorageSet = ({ title, target, rootClass, buttonLabel }) => { const showStorageFormModal = useCallback( (storage) => { - const action = storage ? "Edit" : "Add"; + const action = storage ? "Edit" : "Connect"; const actionTarget = target === "export" ? "Target" : "Source"; const title = `${action} ${actionTarget} Storage`; const modalRef = modal({ title, closeOnClickOutside: false, - style: { width: 760 }, + style: { width: 960 }, + bare: true, body: ( - { > Learn more {" "} - about importing data and saving annotations to Cloud Storage. + about importing data and saving annotations to Cloud Storage! ), }); From f9537eee8f8bbc71646c9d7eb7b8d2447f13bd97 Mon Sep 17 00:00:00 2001 From: Michael Malyuk Date: Thu, 17 Jul 2025 23:19:46 -0700 Subject: [PATCH 02/68] storage removing code --- .../StorageSettings/FormDetails/S3.jsx | 9 +- .../StorageSettings/StorageFormNew.jsx | 1044 +++++++---------- .../Settings/StorageSettings/StorageSet.jsx | 26 +- 3 files changed, 415 insertions(+), 664 deletions(-) diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.jsx index 3d5820cedeb5..66ad9a64ed3a 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.jsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.jsx @@ -4,8 +4,7 @@ import { Label, Toggle, Select, SelectContent, SelectItem, SelectTrigger, Select import { Input } from "../../../../components/Form"; - -export const S3 = ({ formData, handleChange }) => { +export const S3 = ({ formData, setFormData, handleChange }) => { return (
@@ -147,15 +146,15 @@ export const S3 = ({ formData, handleChange }) => { min={1} max={10080} value={formData.presign_ttl} - onChange={(e) => handleNumberChange('presign_ttl', parseInt(e.target.value))} + onChange={(e) => handleChange('presign_ttl', parseInt(e.target.value))} className="w-24" /> - handleNumberChange('presign_ttl', value[0])} + onChange={(value) => handleChange('presign_ttl', value[0])} className="flex-1" />
diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx index be0849373ccd..4c4752a6f84c 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx @@ -4,7 +4,6 @@ import { cn } from "@humansignal/shad/utils"; import { z } from 'zod'; // import { zodResolver } from '@hookform/resolvers/zod'; - import { Button } from "@humansignal/ui"; import { InlineError } from "../../../components/Error/InlineError"; import { Form, Input } from "../../../components/Form"; @@ -13,19 +12,14 @@ import { ApiContext } from "../../../providers/ApiProvider"; import { Block, Elem } from "../../../utils/bem"; import { isDefined } from "../../../utils/helpers"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@humansignal/shad/components/ui/card"; - import { Label, Toggle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@humansignal/ui"; - import { RadioGroup, RadioGroupItem } from "@humansignal/shad/components/ui/radio-group"; - - import { IconCross, IconDocument, IconSearch } from "@humansignal/icons"; - import { S3 } from "./FormDetails/S3"; import { formatDistanceToNow, format } from 'date-fns'; - import { Toast, ToastProvider, ToastViewport } from "@radix-ui/react-toast"; + const Stepper = ({ steps, currentStep }) => { return (
@@ -138,21 +132,6 @@ const formSchema = basicInfoSchema.merge(storageDetailsSchema).merge(previewDeta }); -// // State atom -// const formStateAtom = atom({ -// currentStep: 0, -// formData: { -// connectionName: "", -// provider: "", -// region: "", -// bucket: "", -// apiKey: "", -// secretKey: "" -// }, -// isComplete: false -// }); - - export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass, storage, storageTypes, title, onClose = () => {} }, ref) => { /**@type {import('react').RefObject} */ const api = useContext(ApiContext); @@ -168,6 +147,7 @@ export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass const [nextPreviewToken, setNextPreviewToken] = useState(""); useEffect(() => { + console.log("type changed: " + type); api .callApi("storageForms", { params: { @@ -181,27 +161,9 @@ export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass // Error state const [errors, setErrors] = useState({}); - const [ testingConnection, setTestingConnection ] = useState(false); - const [connectionChecked, setConnectionChecked] = useState(false); - - // const [currentStep, setCurrentStep] = useState(0); - - // // new stuff START - - // Define step components - - // const [formData, setFormData] = useState({ - // connectionName: "", - // provider: "", - // region: "", - // bucket: "", - // apiKey: "", - // secretKey: "" - // }); - - - + const [ connectionChecked, setConnectionChecked ] = useState(false); + const [ formState, setFormState ] = useAtom(formStateAtom); const { currentStep, formData } = formState; @@ -261,10 +223,6 @@ export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass // Handle select changes const handleSelectChange = (name, value) => { - - console.log(name); - console.log(value); - setType(value); setFormState(prev => ({ @@ -330,13 +288,6 @@ export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass } }; - // Setup form with current schema - // const form = useForm({ - // // resolver: zodResolver(currentSchema), - // defaultValues: formData, - // mode: "onChange" - // }); - const nextStep = () => { //if (validateStep()) { if (currentStep < steps.length - 1) { @@ -369,6 +320,7 @@ export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass const testStorageConnection = useCallback(async () => { + // TODO should be real setConnectionChecked(true); }, [formState, target, storage]); @@ -394,35 +346,31 @@ export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass body.id = storage.id; } - console.log(target, type, body) - // Use your API service directly instead of form.api // You might need to adapt this to your actual API service - // const response = await api.callApi("storageFiles", { - // params: { - // limit: 10, - // target, - // type, - // }, - // body, - // }); - - const fl = [ - { "key": "hello/world.jpg", last_modified: "2024", size: 423748 }, - { "key": "yo/hello/world.jpg", last_modified: "2025", size: 3748 } - ] - - for (let i = 0; i<10000; i++) { - fl.push({ "key": "hello/world.jpg", last_modified: "2024", size: 423748 }); - } + const response = await api.callApi("storageFiles", { + params: { + limit: 10, + target, + type, + }, + body, + }); + + // const fl = [ + // { "key": "hello/world.jpg", last_modified: "2024", size: 423748 }, + // { "key": "yo/hello/world.jpg", last_modified: "2025", size: 3748 } + // ] - const response = { "files": fl }; + // for (let i = 0; i<10000; i++) { + // fl.push({ "key": "hello/world.jpg", last_modified: "2024", size: 423748 }); + // } + + // const response = { "files": fl }; setFilesPreview(response?.files); setNextPreviewToken(response?.continuation_token); - setLoadingFilesPreiview(false); - // console.log(response); - // console.log(response); + setLoadingFilesPreiview(false); // if (response?.$meta?.ok) setConnectionValid(true); // else setConnectionValid(false); } @@ -500,95 +448,84 @@ export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass
- - - handleSelectChange("provider", value)} - -> - - {/* GCP Option */} - - {/* Azure Option */} - - - - - - - {errors.provider && ( -

{errors.provider}

- )} -
- - -
); - const action = useMemo(() => { return storage ? "updateStorage" : "createStorage"; }, [storage]); - const renderProviderDetails = () => { return (
@@ -606,9 +543,7 @@ export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass autoFill="off" autoComplete="off"> - - - + {}} handleChange={handleChange} /> @@ -619,288 +554,287 @@ export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass ); }; - const renderPreviewStep = () => { - return ( -
-
-

Configure Import Settings & Preview Data

-

Set up filters for your files and preview what will be synchronized

-
- -
- {/* Left Column Header */} -

Import Configuration

- - {/* Right Column Header with Button */} -
-

Files Preview

-
- - {/* Left Column: Configuration */} + return ( +
-
- -
- {/* File Filter Section */} -
- -
- - -
- Common filters: - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\.(jpe?g|png|gif)$" - } - })); - }} - > - Images - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.(mp4|avi|mov|wmv|webm)$" - } - })); - }} - > - Videos - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.(mp3|wav|ogg|flac)$" - } - })); - }} - > - Audio - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.(csv|tsv)$" - } - })); - }} - > - Tabular - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.(txt|html|xml)$" - } - })); - }} - > - Text - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.pdf$" - } - })); - }} - > - PDFs - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.json$" - } - })); - }} - > - JSON - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*" - } - })); - }} - > - All Files - +

Configure Import Settings & Preview Data

+

Set up filters for your files and preview what will be synchronized

+
+ +
+ {/* Left Column Header */} +

Import Configuration

+ + {/* Right Column Header with Button */} +
+

Files Preview

+
+ + {/* Left Column: Configuration */} +
+ + +
+ {/* File Filter Section */} +
+ +
+ + +
+ Common filters: + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\.(jpe?g|png|gif)$" + } + })); + }} + > + Images + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.(mp4|avi|mov|wmv|webm)$" + } + })); + }} + > + Videos + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.(mp3|wav|ogg|flac)$" + } + })); + }} + > + Audio + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.(csv|tsv)$" + } + })); + }} + > + Tabular + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.(txt|html|xml)$" + } + })); + }} + > + Text + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.pdf$" + } + })); + }} + > + PDFs + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.json$" + } + })); + }} + > + JSON + + { + e.preventDefault(); + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*" + } + })); + }} + > + All Files + +
-
- {/* Import Options */} + {/* Import Options */} -
-
- - - Files will be imported as source data (images, text, etc.) rather than annotation tasks (JSON) - +
+
+ + + Files will be imported as source data (images, text, etc.) rather than annotation tasks (JSON) + +
+ + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + use_blob_urls: checked + } + })) + } + />
- - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - use_blob_urls: checked - } - })) - } - /> + +
+
+ +

+ Scan all subdirectories within the bucket or folder path +

+
+ + setFormState(prevState => ({ + ...prevState, + formData: { + ...prevState.formData, + recursive_scan: checked + } + })) + } + /> +
+ +
-
-
- -

- Scan all subdirectories within the bucket or folder path + {/* Right Column: Preview Files */} +

+
+ {filesPreview === null ? ( + // No API response yet +
+
+ +
+

No Preview Available

+

+ Configure your import settings and click "Load Preview" to see a sample of files that will be imported.

- - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - recursive_scan: checked - } - })) - } - /> -
-
- -
- - {/* Right Column: Preview Files */} -
-
- {filesPreview === null ? ( - // No API response yet -
-
- -
-

No Preview Available

-

- Configure your import settings and click "Load Preview" to see a sample of files that will be imported. -

-
- ) : filesPreview.length === 0 ? ( - // API returned empty array -
-
- + ) : filesPreview.length === 0 ? ( + // API returned empty array +
+
+ +
+

No Files Found

+

+ No files matching your current criteria were found. Try adjusting your filter settings and reload the preview. +

-

No Files Found

-

- No files matching your current criteria were found. Try adjusting your filter settings and reload the preview. -

-
- ) : ( - // Files available - display in a table format with fixed height and scrolling -
-
- {filesPreview.map((file, index) => ( -
-
{file.key}
-
- {formatDistanceToNow(new Date(file.last_modified), {addSuffix: true})} - - {formatSize(file.size)} + ) : ( + // Files available - display in a table format with fixed height and scrolling +
+
+ {filesPreview.map((file, index) => ( +
+
{file.key}
+
+ {formatDistanceToNow(new Date(file.last_modified), {addSuffix: true})} + + {formatSize(file.size)} +
-
- ))} + ))} +
-
- )} + )} +
-
- ); -}; + ); + }; const renderReviewStep = () => { @@ -969,57 +903,57 @@ export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass return (
+ }}> {/* Custom header with title, subtitle and close button */} -
-
-

+
+

+ {title} +

+ {true && ( +
- {title} -

- {true && ( -
- {"Import your data from cloud storage providers"} -
- )} -
-
+ )}
+
- +
-
{currentStep === 1 && ( @@ -1056,7 +989,7 @@ export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass Load Preview - + )} -// -// -// - -// -// -// -// ); -// }); - diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx index 51fbed96597b..9af25370e312 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx @@ -56,19 +56,19 @@ export const StorageSet = ({ title, target, rootClass, buttonLabel }) => { }} /> ), - footer: ( - <> - - Learn more - {" "} - about importing data and saving annotations to Cloud Storage! - - ), + // footer: ( + // <> + // + // Learn more + // {" "} + // about importing data and saving annotations to Cloud Storage! + // + // ), }); }, [project, fetchStorages, target, rootClass], From 15f9fc8aeb6b116a08e27572714d0dd14c401d20 Mon Sep 17 00:00:00 2001 From: Michael Malyuk Date: Thu, 17 Jul 2025 23:24:38 -0700 Subject: [PATCH 03/68] updating buttons --- .../src/pages/Settings/StorageSettings/StorageFormNew.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx index 4c4752a6f84c..e0a34c5571e6 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx @@ -976,19 +976,19 @@ export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass
{currentStep === 1 && ( - + - + )} {currentStep === 2 && ( - + - + )} From 75aeed0fe584e0c8852a628f65190401feb58c7a Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 21 Jul 2025 13:23:23 +0100 Subject: [PATCH 04/68] Components restructure --- .../labelstudio/src/app/ErrorBoundary.jsx | 1 + .../FormDetails/{S3.jsx => S3.tsx} | 36 +- .../{ProviderStep.jsx => ProviderStep.tsx} | 0 .../StorageSettings/StorageFormNew.jsx | 1032 ----------------- .../StorageFormNew/Steps/PreviewStep.tsx | 317 +++++ .../Steps/ProviderDetailsStep.tsx | 59 + .../Steps/ProviderSelectionStep.tsx | 152 +++ .../StorageFormNew/Steps/ReviewStep.tsx | 41 + .../StorageFormNew/Steps/Stepper.tsx | 71 ++ .../StorageFormNew/Steps/index.ts | 5 + .../StorageSettings/StorageFormNew/index.tsx | 475 ++++++++ .../Settings/StorageSettings/StorageSet.jsx | 15 - web/libs/ui/package.json | 9 +- web/libs/ui/src/index.ts | 2 + web/libs/ui/src/lib/label/label.tsx | 9 +- .../ui/src/shad/components/ui/radio-group.tsx | 42 + web/libs/ui/src/shadcn.ts | 7 +- web/package.json | 1 + web/yarn.lock | 7 +- 19 files changed, 1204 insertions(+), 1077 deletions(-) rename web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/{S3.jsx => S3.tsx} (88%) rename web/apps/labelstudio/src/pages/Settings/StorageSettings/Steps/{ProviderStep.jsx => ProviderStep.tsx} (100%) delete mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/PreviewStep.tsx create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderDetailsStep.tsx create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderSelectionStep.tsx create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ReviewStep.tsx create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/Stepper.tsx create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/index.ts create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/index.tsx create mode 100644 web/libs/ui/src/shad/components/ui/radio-group.tsx diff --git a/web/apps/labelstudio/src/app/ErrorBoundary.jsx b/web/apps/labelstudio/src/app/ErrorBoundary.jsx index be249054a30c..dd2de5c6b75b 100644 --- a/web/apps/labelstudio/src/app/ErrorBoundary.jsx +++ b/web/apps/labelstudio/src/app/ErrorBoundary.jsx @@ -19,6 +19,7 @@ export default class ErrorBoundary extends Component { } componentDidCatch(error, { componentStack }) { + console.error(error); // Capture the error in Sentry, so we can fix it directly // Don't make the users copy and paste the stacktrace, it's not actionable captureException(error, { diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.tsx similarity index 88% rename from web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.jsx rename to web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.tsx index 66ad9a64ed3a..6e0b1e4aff76 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.jsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.tsx @@ -1,18 +1,14 @@ -import { useCallback, useContext, useEffect, useRef, useState } from "react"; - -import { Label, Toggle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@humansignal/ui"; +import { Label, Toggle } from "@humansignal/ui"; import { Input } from "../../../../components/Form"; - export const S3 = ({ formData, setFormData, handleChange }) => { return (
- {/* Section 2: Bucket Configuration */}
- {
- { style={{ width: "100%" }} />
-
{/* Section 4: Credentials */}
- {
- { {/* Section 3: AWS Configuration */}
-
- {
- {
-
- { id="presign" name="presign" checked={formData.presign} - onCheckedChange={(checked) => - setFormData(prev => ({ ...prev, presign: checked })) - } + onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, presign: checked }))} />
@@ -146,15 +137,16 @@ export const S3 = ({ formData, setFormData, handleChange }) => { min={1} max={10080} value={formData.presign_ttl} - onChange={(e) => handleChange('presign_ttl', parseInt(e.target.value))} + onChange={(e) => handleChange("presign_ttl", Number.parseInt(e.target.value))} className="w-24" /> - handleChange('presign_ttl', value[0])} + onChange={(value) => handleChange("presign_ttl", value[0])} className="flex-1" />
@@ -163,7 +155,7 @@ export const S3 = ({ formData, setFormData, handleChange }) => {
); -} +}; S3.title = "AWS S3 Configuration"; S3.description = "Configure your AWS S3 connection with all required Label Studio settings"; diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/Steps/ProviderStep.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/Steps/ProviderStep.tsx similarity index 100% rename from web/apps/labelstudio/src/pages/Settings/StorageSettings/Steps/ProviderStep.jsx rename to web/apps/labelstudio/src/pages/Settings/StorageSettings/Steps/ProviderStep.tsx diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx deleted file mode 100644 index e0a34c5571e6..000000000000 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew.jsx +++ /dev/null @@ -1,1032 +0,0 @@ -import { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState, React } from "react"; -import { atom, useAtom } from 'jotai'; -import { cn } from "@humansignal/shad/utils"; -import { z } from 'zod'; - -// import { zodResolver } from '@hookform/resolvers/zod'; -import { Button } from "@humansignal/ui"; -import { InlineError } from "../../../components/Error/InlineError"; -import { Form, Input } from "../../../components/Form"; -import { Oneof } from "../../../components/Oneof/Oneof"; -import { ApiContext } from "../../../providers/ApiProvider"; -import { Block, Elem } from "../../../utils/bem"; -import { isDefined } from "../../../utils/helpers"; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@humansignal/shad/components/ui/card"; -import { Label, Toggle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@humansignal/ui"; -import { RadioGroup, RadioGroupItem } from "@humansignal/shad/components/ui/radio-group"; -import { IconCross, IconDocument, IconSearch } from "@humansignal/icons"; -import { S3 } from "./FormDetails/S3"; -import { formatDistanceToNow, format } from 'date-fns'; -import { Toast, ToastProvider, ToastViewport } from "@radix-ui/react-toast"; - - -const Stepper = ({ steps, currentStep }) => { - return ( -
-
- {/* Step circles and names */} -
- {steps.map((step, index) => ( -
-
index - ? "w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-200" // completed - : currentStep === index - ? "w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-200" // current - : "w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 bg-white border-2 border-slate-200 text-slate-400" // upcoming - )} - > - {currentStep > index ? ( - - ) : ( - index + 1 - )} -
- = index ? "text-primary font-sm" : "text-muted-foreground" - )}> - {step.title} - -
- ))} -
- - {/* Progress bar */} -
-
-
-
-
- ); -}; - - -// Step validation schemas -const basicInfoSchema = z.object({ - connectionName: z.string().min(1, "Storage title is required"), - provider: z.enum(["s3", "gcp", "azure"], { - required_error: "Please select a cloud provider" - }) -}); - -const storageDetailsSchema = z.object({ - bucketName: z.string().min(1, "Bucket Name is required"), - bucketPrefix: z.string().optional(), - region: z.string().optional(), - endpoint: z.string().optional(), - accessKeyId: z.string().min(1, "Access Key ID is required"), - secretKey: z.string().min(1, "Secret Access Key is required"), - sessionToken: z.string().optional(), - usePresignedUrls: z.boolean().default(false), -}); - -const previewDetailsSchema = z.object({ - fileFilterRegex: z.string().optional(), - importAllAsDataSources: z.boolean().default(false), - recursiveScan: z.boolean().default(false) - - // apiKey: z.string().min(8, "API key must be at least 8 characters"), - // secretKey: z.string().min(8, "Secret key must be at least 8 characters") -}); - -// Combine all schemas -const formSchema = basicInfoSchema.merge(storageDetailsSchema).merge(previewDetailsSchema); - -// {"project":"106076","title":"asfdsdaf","bucket":"asdfsadfsadf","prefix":"asdfasdfsadf","regex_filter":"","region_name":"asdfsadf","s3_endpoint":"asdfsadf","aws_access_key_id":"got ya, suspisadfasdfcious hacker!","aws_secret_access_key":"got ya, suspasdfasdficious hacker!","aws_session_token":"got sadfsadfya, suspicious hacker!","use_blob_urls":false,"recursive_scan":false,"presign":true,"presign_ttl":"15"} - - const formStateAtom = atom({ - currentStep: 0, - formData: { - project: 0, - - title: "", // Storage title - provider: "", - - bucket: "", // Bucket Name * - prefix: "", // Bucket Prefix - - region_name: "", // Region Name - s3_endpoint: "", // S3 Endpoint - - aws_access_key_id: "", // Access Key ID * - aws_secret_access_key: "", // Secret Access Key * - - aws_session_token: "", - - presign: false, // Use pre-signed URLs - - regex_filter: "", - - use_blob_urls: false, - recursive_scan: true, - }, - isComplete: false - }); - - -export const StorageFormNew = forwardRef(({ onSubmit, target, project, rootClass, storage, storageTypes, title, onClose = () => {} }, ref) => { - /**@type {import('react').RefObject} */ - const api = useContext(ApiContext); - const formRef = ref ?? useRef(); - const [type, setType] = useState(); - const [checking, setChecking] = useState(false); - const [connectionValid, setConnectionValid] = useState(null); - const [formFields, setFormFields] = useState([]); - - const [filesPreview, setFilesPreview] = useState(null); - const [ loadingFilesPreiview, setLoadingFilesPreiview ] = useState(false); - - const [nextPreviewToken, setNextPreviewToken] = useState(""); - - useEffect(() => { - console.log("type changed: " + type); - api - .callApi("storageForms", { - params: { - target, - type, - }, - }) - .then((formFields) => setFormFields(formFields ?? []));; - }, [type]); - - // Error state - const [errors, setErrors] = useState({}); - - const [ testingConnection, setTestingConnection ] = useState(false); - const [ connectionChecked, setConnectionChecked ] = useState(false); - - const [ formState, setFormState ] = useAtom(formStateAtom); - const { currentStep, formData } = formState; - - useEffect(() => { - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - project: project - } - })); - }, []); // Empty dependency array means this runs once on component mount - - - const setCurrentStep = (step) => { - setFormState((prevState) => ({ - ...prevState, - currentStep: step - })); - } - - const steps = [ - { title: "Select Provider", schema: basicInfoSchema }, - { title: "Configure Connection", schema: storageDetailsSchema }, - { title: "Preview & Import Settings" }, - { title: "Review & Confirm" } - ]; - - // Get current schema based on step - const currentSchema = steps[currentStep].schema || z.object({}); - - const handleChange = (e) => { - const { name, value } = e.target; - - setFormState(prev => ({ - ...prev, - formData: { - ...prev.formData, - [name]: value - } - })); - - // setFormData(prev => ({ - // ...prev, - // [name]: value - // })); - - // Clear error for this field when it changes - if (errors[name]) { - setErrors(prev => { - const newErrors = {...prev}; - delete newErrors[name]; - return newErrors; - }); - } - }; - - // Handle select changes - const handleSelectChange = (name, value) => { - setType(value); - - setFormState(prev => ({ - ...prev, - formData: { - ...prev.formData, - [name]: value - } - })); - - // setFormState(prevState => ({ - // ...prevState, - // formData: { - // ...prevState.formData, - // provider: "s3" - // } - // })); - - // setFormData(prev => ({ - // ...prev, - // [name]: value - // })); - - // Clear error for this field when it changes - // if (errors[name]) { - // setErrors(prev => { - // const newErrors = {...prev}; - // delete newErrors[name]; - // return newErrors; - // }); - // } - }; - - // Validate current step - const validateStep = () => { - // No validation for review step - if (currentStep === steps.length - 1) return true; - - const schema = steps[currentStep].schema; - try { - // Extract only the fields relevant to current step - const currentData = {}; - Object.keys(schema.shape).forEach(key => { - currentData[key] = formData[key]; - }); - - // Validate with Zod - schema.parse(currentData); - setErrors({}); - return true; - } catch (error) { - if (error instanceof z.ZodError) { - // Transform Zod errors into a field-error map - const newErrors = {}; - error.errors.forEach(err => { - // Remove the first part of the path (which is just the field name) - const fieldName = err.path[0]; - newErrors[fieldName] = err.message; - }); - setErrors(newErrors); - } - return false; - } - }; - - const nextStep = () => { - //if (validateStep()) { - if (currentStep < steps.length - 1) { - setCurrentStep(currentStep + 1); - } else { - // Submit the form - console.log("Form submitted with data:", formData); - // Here you would typically make an API call - alert("Form submitted successfully!"); - } - //} - }; - - // Go to previous step - const prevStep = () => { - if (currentStep > 0) { - setCurrentStep(currentStep - 1); - } - }; - - - // Format the file size - const formatSize = (bytes) => { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - }; - - - const testStorageConnection = useCallback(async () => { - // TODO should be real - setConnectionChecked(true); - }, [formState, target, storage]); - - const loadFilesPreview = useCallback(async () => { - setLoadingFilesPreiview(true); - // setChecking(true); - // setConnectionValid(null); - - // Get the form data directly from Atom state - const { formData } = formState; - - // TODO this needs to be dynamic - const type = 's3'; - - // Check if the form data is valid - // You might need to implement a validation logic here - const isFormValid = true; // Replace with actual validation check - - if (isFormValid) { - const body = { ...formData }; - - if (isDefined(storage?.id)) { - body.id = storage.id; - } - - // Use your API service directly instead of form.api - // You might need to adapt this to your actual API service - const response = await api.callApi("storageFiles", { - params: { - limit: 10, - target, - type, - }, - body, - }); - - // const fl = [ - // { "key": "hello/world.jpg", last_modified: "2024", size: 423748 }, - // { "key": "yo/hello/world.jpg", last_modified: "2025", size: 3748 } - // ] - - // for (let i = 0; i<10000; i++) { - // fl.push({ "key": "hello/world.jpg", last_modified: "2024", size: 423748 }); - // } - - // const response = { "files": fl }; - - setFilesPreview(response?.files); - setNextPreviewToken(response?.continuation_token); - setLoadingFilesPreiview(false); - // if (response?.$meta?.ok) setConnectionValid(true); - // else setConnectionValid(false); - } - - // setChecking(false); - }, [formState, target, storage]); - - // const validateConnection = useCallback(async () => { - // setChecking(true); - // setConnectionValid(null); - - // const form = formRef.current; - - // if (form && form.validateFields()) { - // const body = formData form.assembleFormData({ asJSON: true }); - // const type = form.getField("storage_type").value; - - // if (isDefined(storage?.id)) { - // body.id = storage.id; - // } - - // // we're using api provided by the form to be able to save - // // current api context and render inline erorrs properly - // const response = await form.api.callApi("storageFiles", { - // params: { - // target, - // type, - // }, - // body, - // }); - - // if (response?.$meta?.ok) setConnectionValid(true); - // else setConnectionValid(false); - // } - // setChecking(false); - // }, [formRef, target, type, storage]); - - // const RadioButtonContent = ({ value, label, description }) => { - // // The component will re-render when RadioButton re-renders - // // and will receive the current checked state - // return ( - // - // ); - // }; - - - const renderProviderSelectionStep = () => ( -
-
-

Choose your cloud storage provider

-

Select the cloud storage service where your data is stored

-
- -
- - -

This name will help you identify this connection in your project

-
- -
- - handleSelectChange("provider", value)}> - - - {/* GCP Option */} - - - {/* Azure Option */} - - - - {errors.provider && ( -

{errors.provider}

- )} -
-
- ); - - const action = useMemo(() => { - return storage ? "updateStorage" : "createStorage"; - }, [storage]); - - const renderProviderDetails = () => { - return ( -
-
-

AWS S3 Configuration

-

Configure your AWS S3 connection with all required Label Studio settings

-
- - - - {}} handleChange={handleChange} /> - - - - {errors.provider && ( -

{errors.provider}

- )} -
- ); - }; - - const renderPreviewStep = () => { - return ( -
-
-

Configure Import Settings & Preview Data

-

Set up filters for your files and preview what will be synchronized

-
- -
- {/* Left Column Header */} -

Import Configuration

- - {/* Right Column Header with Button */} -
-

Files Preview

-
- - {/* Left Column: Configuration */} -
-
- -
- {/* File Filter Section */} -
- -
- - -
- Common filters: - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\.(jpe?g|png|gif)$" - } - })); - }} - > - Images - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.(mp4|avi|mov|wmv|webm)$" - } - })); - }} - > - Videos - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.(mp3|wav|ogg|flac)$" - } - })); - }} - > - Audio - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.(csv|tsv)$" - } - })); - }} - > - Tabular - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.(txt|html|xml)$" - } - })); - }} - > - Text - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.pdf$" - } - })); - }} - > - PDFs - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.json$" - } - })); - }} - > - JSON - - { - e.preventDefault(); - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*" - } - })); - }} - > - All Files - -
-
-
- - {/* Import Options */} - -
-
- - - Files will be imported as source data (images, text, etc.) rather than annotation tasks (JSON) - -
- - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - use_blob_urls: checked - } - })) - } - /> -
- -
-
- -

- Scan all subdirectories within the bucket or folder path -

-
- - setFormState(prevState => ({ - ...prevState, - formData: { - ...prevState.formData, - recursive_scan: checked - } - })) - } - /> -
-
-
-
- - {/* Right Column: Preview Files */} -
-
- {filesPreview === null ? ( - // No API response yet -
-
- -
-

No Preview Available

-

- Configure your import settings and click "Load Preview" to see a sample of files that will be imported. -

-
- ) : filesPreview.length === 0 ? ( - // API returned empty array -
-
- -
-

No Files Found

-

- No files matching your current criteria were found. Try adjusting your filter settings and reload the preview. -

-
- ) : ( - // Files available - display in a table format with fixed height and scrolling -
-
- {filesPreview.map((file, index) => ( -
-
{file.key}
-
- {formatDistanceToNow(new Date(file.last_modified), {addSuffix: true})} - - {formatSize(file.size)} -
-
- ))} -
-
- )} -
-
-
-
- ); - }; - - - const renderReviewStep = () => { - return ( -
-
-

Ready to Connect

-

- Review your connection details and confirm to start importing -

-
- - {/* Connection Details Section */} -
-
-

Provider

-

Amazon S3

-
- -
-

Bucket

-

asdfasdf

-
- -
-

Files to import

-

1247 files

-
- -
-

Total size

-

2.3 GB

-
-
- - {/* Import Process Section */} -
-

Import Process

-

- Files will be imported in the background. You can continue working while the import is in progress. -

-
-
- ); - }; - - const renderStepContent = () => { - switch (currentStep) { - case 0: - return renderProviderSelectionStep(); - case 1: - return renderProviderDetails(); - case 2: - return renderPreviewStep(); - case 3: - return renderReviewStep(); - default: - return null; - } - }; - - const handleClose = () => { - onClose(); - modalInstance?.close(); - }; - - return ( -
- - {/* Custom header with title, subtitle and close button */} -
-
-

- {title} -

- {true && ( -
- {"Import your data from cloud storage providers"} -
- )} -
-
- - - - -
- { renderStepContent() } -
- -
- - -
- {currentStep === 1 && ( - - - - )} - - {currentStep === 2 && ( - - - - - )} - - -
- -
-
- ); - - return ( - - - - - Add Cloud Storage Connection - - - - { renderStepContent() } - - - - - - - - - ); - -}); diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/PreviewStep.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/PreviewStep.tsx new file mode 100644 index 000000000000..59d1070c7ccd --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/PreviewStep.tsx @@ -0,0 +1,317 @@ +import { Label, Toggle } from "@humansignal/ui"; +import { Form, Input } from "apps/labelstudio/src/components/Form"; +import { IconDocument, IconSearch } from "@humansignal/icons"; +import { formatDistanceToNow } from "date-fns"; + +interface PreviewStepProps { + formData: any; + formState: any; + setFormState: (updater: (prevState: any) => any) => void; + handleChange: (e: React.ChangeEvent) => void; + action: string; + target: string; + type: string; + project: string; + storage?: any; + onSubmit: () => void; + formRef: React.RefObject; + filesPreview: any[] | null; + formatSize: (bytes: number) => string; +} + +export const PreviewStep = ({ + formData, + formState, + setFormState, + handleChange, + action, + target, + type, + project, + storage, + onSubmit, + formRef, + filesPreview, + formatSize, +}: PreviewStepProps) => { + return ( +
+
+

Configure Import Settings & Preview Data

+

Set up filters for your files and preview what will be synchronized

+
+ +
+ {/* Left Column Header */} +

Import Configuration

+ + {/* Right Column Header with Button */} +
+

Files Preview

+
+ + {/* Left Column: Configuration */} +
+
+
+ {/* File Filter Section */} +
+ +
+ + +
+ Common filters: + { + e.preventDefault(); + setFormState((prevState) => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*.(jpe?g|png|gif)$", + }, + })); + }} + > + Images + + { + e.preventDefault(); + setFormState((prevState) => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.(mp4|avi|mov|wmv|webm)$", + }, + })); + }} + > + Videos + + { + e.preventDefault(); + setFormState((prevState) => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.(mp3|wav|ogg|flac)$", + }, + })); + }} + > + Audio + + { + e.preventDefault(); + setFormState((prevState) => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.(csv|tsv)$", + }, + })); + }} + > + Tabular + + { + e.preventDefault(); + setFormState((prevState) => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.(txt|html|xml)$", + }, + })); + }} + > + Text + + { + e.preventDefault(); + setFormState((prevState) => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.pdf$", + }, + })); + }} + > + PDFs + + { + e.preventDefault(); + setFormState((prevState) => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*\\.json$", + }, + })); + }} + > + JSON + + { + e.preventDefault(); + setFormState((prevState) => ({ + ...prevState, + formData: { + ...prevState.formData, + regex_filter: ".*", + }, + })); + }} + > + All Files + +
+
+
+ + {/* Import Options */} +
+
+ + + Files will be imported as source data (images, text, etc.) rather than annotation tasks (JSON) + +
+ + setFormState((prevState) => ({ + ...prevState, + formData: { + ...prevState.formData, + use_blob_urls: checked, + }, + })) + } + /> +
+ +
+
+ +

Scan all subdirectories within the bucket or folder path

+
+ + setFormState((prevState) => ({ + ...prevState, + formData: { + ...prevState.formData, + recursive_scan: checked, + }, + })) + } + /> +
+
+
+
+ + {/* Right Column: Preview Files */} +
+
+ {filesPreview === null ? ( + // No API response yet +
+
+ +
+

No Preview Available

+

+ Configure your import settings and click "Load Preview" to see a sample of files that will be + imported. +

+
+ ) : filesPreview.length === 0 ? ( + // API returned empty array +
+
+ +
+

No Files Found

+

+ No files matching your current criteria were found. Try adjusting your filter settings and reload + the preview. +

+
+ ) : ( + // Files available - display in a table format with fixed height and scrolling +
+
+ {filesPreview.map((file, index) => ( +
+
{file.key}
+
+ {formatDistanceToNow(new Date(file.last_modified), { addSuffix: true })} + + {formatSize(file.size)} +
+
+ ))} +
+
+ )} +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderDetailsStep.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderDetailsStep.tsx new file mode 100644 index 000000000000..98b4e62dcd73 --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderDetailsStep.tsx @@ -0,0 +1,59 @@ +import { Form, Input } from "apps/labelstudio/src/components/Form"; +import { InlineError } from "apps/labelstudio/src/components/Error/InlineError"; +import { S3 } from "apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3"; + +interface ProviderDetailsStepProps { + formData: any; + errors: { + provider?: string; + }; + handleChange: (e: React.ChangeEvent) => void; + action: string; + target: string; + type: string; + project: string; + storage?: any; + onSubmit: () => void; + formRef: React.RefObject; +} + +export const ProviderDetailsStep = ({ + formData, + errors, + handleChange, + action, + target, + type, + project, + storage, + onSubmit, + formRef, +}: ProviderDetailsStepProps) => { + return ( +
+
+

AWS S3 Configuration

+

+ Configure your AWS S3 connection with all required Label Studio settings +

+
+ +
+ + {}} handleChange={handleChange} /> + + + + {errors.provider &&

{errors.provider}

} +
+ ); +}; \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderSelectionStep.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderSelectionStep.tsx new file mode 100644 index 000000000000..cd108e072c0d --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderSelectionStep.tsx @@ -0,0 +1,152 @@ +import { Label } from "@humansignal/ui"; +import { Input } from "apps/labelstudio/src/components/Form"; +import { RadioGroup, RadioGroupItem } from "@humansignal/ui"; + +interface ProviderSelectionStepProps { + formData: { + title: string; + provider: string; + }; + errors: { + provider?: string; + }; + handleChange: (e: React.ChangeEvent) => void; + handleSelectChange: (name: string, value: string) => void; + setFormState: (updater: (prevState: any) => any) => void; +} + +export const ProviderSelectionStep = ({ + formData, + errors, + handleChange, + handleSelectChange, + setFormState, +}: ProviderSelectionStepProps) => { + return ( +
+
+

Choose your cloud storage provider

+

Select the cloud storage service where your data is stored

+
+ +
+ + +

This name will help you identify this connection in your project

+
+ +
+ + handleSelectChange("provider", value)} + > + + + {/* GCP Option */} + + + {/* Azure Option */} + + + + {errors.provider &&

{errors.provider}

} +
+
+ ); +}; diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ReviewStep.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ReviewStep.tsx new file mode 100644 index 000000000000..9e7965fc9290 --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ReviewStep.tsx @@ -0,0 +1,41 @@ +export const ReviewStep = () => { + return ( +
+
+

Ready to Connect

+

Review your connection details and confirm to start importing

+
+ + {/* Connection Details Section */} +
+
+

Provider

+

Amazon S3

+
+ +
+

Bucket

+

asdfasdf

+
+ +
+

Files to import

+

1247 files

+
+ +
+

Total size

+

2.3 GB

+
+
+ + {/* Import Process Section */} +
+

Import Process

+

+ Files will be imported in the background. You can continue working while the import is in progress. +

+
+
+ ); +}; \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/Stepper.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/Stepper.tsx new file mode 100644 index 000000000000..047206e05ffd --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/Stepper.tsx @@ -0,0 +1,71 @@ +import { cn } from "@humansignal/shad/utils"; + +interface StepperProps { + steps: { title: string }[]; + currentStep: number; +} + +export const Stepper = ({ steps, currentStep }: StepperProps) => { + return ( +
+
+ {/* Step circles and names */} +
+ {steps.map((step, index) => ( +
+
index + ? "w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-200" // completed + : currentStep === index + ? "w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-200" // current + : "w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 bg-white border-2 border-slate-200 text-slate-400", // upcoming + )} + > + {currentStep > index ? ( + + + + ) : ( + index + 1 + )} +
+ = index ? "text-primary font-sm" : "text-muted-foreground")}> + {step.title} + +
+ ))} +
+ + {/* Progress bar */} +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/index.ts b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/index.ts new file mode 100644 index 000000000000..6e3ec97637e8 --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/index.ts @@ -0,0 +1,5 @@ +export { Stepper } from "./Stepper"; +export { ProviderSelectionStep } from "./ProviderSelectionStep"; +export { ProviderDetailsStep } from "./ProviderDetailsStep"; +export { PreviewStep } from "./PreviewStep"; +export { ReviewStep } from "./ReviewStep"; \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/index.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/index.tsx new file mode 100644 index 000000000000..38e946f9ff2e --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/index.tsx @@ -0,0 +1,475 @@ +import { + type ChangeEventHandler, + forwardRef, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { atom, useAtom } from "jotai"; +import { cn } from "@humansignal/shad/utils"; +import { z } from "zod"; + +import { Button } from "@humansignal/ui"; +import { InlineError } from "apps/labelstudio/src/components/Error/InlineError"; +import { Form, Input } from "apps/labelstudio/src/components/Form"; +import { ApiContext } from "apps/labelstudio/src/providers/ApiProvider"; +import { isDefined } from "apps/labelstudio/src/utils/helpers"; +import { Label, Toggle } from "@humansignal/ui"; +import { RadioGroup, RadioGroupItem } from "@humansignal/shad/components/ui/radio-group"; +import { IconCross, IconDocument, IconSearch } from "@humansignal/icons"; +import { S3 } from "../../FormDetails/S3"; +import { formatDistanceToNow } from "date-fns"; +import { useModalControls } from "apps/labelstudio/src/components/Modal/ModalPopup"; +import { + Stepper, + ProviderSelectionStep, + ProviderDetailsStep, + PreviewStep, + ReviewStep, +} from "./Steps"; + + + +// Step validation schemas +const basicInfoSchema = z.object({ + connectionName: z.string().min(1, "Storage title is required"), + provider: z.enum(["s3", "gcp", "azure"], { + required_error: "Please select a cloud provider", + }), +}); + +const storageDetailsSchema = z.object({ + bucketName: z.string().min(1, "Bucket Name is required"), + bucketPrefix: z.string().optional(), + region: z.string().optional(), + endpoint: z.string().optional(), + accessKeyId: z.string().min(1, "Access Key ID is required"), + secretKey: z.string().min(1, "Secret Access Key is required"), + sessionToken: z.string().optional(), + usePresignedUrls: z.boolean().default(false), +}); + +// Combine all schemas +const formStateAtom = atom({ + currentStep: 0, + formData: { + project: 0, + + title: "", // Storage title + provider: "", + + bucket: "", // Bucket Name * + prefix: "", // Bucket Prefix + + region_name: "", // Region Name + s3_endpoint: "", // S3 Endpoint + + aws_access_key_id: "", // Access Key ID * + aws_secret_access_key: "", // Secret Access Key * + + aws_session_token: "", + + presign: false, // Use pre-signed URLs + + regex_filter: "", + + use_blob_urls: false, + recursive_scan: true, + }, + isComplete: false, +}); + +export const StorageFormNew = forwardRef< + unknown, + { + onSubmit: () => void; + } +>(({ onSubmit, target, project, storage, title, onClose = () => {} }, ref) => { + /**@type {import('react').RefObject
} */ + const api = useContext(ApiContext); + const modal = useModalControls(); + const formRef = ref ?? useRef(); + const [type, setType] = useState(); + + const [filesPreview, setFilesPreview] = useState(null); + const [loadingFilesPreiview, setLoadingFilesPreiview] = useState(false); + + const [nextPreviewToken, setNextPreviewToken] = useState(""); + + // Error state + const [errors, setErrors] = useState>({}); + + const [testingConnection, setTestingConnection] = useState(false); + const [connectionChecked, setConnectionChecked] = useState(false); + + const [formState, setFormState] = useAtom(formStateAtom); + const { currentStep, formData } = formState; + + useEffect(() => { + setFormState((prevState) => ({ + ...prevState, + formData: { + ...prevState.formData, + project: project, + }, + })); + }, []); // Empty dependency array means this runs once on component mount + + const setCurrentStep = (step: number) => { + setFormState((prevState) => ({ + ...prevState, + currentStep: step, + })); + }; + + const steps = [ + { title: "Select Provider", schema: basicInfoSchema }, + { title: "Configure Connection", schema: storageDetailsSchema }, + { title: "Preview & Import Settings" }, + { title: "Review & Confirm" }, + ]; + + // Get current schema based on step + + const handleChange: ChangeEventHandler = (e) => { + const { name, value } = e.target as HTMLInputElement; + + setFormState((prev) => ({ + ...prev, + formData: { + ...prev.formData, + [name]: value, + }, + })); + + // setFormData(prev => ({ + // ...prev, + // [name]: value + // })); + + // Clear error for this field when it changes + if (errors[name]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[name]; + return newErrors; + }); + } + }; + + // Handle select changes + const handleSelectChange = (name: string, value: string) => { + setType(value); + + setFormState((prev) => ({ + ...prev, + formData: { + ...prev.formData, + [name]: value, + }, + })); + + // Clear error for this field when it changes + // if (errors[name]) { + // setErrors(prev => { + // const newErrors = {...prev}; + // delete newErrors[name]; + // return newErrors; + // }); + // } + }; + + // Validate current step + + const nextStep = () => { + //if (validateStep()) { + if (currentStep < steps.length - 1) { + setCurrentStep(currentStep + 1); + } else { + // Submit the form + console.log("Form submitted with data:", formData); + // Here you would typically make an API call + alert("Form submitted successfully!"); + } + //} + }; + + // Go to previous step + const prevStep = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }; + + // Format the file size + const formatSize = (bytes: number) => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + }; + + const testStorageConnection = useCallback(async () => { + // TODO should be real + setConnectionChecked(true); + }, [formState, target, storage]); + + const loadFilesPreview = useCallback(async () => { + setLoadingFilesPreiview(true); + // setChecking(true); + // setConnectionValid(null); + + // Get the form data directly from Atom state + const { formData } = formState; + + // TODO this needs to be dynamic + const type = "s3"; + + // Check if the form data is valid + // You might need to implement a validation logic here + const isFormValid = true; // Replace with actual validation check + + if (isFormValid) { + const body = { ...formData }; + + if (isDefined(storage?.id)) { + body.id = storage.id; + } + + // Use your API service directly instead of form.api + // You might need to adapt this to your actual API service + const response = await api.callApi("storageFiles", { + params: { + limit: 10, + target, + type, + }, + body, + }); + + // const fl = [ + // { "key": "hello/world.jpg", last_modified: "2024", size: 423748 }, + // { "key": "yo/hello/world.jpg", last_modified: "2025", size: 3748 } + // ] + + // for (let i = 0; i<10000; i++) { + // fl.push({ "key": "hello/world.jpg", last_modified: "2024", size: 423748 }); + // } + + // const response = { "files": fl }; + + setFilesPreview(response?.files); + setNextPreviewToken(response?.continuation_token); + setLoadingFilesPreiview(false); + // if (response?.$meta?.ok) setConnectionValid(true); + // else setConnectionValid(false); + } + + // setChecking(false); + }, [formState, target, storage]); + + // const validateConnection = useCallback(async () => { + // setChecking(true); + // setConnectionValid(null); + + // const form = formRef.current; + + // if (form && form.validateFields()) { + // const body = formData form.assembleFormData({ asJSON: true }); + // const type = form.getField("storage_type").value; + + // if (isDefined(storage?.id)) { + // body.id = storage.id; + // } + + // // we're using api provided by the form to be able to save + // // current api context and render inline erorrs properly + // const response = await form.api.callApi("storageFiles", { + // params: { + // target, + // type, + // }, + // body, + // }); + + // if (response?.$meta?.ok) setConnectionValid(true); + // else setConnectionValid(false); + // } + // setChecking(false); + // }, [formRef, target, type, storage]); + + // const RadioButtonContent = ({ value, label, description }) => { + // // The component will re-render when RadioButton re-renders + // // and will receive the current checked state + // return ( + // + // ); + // }; + + + + const action = useMemo(() => { + return storage ? "updateStorage" : "createStorage"; + }, [storage]); + + + + + + const renderStepContent = () => { + switch (currentStep) { + case 0: + return ( + + ); + case 1: + return ( + + ); + case 2: + return ( + + ); + case 3: + return ; + default: + return null; + } + }; + + const handleClose = () => { + onClose(); + modal?.hide(); + }; + + return ( +
+ {/* Custom header with title, subtitle and close button */} +
+
+

+ {title} +

+ {true && ( +
+ {"Import your data from cloud storage providers"} +
+ )} +
+
+ + + +
+ {renderStepContent()} +
+ +
+ + +
+ {currentStep === 1 && ( + + )} + + {currentStep === 2 && ( + + )} + + +
+
+
+ ); +}); diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx index 9af25370e312..ab70d13419a6 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx @@ -6,7 +6,6 @@ import { Spinner } from "../../../components/Spinner/Spinner"; import { ApiContext } from "../../../providers/ApiProvider"; import { projectAtom } from "../../../providers/ProjectProvider"; import { StorageCard } from "./StorageCard"; -import { StorageForm } from "./StorageForm"; import { StorageFormNew } from "./StorageFormNew"; import { useAtomValue } from "jotai"; import { useStorageCard } from "./hooks/useStorageCard"; @@ -51,24 +50,10 @@ export const StorageSet = ({ title, target, rootClass, buttonLabel }) => { rootClass={rootClass} storageTypes={storageTypes} onSubmit={async () => { - await fetchStorages(); modalRef.close(); }} /> ), - // footer: ( - // <> - // - // Learn more - // {" "} - // about importing data and saving annotations to Cloud Storage! - // - // ), }); }, [project, fetchStorages, target, rootClass], diff --git a/web/libs/ui/package.json b/web/libs/ui/package.json index 91f338bb4ff4..01edc6039b65 100644 --- a/web/libs/ui/package.json +++ b/web/libs/ui/package.json @@ -4,5 +4,12 @@ "license": "MIT", "private": true, "main": "src/index.ts", - "files": ["src/tailwind.css", "src/shad/components", "src/ui/assets"] + "files": [ + "src/tailwind.css", + "src/shad/components", + "src/ui/assets" + ], + "dependencies": { + "@radix-ui/react-radio-group": "^1.3.7" + } } diff --git a/web/libs/ui/src/index.ts b/web/libs/ui/src/index.ts index 7a906b8517a5..3ad5e721741a 100644 --- a/web/libs/ui/src/index.ts +++ b/web/libs/ui/src/index.ts @@ -31,3 +31,5 @@ export * from "./utils/utils"; // TODO: Remove when DIA-2142 and DIA-2175 are delivered export * from "./shadcn"; export * from "./utils/utils"; + +export { RadioGroup, RadioGroupItem } from "./shad/components/ui/radio-group"; diff --git a/web/libs/ui/src/lib/label/label.tsx b/web/libs/ui/src/lib/label/label.tsx index 1ccce9bed380..948e7b9335fa 100644 --- a/web/libs/ui/src/lib/label/label.tsx +++ b/web/libs/ui/src/lib/label/label.tsx @@ -2,7 +2,7 @@ import { forwardRef, type PropsWithChildren } from "react"; import clsx from "clsx"; import styles from "./label.module.scss"; type LabelProps = PropsWithChildren<{ - text: string; + text?: string; required?: boolean; placement?: "right" | "left"; description?: string; @@ -11,6 +11,7 @@ type LabelProps = PropsWithChildren<{ style?: any; simple?: boolean; flat?: boolean; + htmlFor?: string; }>; export const Label = forwardRef( @@ -25,6 +26,7 @@ export const Label = forwardRef( style: inlineStyle, simple, flat, + htmlFor, }: LabelProps) => { const TagName = simple ? "div" : "label"; @@ -32,6 +34,7 @@ export const Label = forwardRef( - {text} + {text ?? children} {description && {description}} - {children} + {text && {children}} ); }, diff --git a/web/libs/ui/src/shad/components/ui/radio-group.tsx b/web/libs/ui/src/shad/components/ui/radio-group.tsx new file mode 100644 index 000000000000..04d57a34dcad --- /dev/null +++ b/web/libs/ui/src/shad/components/ui/radio-group.tsx @@ -0,0 +1,42 @@ +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { Circle } from "lucide-react" + +import { cn } from "@humansignal/shad/utils" + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/web/libs/ui/src/shadcn.ts b/web/libs/ui/src/shadcn.ts index deee408ad46a..8aac3e13133e 100644 --- a/web/libs/ui/src/shadcn.ts +++ b/web/libs/ui/src/shadcn.ts @@ -1,4 +1,5 @@ /// Raw shadcn components for re-export -export { Badge, type BadgeProps, badgeVariants } from "./shad/components/ui/badge"; -export { Skeleton } from "./shad/components/ui/skeleton"; -export { cn } from "./utils/utils"; +export * from "./shad/components/ui/badge"; +export * from "./shad/components/ui/skeleton"; +export * from "./shad/components/ui/radio-group"; +export * from "./utils/utils"; diff --git a/web/package.json b/web/package.json index 0acde1df097a..e3bab7389d0b 100644 --- a/web/package.json +++ b/web/package.json @@ -86,6 +86,7 @@ "lodash.get": "^4.4.0", "lodash.ismatch": "^4.4.0", "lodash.throttle": "^4.1.1", + "lucide-react": "^0.525.0", "mobx": "^5.15.4", "mobx-react": "^6", "mobx-state-tree": "^3.16.0", diff --git a/web/yarn.lock b/web/yarn.lock index efcdd0a58663..15b72141bdb1 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -13225,6 +13225,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lucide-react@^0.525.0: + version "0.525.0" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.525.0.tgz#5f7bcecd65e4f9b2b5b6b5d295e3376df032d5e3" + integrity sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ== + luxon@^3.2.1: version "3.6.1" resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.6.1.tgz#d283ffc4c0076cb0db7885ec6da1c49ba97e47b0" @@ -16589,7 +16594,7 @@ sass@^1.85.0: optionalDependencies: "@parcel/watcher" "^2.4.1" -sax@>=0.6.0, sax@^1.2.4, sax@~1.3.0: +sax@^1.2.4, sax@~1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/sax/-/sax-1.3.0.tgz#a5dbe77db3be05c9d1ee7785dbd3ea9de51593d0" integrity sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA== From 22f82c95b7fd66c375ffda896978a53a1382eb0e Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 21 Jul 2025 16:57:52 +0100 Subject: [PATCH 05/68] Form setup --- .../components/Form/Elements/Label/Label.jsx | 2 +- .../components/Form/Elements/Label/Label.scss | 2 +- .../src/components/Modal/Modal.jsx | 3 + .../StorageSettings/FormDetails/Azure.tsx | 246 ++++++++++++ .../StorageSettings/FormDetails/GCP.tsx | 254 +++++++++++++ .../FormDetails/LocalFiles.tsx | 188 ++++++++++ .../StorageSettings/FormDetails/Redis.tsx | 251 +++++++++++++ .../StorageSettings/FormDetails/S3.tsx | 255 ++++++++++--- .../Steps/ProviderDetailsStep.tsx | 97 ++++- .../Steps/ProviderSelectionStep.tsx | 297 +++++++++------ .../StorageSettings/StorageFormNew/index.tsx | 349 +++++++++++++----- .../Settings/StorageSettings/StorageSet.jsx | 1 + .../StorageSettings/hooks/useStorageCard.tsx | 2 +- web/libs/ui/src/lib/label/label.module.scss | 2 +- 14 files changed, 1685 insertions(+), 264 deletions(-) create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/Azure.tsx create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/GCP.tsx create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/LocalFiles.tsx create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/Redis.tsx diff --git a/web/apps/labelstudio/src/components/Form/Elements/Label/Label.jsx b/web/apps/labelstudio/src/components/Form/Elements/Label/Label.jsx index e80d10376b80..57abe068c077 100644 --- a/web/apps/labelstudio/src/components/Form/Elements/Label/Label.jsx +++ b/web/apps/labelstudio/src/components/Form/Elements/Label/Label.jsx @@ -47,7 +47,7 @@ const Label = ({
- {text} + {text} {tooltip && (
{tooltipIcon ? tooltipIcon : } diff --git a/web/apps/labelstudio/src/components/Form/Elements/Label/Label.scss b/web/apps/labelstudio/src/components/Form/Elements/Label/Label.scss index bf60737eb870..333430c37051 100644 --- a/web/apps/labelstudio/src/components/Form/Elements/Label/Label.scss +++ b/web/apps/labelstudio/src/components/Form/Elements/Label/Label.scss @@ -89,7 +89,7 @@ width: 100%; } - &[data-required] &__text::after { + &[data-required] &__text span::after { content: "Required"; font-size: 0.825rem; color: var(--color-neutral-content-subtler); diff --git a/web/apps/labelstudio/src/components/Modal/Modal.jsx b/web/apps/labelstudio/src/components/Modal/Modal.jsx index a3c8bf7593fb..7dd60ddd6669 100644 --- a/web/apps/labelstudio/src/components/Modal/Modal.jsx +++ b/web/apps/labelstudio/src/components/Modal/Modal.jsx @@ -9,6 +9,8 @@ import { Button } from "@humansignal/ui"; import { Space } from "../Space/Space"; import { Modal } from "./ModalPopup"; import { ToastProvider, ToastViewport } from "@humansignal/ui"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "../../utils/query-client"; const standaloneModal = (props) => { const modalRef = createRef(); @@ -33,6 +35,7 @@ const standaloneModal = (props) => { , , , + , ] } > diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/Azure.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/Azure.tsx new file mode 100644 index 000000000000..91746d3cb76b --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/Azure.tsx @@ -0,0 +1,246 @@ +import { Label, Toggle } from "@humansignal/ui"; +import { atom, useAtom } from "jotai"; +import { z } from "zod"; +import { useCallback, useEffect } from "react"; + +// Azure Form validation schema +const azureFormSchema = z.object({ + container: z.string().min(1, "Container name is required"), + prefix: z.string().optional(), + account_name: z.string().min(1, "Storage Account Name is required"), + account_key: z.string().min(1, "Storage Account Key is required"), + sas_token: z.string().optional(), + regex_filter: z.string().optional(), + presign: z.boolean().default(false), + presign_ttl: z.number().min(1).max(10080).optional(), +}); + +type AzureFormData = z.infer; + +// Azure Form state atom +const azureFormAtom = atom({ + container: "", + prefix: "", + account_name: "", + account_key: "", + sas_token: "", + regex_filter: "", + presign: false, + presign_ttl: 60, +}); + +// Azure Form errors atom +const azureFormErrorsAtom = atom>({}); + +interface AzureProps { + formData?: any; + setFormData?: (updater: (prev: any) => any) => void; + handleChange?: (e: React.ChangeEvent) => void; + initialData?: Partial; + onChange?: (data: AzureFormData) => void; + onValidationChange?: (isValid: boolean) => void; +} + +export const Azure = ({ initialData = {}, onChange, onValidationChange }: AzureProps) => { + const [formData, setFormData] = useAtom(azureFormAtom); + const [errors, setErrors] = useAtom(azureFormErrorsAtom); + + // Initialize form data with initial values + useEffect(() => { + if (initialData && Object.keys(initialData).length > 0) { + setFormData((prev) => ({ ...prev, ...initialData })); + } + }, [initialData, setFormData]); + + // Validate form data + const validateForm = useCallback((data: AzureFormData) => { + try { + azureFormSchema.parse(data); + setErrors({}); + onValidationChange?.(true); + return true; + } catch (error) { + if (error instanceof z.ZodError) { + const newErrors: Record = {}; + error.errors.forEach((err) => { + if (err.path[0]) { + newErrors[err.path[0] as string] = err.message; + } + }); + setErrors(newErrors); + onValidationChange?.(false); + } + return false; + } + }, [setErrors, onValidationChange]); + + // Handle form data changes + const handleChange = useCallback((name: string, value: any) => { + const newFormData = { ...formData, [name]: value }; + setFormData(newFormData); + validateForm(newFormData); + onChange?.(newFormData); + }, [formData, setFormData, validateForm, onChange]); + + // Handle input change events + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const { name, value, type } = e.target; + const parsedValue = type === 'number' ? Number(value) : value; + handleChange(name, parsedValue); + }, [handleChange]); + + // Handle toggle change + const handleToggleChange = useCallback((name: string, checked: boolean) => { + handleChange(name, checked); + }, [handleChange]); + + return ( +
+ {/* Section 1: Container Configuration */} +
+
+ + + {errors.container &&

{errors.container}

} +
+ +
+ + + {errors.account_name &&

{errors.account_name}

} +
+
+ + {/* Section 2: Credentials */} +
+
+ + + {errors.account_key &&

{errors.account_key}

} +
+ +
+ + +

Optional Shared Access Signature token

+
+
+ + {/* Section 3: Configuration */} +
+ + +

Optional prefix to limit files to a specific folder

+
+ + {/* Section 4: File Filter */} +
+ + +

Optional regex pattern to filter files (e.g., .*\.(jpg|jpeg|png|gif)$ for images only)

+
+ + {/* Section 4: Additional Settings */} +
+
+
+
+ +

Generate pre-signed URLs for secure file access

+
+ handleToggleChange("presign", e.target.checked)} + /> +
+
+ + {formData.presign && ( +
+ +

Minutes until pre-signed URLs expire (1-10080 minutes)

+
+ + handleChange("presign_ttl", Number(e.target.value))} + className="flex-1" + /> +
+ {errors.presign_ttl &&

{errors.presign_ttl}

} +
+ )} +
+
+ ); +}; + +Azure.title = "Azure Blob Storage Configuration"; +Azure.description = "Configure your Azure Blob Storage connection with all required Label Studio settings"; \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/GCP.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/GCP.tsx new file mode 100644 index 000000000000..7d3c7e564db4 --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/GCP.tsx @@ -0,0 +1,254 @@ +import { Label, Toggle } from "@humansignal/ui"; +import { atom, useAtom } from "jotai"; +import { z } from "zod"; +import { useCallback, useEffect } from "react"; +import Input from "apps/labelstudio/src/components/Form/Elements/Input/Input"; + +// GCP Form validation schema +const gcpFormSchema = z.object({ + bucket: z.string().min(1, "Bucket name is required"), + prefix: z.string().optional(), + google_application_credentials: z.string().min(1, "Service Account Key is required"), + project_id: z.string().optional(), + regex_filter: z.string().optional(), + presign: z.boolean().default(false), + presign_ttl: z.number().min(1).max(10080).optional(), +}); + +type GCPFormData = z.infer; + +// GCP Form state atom +const gcpFormAtom = atom({ + bucket: "", + prefix: "", + google_application_credentials: "", + project_id: "", + regex_filter: "", + presign: false, + presign_ttl: 60, +}); + +// GCP Form errors atom +const gcpFormErrorsAtom = atom>({}); + +interface GCPProps { + formData?: any; + setFormData?: (updater: (prev: any) => any) => void; + handleChange?: (e: React.ChangeEvent) => void; + handleProviderFieldChange?: (name: string, value: any) => void; + initialData?: Partial; + onChange?: (data: GCPFormData) => void; + onValidationChange?: (isValid: boolean) => void; + validationErrors?: Record; +} + +export const GCP = ({ initialData = {}, onChange, onValidationChange, validationErrors = {}, handleProviderFieldChange }: GCPProps) => { + const [formData, setFormData] = useAtom(gcpFormAtom); + const [localErrors, setLocalErrors] = useAtom(gcpFormErrorsAtom); + + // Initialize form data with initial values + useEffect(() => { + if (initialData && Object.keys(initialData).length > 0) { + setFormData((prev) => ({ ...prev, ...initialData })); + } + }, [initialData, setFormData]); + + // Validate form data + const validateForm = useCallback((data: GCPFormData) => { + try { + gcpFormSchema.parse(data); + setLocalErrors({}); + onValidationChange?.(true); + return true; + } catch (error) { + if (error instanceof z.ZodError) { + const newErrors: Record = {}; + error.errors.forEach((err) => { + if (err.path[0]) { + newErrors[err.path[0] as string] = err.message; + } + }); + setLocalErrors(newErrors); + onValidationChange?.(false); + } + return false; + } + }, [setLocalErrors, onValidationChange]); + + // Handle form data changes + const handleChange = useCallback((name: string, value: any) => { + const newFormData = { ...formData, [name]: value }; + setFormData(newFormData); + validateForm(newFormData); + onChange?.(newFormData); + }, [formData, setFormData, validateForm, onChange]); + + // Handle input change events + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const { name, value, type } = e.target; + const parsedValue = type === 'number' ? Number(value) : value; + + // Use centralized validation if available, otherwise use local validation + if (handleProviderFieldChange) { + handleProviderFieldChange(name, parsedValue); + } else { + const newFormData = { ...formData, [name]: parsedValue }; + setFormData(newFormData); + validateForm(newFormData); + onChange?.(newFormData); + } + }, [formData, setFormData, validateForm, onChange, handleProviderFieldChange]); + + // Handle input blur events for validation + const handleInputBlur = useCallback((e: React.FocusEvent) => { + const { name, value, type } = e.target; + const parsedValue = type === 'number' ? Number(value) : value; + const newFormData = { ...formData, [name]: parsedValue }; + validateForm(newFormData); + }, [formData, validateForm]); + + // Handle toggle change + const handleToggleChange = useCallback((name: string, checked: boolean) => { + handleChange(name, checked); + }, [handleChange]); + + // Use external validation errors if provided, otherwise use local errors + const displayErrors = Object.keys(validationErrors).length > 0 ? validationErrors : localErrors; + + // Default props for Input component + const getInputProps = (fieldName: string, label: string, required = false) => ({ + validate: "", + skip: false, + labelProps: {}, + ghost: false, + tooltip: "", + tooltipIcon: null, + required, + label, + description: "", + footer: displayErrors[fieldName] || "", + className: displayErrors[fieldName] ? 'border-red-500' : '', + }); + + return ( +
+ {/* Section 1: Bucket Configuration */} +
+
+ +
+ +
+ +
+
+ + {/* Section 2: Credentials */} +
+ +
+ + {/* Section 3: Configuration */} +
+ +
+ + {/* Section 4: File Filter */} +
+ +
+ + {/* Section 5: Additional Settings */} +
+
+
+
+ +

Generate pre-signed URLs for secure file access

+
+ handleToggleChange("presign", e.target.checked)} + /> +
+
+ + {formData.presign && ( +
+ +

Minutes until pre-signed URLs expire (1-10080 minutes)

+
+ + handleChange("presign_ttl", Number(e.target.value))} + className="flex-1" + /> +
+
+ )} +
+
+ ); +}; + +GCP.title = "Google Cloud Storage Configuration"; +GCP.description = "Configure your Google Cloud Storage connection with all required Label Studio settings"; \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/LocalFiles.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/LocalFiles.tsx new file mode 100644 index 000000000000..4ea823ed9a2c --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/LocalFiles.tsx @@ -0,0 +1,188 @@ +import { Label, Toggle } from "@humansignal/ui"; +import { atom, useAtom } from "jotai"; +import { z } from "zod"; +import { useCallback, useEffect } from "react"; + +// LocalFiles Form validation schema +const localFilesFormSchema = z.object({ + path: z.string().min(1, "Path is required"), + prefix: z.string().optional(), + regex_filter: z.string().optional(), + presign: z.boolean().default(false), + presign_ttl: z.number().min(1).max(10080).optional(), +}); + +type LocalFilesFormData = z.infer; + +// LocalFiles Form state atom +const localFilesFormAtom = atom({ + path: "", + prefix: "", + regex_filter: "", + presign: false, + presign_ttl: 60, +}); + +// LocalFiles Form errors atom +const localFilesFormErrorsAtom = atom>({}); + +interface LocalFilesProps { + formData?: any; + setFormData?: (updater: (prev: any) => any) => void; + handleChange?: (e: React.ChangeEvent) => void; + initialData?: Partial; + onChange?: (data: LocalFilesFormData) => void; + onValidationChange?: (isValid: boolean) => void; +} + +export const LocalFiles = ({ initialData = {}, onChange, onValidationChange }: LocalFilesProps) => { + const [formData, setFormData] = useAtom(localFilesFormAtom); + const [errors, setErrors] = useAtom(localFilesFormErrorsAtom); + + // Initialize form data with initial values + useEffect(() => { + if (initialData && Object.keys(initialData).length > 0) { + setFormData((prev) => ({ ...prev, ...initialData })); + } + }, [initialData, setFormData]); + + // Validate form data + const validateForm = useCallback((data: LocalFilesFormData) => { + try { + localFilesFormSchema.parse(data); + setErrors({}); + onValidationChange?.(true); + return true; + } catch (error) { + if (error instanceof z.ZodError) { + const newErrors: Record = {}; + error.errors.forEach((err) => { + if (err.path[0]) { + newErrors[err.path[0] as string] = err.message; + } + }); + setErrors(newErrors); + onValidationChange?.(false); + } + return false; + } + }, [setErrors, onValidationChange]); + + // Handle form data changes + const handleChange = useCallback((name: string, value: any) => { + const newFormData = { ...formData, [name]: value }; + setFormData(newFormData); + validateForm(newFormData); + onChange?.(newFormData); + }, [formData, setFormData, validateForm, onChange]); + + // Handle input change events + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const { name, value, type } = e.target; + const parsedValue = type === 'number' ? Number(value) : value; + handleChange(name, parsedValue); + }, [handleChange]); + + // Handle toggle change + const handleToggleChange = useCallback((name: string, checked: boolean) => { + handleChange(name, checked); + }, [handleChange]); + + return ( +
+ {/* Section 1: Path Configuration */} +
+ + + {errors.path &&

{errors.path}

} +

Absolute path to the directory containing your files

+
+ + {/* Section 2: Configuration */} +
+ + +

Optional prefix to limit files to a specific subdirectory

+
+ + {/* Section 3: File Filter */} +
+ + +

Optional regex pattern to filter files (e.g., .*\.(jpg|jpeg|png|gif)$ for images only)

+
+ + {/* Section 3: Additional Settings */} +
+
+
+
+ +

Generate pre-signed URLs for secure file access

+
+ handleToggleChange("presign", e.target.checked)} + /> +
+
+ + {formData.presign && ( +
+ +

Minutes until pre-signed URLs expire (1-10080 minutes)

+
+ + handleChange("presign_ttl", Number(e.target.value))} + className="flex-1" + /> +
+ {errors.presign_ttl &&

{errors.presign_ttl}

} +
+ )} +
+
+ ); +}; + +LocalFiles.title = "Local Files Configuration"; +LocalFiles.description = "Configure your local file system connection"; \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/Redis.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/Redis.tsx new file mode 100644 index 000000000000..0bc3f7789c5f --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/Redis.tsx @@ -0,0 +1,251 @@ +import { Label, Toggle } from "@humansignal/ui"; +import { atom, useAtom } from "jotai"; +import { z } from "zod"; +import { useCallback, useEffect } from "react"; + +// Redis Form validation schema +const redisFormSchema = z.object({ + host: z.string().min(1, "Host is required"), + port: z.number().min(1).max(65535, "Port must be between 1 and 65535"), + db: z.number().min(0).max(15, "Database must be between 0 and 15"), + password: z.string().optional(), + prefix: z.string().optional(), + regex_filter: z.string().optional(), + presign: z.boolean().default(false), + presign_ttl: z.number().min(1).max(10080).optional(), +}); + +type RedisFormData = z.infer; + +// Redis Form state atom +const redisFormAtom = atom({ + host: "", + port: 6379, + db: 0, + password: "", + prefix: "", + regex_filter: "", + presign: false, + presign_ttl: 60, +}); + +// Redis Form errors atom +const redisFormErrorsAtom = atom>({}); + +interface RedisProps { + formData?: any; + setFormData?: (updater: (prev: any) => any) => void; + handleChange?: (e: React.ChangeEvent) => void; + initialData?: Partial; + onChange?: (data: RedisFormData) => void; + onValidationChange?: (isValid: boolean) => void; +} + +export const Redis = ({ initialData = {}, onChange, onValidationChange }: RedisProps) => { + const [formData, setFormData] = useAtom(redisFormAtom); + const [errors, setErrors] = useAtom(redisFormErrorsAtom); + + // Initialize form data with initial values + useEffect(() => { + if (initialData && Object.keys(initialData).length > 0) { + setFormData((prev) => ({ ...prev, ...initialData })); + } + }, [initialData, setFormData]); + + // Validate form data + const validateForm = useCallback((data: RedisFormData) => { + try { + redisFormSchema.parse(data); + setErrors({}); + onValidationChange?.(true); + return true; + } catch (error) { + if (error instanceof z.ZodError) { + const newErrors: Record = {}; + error.errors.forEach((err) => { + if (err.path[0]) { + newErrors[err.path[0] as string] = err.message; + } + }); + setErrors(newErrors); + onValidationChange?.(false); + } + return false; + } + }, [setErrors, onValidationChange]); + + // Handle form data changes + const handleChange = useCallback((name: string, value: any) => { + const newFormData = { ...formData, [name]: value }; + setFormData(newFormData); + validateForm(newFormData); + onChange?.(newFormData); + }, [formData, setFormData, validateForm, onChange]); + + // Handle input change events + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const { name, value, type } = e.target; + const parsedValue = type === 'number' ? Number(value) : value; + handleChange(name, parsedValue); + }, [handleChange]); + + // Handle toggle change + const handleToggleChange = useCallback((name: string, checked: boolean) => { + handleChange(name, checked); + }, [handleChange]); + + return ( +
+ {/* Section 1: Connection Configuration */} +
+
+ + + {errors.host &&

{errors.host}

} +
+ +
+ + + {errors.port &&

{errors.port}

} +
+
+ + {/* Section 2: Database Configuration */} +
+
+ + + {errors.db &&

{errors.db}

} +

Redis database number (0-15)

+
+ +
+ + +

Optional Redis authentication password

+
+
+ + {/* Section 3: Configuration */} +
+ + +

Optional prefix for Redis keys

+
+ + {/* Section 4: File Filter */} +
+ + +

Optional regex pattern to filter files (e.g., .*\.(jpg|jpeg|png|gif)$ for images only)

+
+ + {/* Section 4: Additional Settings */} +
+
+
+
+ +

Generate pre-signed URLs for secure file access

+
+ handleToggleChange("presign", e.target.checked)} + /> +
+
+ + {formData.presign && ( +
+ +

Minutes until pre-signed URLs expire (1-10080 minutes)

+
+ + handleChange("presign_ttl", Number(e.target.value))} + className="flex-1" + /> +
+ {errors.presign_ttl &&

{errors.presign_ttl}

} +
+ )} +
+
+ ); +}; + +Redis.title = "Redis Configuration"; +Redis.description = "Configure your Redis connection for file storage"; \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.tsx index 6e0b1e4aff76..f212f2dc8b13 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.tsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.tsx @@ -1,33 +1,195 @@ import { Label, Toggle } from "@humansignal/ui"; -import { Input } from "../../../../components/Form"; +import { atom, useAtom } from "jotai"; +import { z } from "zod"; +import { useCallback, useEffect } from "react"; +import Input from "apps/labelstudio/src/components/Form/Elements/Input/Input"; + +// S3 Form validation schema +const s3FormSchema = z.object({ + bucket: z.string().min(1, "Bucket name is required"), + region_name: z.string().optional(), + aws_access_key_id: z.string().min(1, "Access Key ID is required"), + aws_secret_access_key: z.string().min(1, "Secret Access Key is required"), + aws_session_token: z.string().optional(), + prefix: z.string().optional(), + s3_endpoint: z.string().optional(), + regex_filter: z.string().optional(), + presign: z.boolean().default(false), + presign_ttl: z.number().min(1).max(10080).optional(), +}); + +type S3FormData = z.infer; + +// S3 Form state atom +const s3FormAtom = atom({ + bucket: "", + region_name: "", + aws_access_key_id: "", + aws_secret_access_key: "", + aws_session_token: "", + prefix: "", + s3_endpoint: "", + regex_filter: "", + presign: false, + presign_ttl: 60, +}); + +// S3 Form errors atom +const s3FormErrorsAtom = atom>({}); + +interface S3Props { + formData?: any; + setFormData?: (updater: (prev: any) => any) => void; + handleChange?: (e: React.ChangeEvent) => void; + handleProviderFieldChange?: (name: string, value: any) => void; + initialData?: Partial; + onChange?: (data: S3FormData) => void; + onValidationChange?: (isValid: boolean) => void; + validationErrors?: Record; +} + +export const S3 = ({ + initialData = {}, + onChange, + onValidationChange, + validationErrors = {}, + handleProviderFieldChange, +}: S3Props) => { + const [formData, setFormData] = useAtom(s3FormAtom); + const [localErrors, setLocalErrors] = useAtom(s3FormErrorsAtom); + + // Initialize form data with initial values + useEffect(() => { + if (initialData && Object.keys(initialData).length > 0) { + setFormData((prev) => ({ ...prev, ...initialData })); + } + }, [initialData, setFormData]); + + // Validate form data + const validateForm = useCallback( + (data: S3FormData) => { + try { + s3FormSchema.parse(data); + setLocalErrors({}); + onValidationChange?.(true); + return true; + } catch (error) { + if (error instanceof z.ZodError) { + const newErrors: Record = {}; + error.errors.forEach((err) => { + if (err.path[0]) { + newErrors[err.path[0] as string] = err.message; + } + }); + setLocalErrors(newErrors); + onValidationChange?.(false); + } + return false; + } + }, + [setLocalErrors, onValidationChange], + ); + + // Handle form data changes + const handleChange = useCallback( + (name: string, value: any) => { + const newFormData = { ...formData, [name]: value }; + setFormData(newFormData); + validateForm(newFormData); + onChange?.(newFormData); + }, + [formData, setFormData, validateForm, onChange], + ); + + // Handle input change events + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value, type } = e.target; + const parsedValue = type === "number" ? Number(value) : value; + + // Use centralized validation if available, otherwise use local validation + if (handleProviderFieldChange) { + handleProviderFieldChange(name, parsedValue); + } else { + const newFormData = { ...formData, [name]: parsedValue }; + setFormData(newFormData); + validateForm(newFormData); + onChange?.(newFormData); + } + }, + [formData, setFormData, validateForm, onChange, handleProviderFieldChange], + ); + + // Handle input blur events for validation + const handleInputBlur = useCallback( + (e: React.FocusEvent) => { + const { name, value, type } = e.target; + const parsedValue = type === "number" ? Number(value) : value; + const newFormData = { ...formData, [name]: parsedValue }; + validateForm(newFormData); + }, + [formData, validateForm], + ); + + // Handle toggle change + const handleToggleChange = useCallback( + (name: string, checked: boolean) => { + handleChange(name, checked); + }, + [handleChange], + ); + + // Use external validation errors if provided, otherwise use local errors + const displayErrors = Object.keys(validationErrors).length > 0 ? validationErrors : localErrors; + + // Debug: Log validation errors + console.log('S3 validationErrors:', validationErrors); + console.log('S3 localErrors:', localErrors); + console.log('S3 displayErrors:', displayErrors); + + // Default props for Input component + const getInputProps = (fieldName: string, label: string, required = false) => { + const footer = displayErrors[fieldName] || ""; + console.log(`S3 getInputProps for ${fieldName}:`, { footer, hasError: !!displayErrors[fieldName] }); + return { + validate: "", + skip: false, + labelProps: {}, + ghost: false, + tooltip: "", + tooltipIcon: null, + required, + label, + description: "", + footer, + className: displayErrors[fieldName] ? "border-red-500" : "", + }; + }; -export const S3 = ({ formData, setFormData, handleChange }) => { return (
{/* Section 2: Bucket Configuration */}
-
-
@@ -35,32 +197,30 @@ export const S3 = ({ formData, setFormData, handleChange }) => { {/* Section 4: Credentials */}
-
-
@@ -68,44 +228,58 @@ export const S3 = ({ formData, setFormData, handleChange }) => { {/* Section 3: AWS Configuration */}
- -

Optional prefix to limit files to a specific folder

- -

For S3-compatible storage (leave empty for AWS S3)

- +
+ + {/* Section 4: File Filter */} +
+ -

Optional session token for temporary AWS credentials

{/* Section 5: Additional Settings */} @@ -116,12 +290,7 @@ export const S3 = ({ formData, setFormData, handleChange }) => {

Generate pre-signed URLs for secure file access

- setFormData((prev) => ({ ...prev, presign: checked }))} - /> + handleToggleChange("presign", e.target.checked)} />
@@ -137,16 +306,18 @@ export const S3 = ({ formData, setFormData, handleChange }) => { min={1} max={10080} value={formData.presign_ttl} - onChange={(e) => handleChange("presign_ttl", Number.parseInt(e.target.value))} - className="w-24" + onChange={handleInputChange} + onBlur={handleInputBlur} + {...getInputProps("presign_ttl", "Expiration Minutes")} + className={`w-24 ${displayErrors.presign_ttl ? "border-red-500" : ""}`} /> handleChange("presign_ttl", value[0])} + value={formData.presign_ttl} + onChange={(e) => handleChange("presign_ttl", Number(e.target.value))} className="flex-1" />
diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderDetailsStep.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderDetailsStep.tsx index 98b4e62dcd73..b19c212c0e21 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderDetailsStep.tsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderDetailsStep.tsx @@ -1,13 +1,16 @@ import { Form, Input } from "apps/labelstudio/src/components/Form"; import { InlineError } from "apps/labelstudio/src/components/Error/InlineError"; import { S3 } from "apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3"; +import { GCP } from "apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/GCP"; +import { Azure } from "apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/Azure"; +import { Redis } from "apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/Redis"; +import { LocalFiles } from "apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/LocalFiles"; interface ProviderDetailsStepProps { formData: any; - errors: { - provider?: string; - }; + errors: Record; handleChange: (e: React.ChangeEvent) => void; + handleProviderFieldChange: (name: string, value: any) => void; action: string; target: string; type: string; @@ -17,10 +20,44 @@ interface ProviderDetailsStepProps { formRef: React.RefObject; } +// Provider form components mapping +const providerComponents = { + s3: S3, + s3s: S3, + gcp: GCP, + gcs: GCP, + azure: Azure, + redis: Redis, + localfiles: LocalFiles, +}; + +// Provider display names +const providerDisplayNames = { + s3: "AWS S3", + s3s: "AWS S3", + gcp: "Google Cloud Storage", + gcs: "Google Cloud Storage", + azure: "Azure Blob Storage", + redis: "Redis", + localfiles: "Local Files", +}; + +// Provider descriptions +const providerDescriptions = { + s3: "Configure your AWS S3 connection with all required Label Studio settings", + s3s: "Configure your AWS S3 connection with all required Label Studio settings", + gcp: "Configure your Google Cloud Storage connection with all required Label Studio settings", + gcs: "Configure your Google Cloud Storage connection with all required Label Studio settings", + azure: "Configure your Azure Blob Storage connection with all required Label Studio settings", + redis: "Configure your Redis connection for file storage", + localfiles: "Configure your local file system connection", +}; + export const ProviderDetailsStep = ({ formData, errors, handleChange, + handleProviderFieldChange, action, target, type, @@ -29,13 +66,18 @@ export const ProviderDetailsStep = ({ onSubmit, formRef, }: ProviderDetailsStepProps) => { + console.log('ProviderDetailsStep received errors:', errors); + + const selectedProvider = formData.provider?.toLowerCase() as keyof typeof providerComponents; + const ProviderComponent = selectedProvider ? providerComponents[selectedProvider] : null; + const displayName = selectedProvider ? providerDisplayNames[selectedProvider] : "Storage Configuration"; + const description = selectedProvider ? providerDescriptions[selectedProvider] : ""; + return (
-

AWS S3 Configuration

-

- Configure your AWS S3 connection with all required Label Studio settings -

+

{displayName}

+

{description}

- - {}} handleChange={handleChange} /> - + + + {ProviderComponent ? ( + {}} + handleChange={handleChange} + handleProviderFieldChange={handleProviderFieldChange} + validationErrors={errors} + /> + ) : ( +
+

Please select a storage provider first

+
+ )} + + - {errors.provider &&

{errors.provider}

}
); }; \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderSelectionStep.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderSelectionStep.tsx index cd108e072c0d..5289f2785597 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderSelectionStep.tsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderSelectionStep.tsx @@ -1,6 +1,6 @@ -import { Label } from "@humansignal/ui"; -import { Input } from "apps/labelstudio/src/components/Form"; -import { RadioGroup, RadioGroupItem } from "@humansignal/ui"; +import { Label, Select } from "@humansignal/ui"; +import { useMemo, useEffect, useCallback } from "react"; +import Input from "apps/labelstudio/src/components/Form/Elements/Input/Input"; interface ProviderSelectionStepProps { formData: { @@ -9,10 +9,16 @@ interface ProviderSelectionStepProps { }; errors: { provider?: string; + title?: string; }; handleChange: (e: React.ChangeEvent) => void; handleSelectChange: (name: string, value: string) => void; + handleStep1FieldChange: (name: string, value: any) => void; setFormState: (updater: (prevState: any) => any) => void; + storageTypes?: any[]; + storageTypesLoading?: boolean; + target?: "import" | "export"; + onValidationChange?: (isValid: boolean) => void; } export const ProviderSelectionStep = ({ @@ -20,8 +26,177 @@ export const ProviderSelectionStep = ({ errors, handleChange, handleSelectChange, + handleStep1FieldChange, setFormState, + storageTypes = [], + storageTypesLoading = false, + target = "import", + onValidationChange, }: ProviderSelectionStepProps) => { + // Get provider display name + const getProviderDisplayName = (provider: string) => { + switch (provider.toLowerCase()) { + case "s3": + return "Amazon S3"; + case "gcp": + return "Google Cloud Storage"; + case "azure": + return "Azure Blob Storage"; + default: + return provider; + } + }; + + // Process storage types data + const storageTypeOptions = useMemo(() => { + if (!storageTypes || !Array.isArray(storageTypes)) { + return []; + } + + return storageTypes.map( + (storageType: any) => + ({ + label: storageType.title, + value: storageType.name, + }) as const, + ); + }, [storageTypes]); + + // Set default provider if none is selected and we have options + useEffect(() => { + if (!formData.provider && storageTypeOptions.length > 0) { + handleSelectChange("provider", storageTypeOptions[0].value); + } + }, [storageTypeOptions, formData.provider, handleSelectChange]); + + // Handle input blur for validation + const handleInputBlur = useCallback( + (e: React.FocusEvent) => { + const { name, value } = e.target; + // Trigger validation on blur + onValidationChange?.(Object.keys(errors).length === 0); + }, + [errors, onValidationChange], + ); + + // Handle input change for real-time validation + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value } = e.target; + + // Use centralized validation if available, otherwise use local validation + if (handleStep1FieldChange) { + handleStep1FieldChange(name, value); + } else { + // Fallback to local validation + onValidationChange?.(Object.keys(errors).length === 0); + } + }, + [errors, onValidationChange, handleStep1FieldChange], + ); + + // Default props for Input component + const getInputProps = (fieldName: string, label: string, required = false) => ({ + validate: "", + skip: false, + labelProps: {}, + ghost: false, + tooltip: "", + tooltipIcon: null, + required, + label, + description: "", + footer: errors[fieldName as keyof typeof errors] || "", + className: errors[fieldName as keyof typeof errors] ? "border-red-500" : "", + }); + + // Get provider icon based on provider type + const getProviderIcon = (provider: string) => { + switch (provider.toLowerCase()) { + case "s3": + return ( +
+ + + + + +
+ ); + case "gcp": + return ( +
+ + + + + +
+ ); + case "azure": + return ( +
+ + + + + + +
+ ); + default: + return ( +
+ + + + + +
+ ); + } + }; + return (
@@ -30,120 +205,28 @@ export const ProviderSelectionStep = ({
- -

This name will help you identify this connection in your project

- - handleSelectChange("provider", value)} - > - - - {/* GCP Option */} - - - {/* Azure Option */} - - + + handleSelectChange("provider", value)} + handleSelectChange("provider", providerName)} disabled={storageTypesLoading} + error={errors.provider} /> - - {errors.provider &&

{errors.provider}

}
); diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/Stepper.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/Stepper.tsx index 047206e05ffd..15e0a82b4690 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/Stepper.tsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/Stepper.tsx @@ -1,4 +1,4 @@ -import { cn } from "@humansignal/shad/utils"; +import { cn } from "@humansignal/ui"; interface StepperProps { steps: { title: string }[]; @@ -7,27 +7,20 @@ interface StepperProps { export const Stepper = ({ steps, currentStep }: StepperProps) => { return ( -
+
{/* Step circles and names */} -
+
{steps.map((step, index) => (
index - ? "w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-200" // completed + ? "w-8 h-8 rounded-full flex items-center justify-center text-body-small font-semibold transition-all duration-300 bg-primary-surface text-primary-surface-content shadow-sm" // completed : currentStep === index - ? "w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-200" // current - : "w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 bg-white border-2 border-slate-200 text-slate-400", // upcoming + ? "w-8 h-8 rounded-full flex items-center justify-center text-body-small font-semibold transition-all duration-300 bg-primary-surface text-primary-surface-content shadow-sm" // current + : "w-8 h-8 rounded-full flex items-center justify-center text-body-small font-semibold transition-all duration-300 bg-neutral-surface border-2 border-neutral-border text-neutral-content-subtle", // upcoming )} > {currentStep > index ? ( @@ -49,7 +42,7 @@ export const Stepper = ({ steps, currentStep }: StepperProps) => { index + 1 )}
- = index ? "text-primary font-sm" : "text-muted-foreground")}> + = index ? "text-primary-content font-semibold" : "text-neutral-content-subtle")}> {step.title}
@@ -57,9 +50,9 @@ export const Stepper = ({ steps, currentStep }: StepperProps) => {
{/* Progress bar */} -
+
= { + title: "Storage/ProviderGrid", + component: ProviderGrid, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + onProviderSelect: { action: "provider-selected" }, + }, +}; + +export default meta; +type Story = StoryObj; + +const defaultProviders = [ + { + name: "s3", + title: "AWS S3", + }, + { + name: "gcs", + title: "Google Cloud Storage", + }, + { + name: "azure", + title: "Microsoft Azure", + }, + { + name: "redis", + title: "Redis", + }, + { + name: "localfiles", + title: "Local files", + }, +]; + +export const Default: Story = { + args: { + providers: defaultProviders, + }, +}; + +export const WithSelectedProvider: Story = { + args: { + providers: defaultProviders, + selectedProvider: "s3", + }, +}; + +export const Disabled: Story = { + args: { + providers: defaultProviders, + disabled: true, + }, +}; + +export const WithError: Story = { + args: { + providers: defaultProviders, + error: "Please select a storage provider", + }, +}; + +export const Loading: Story = { + args: { + providers: [], + disabled: true, + }, +}; \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/components/provider-grid.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/components/provider-grid.tsx new file mode 100644 index 000000000000..f0ad10d9ff65 --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/components/provider-grid.tsx @@ -0,0 +1,208 @@ +import { cn } from "@humansignal/ui"; + +interface Provider { + name: string; + title: string; +} + +interface ProviderGridProps { + providers: Provider[]; + selectedProvider?: string; + onProviderSelect: (providerName: string) => void; + disabled?: boolean; + error?: string; +} + +export const ProviderGrid = ({ + providers, + selectedProvider, + onProviderSelect, + disabled = false, + error, +}: ProviderGridProps) => { + // Get provider icon based on provider type + const getProviderIcon = (provider: string) => { + switch (provider.toLowerCase()) { + case "s3": + return ( +
+ + + + + +
+ ); + case "gcs": + return ( +
+ + + + + +
+ ); + case "azure": + return ( +
+ + + + + + +
+ ); + case "redis": + return ( +
+ + + + +
+ ); + case "localfiles": + return ( +
+ + + + + + + +
+ ); + default: + return ( +
+ + + + + +
+ ); + } + }; + + return ( +
+
+ {providers.map((provider) => { + const isSelected = selectedProvider === provider.name; + const isDisabled = disabled; + + return ( + + ); + })} +
+ + {error && ( +

{error}

+ )} +
+ ); +}; \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/index.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/index.tsx index 6a4ba072b2fb..bc348b9dd944 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/index.tsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/index.tsx @@ -79,8 +79,8 @@ const localFilesSchema = z.object({ presign_ttl: z.number().min(1).max(10080).optional(), }); -// Combine all schemas -const formStateAtom = atom({ +// Initial form state +const initialFormState = { currentStep: 0, formData: { project: 0, @@ -107,7 +107,10 @@ const formStateAtom = atom({ recursive_scan: true, }, isComplete: false, -}); +}; + +// Combine all schemas +const formStateAtom = atom(initialFormState); // Helper function to get provider-specific schema const getProviderSchema = (provider: string) => { @@ -531,49 +534,32 @@ export const StorageFormNew = forwardRef< }; const handleClose = () => { + // Reset Jotai state to initial values + setFormState(initialFormState); + + // Reset local state + setType(undefined); + setFilesPreview(null); + setLoadingFilesPreiview(false); + setNextPreviewToken(""); + setErrors({}); + setTestingConnection(false); + setConnectionChecked(false); + onClose(); modal?.hide(); }; return ( -
+
{/* Custom header with title, subtitle and close button */} -
+
-

+

{title}

{true && ( -
+
{"Import your data from cloud storage providers"}
)} @@ -583,22 +569,16 @@ export const StorageFormNew = forwardRef< -
+
{renderStepContent()}
-
+
-
+
{currentStep === 1 && ( -
+
{currentStep === 1 && ( - + <> + {connectionChecked && ✓ Connection successful} + + )} {currentStep === 2 && ( From b3a89d9ec7859452e3fa84cf1a7d7cd4d03abc4d Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Wed, 23 Jul 2025 18:24:17 +0100 Subject: [PATCH 08/68] S3 preview files --- label_studio/io_storages/api.py | 38 +++++++++++++-------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/label_studio/io_storages/api.py b/label_studio/io_storages/api.py index 9e0edda0cf85..f0d09220a179 100644 --- a/label_studio/io_storages/api.py +++ b/label_studio/io_storages/api.py @@ -197,38 +197,31 @@ def create(self, request, *args, **kwargs): instance = serializer.update(instance, serializer.validated_data) else: instance = serializer.Meta.model(**serializer.validated_data) - + # double check: not all storages validate connection in serializer, just make another explicit check here try: params = self._extract_pagination_params(request.data) all_files = list(instance.iter_keys(**params)) - + # Get files with pagination files = [ - { - 'key': fl.get('Key'), - 'last_modified': fl.get('LastModified'), - 'size': fl.get('Size') - } + {'key': fl.get('Key'), 'last_modified': fl.get('LastModified'), 'size': fl.get('Size')} for fl in all_files ] - + # Try to get continuation token if available - next_token = self._extract_continuation_token(instance.iter_keys, params) - - return Response({ - 'files': files, - 'continuation_token': next_token - }) + # next_token = self._extract_continuation_token(instance.iter_keys, params) + + return Response({'files': files}) except Exception as exc: raise ValidationError(exc) - + return Response() def _extract_pagination_params(self, data): """Extract and validate pagination parameters.""" params = {} - + # Process limit parameter if 'limit' in data: try: @@ -238,25 +231,24 @@ def _extract_pagination_params(self, data): params['limit'] = limit except (ValueError, TypeError): raise ValidationError({'limit': 'Must be a valid integer'}) - + # Process pagination token if data.get('starting_token'): params['starting_token'] = data['starting_token'] - + # Process sort direction - params['reverse'] = bool(data.get('reverse', False)) - + # params['reverse'] = bool(data.get('reverse', False)) + # Process page size try: page_size = int(data.get('page_size', 1000)) params['page_size'] = max(1, min(page_size, 1000)) # Clamp between1-1000 except (ValueError, TypeError): params['page_size'] = 1000 - + return params - - + @extend_schema(exclude=True) class StorageFormLayoutAPI(generics.RetrieveAPIView): From 16169747a02261104d455b2395605a8b8fda3ca7 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Thu, 24 Jul 2025 13:32:25 +0100 Subject: [PATCH 09/68] Providers --- .../StorageSettings/FormDetails/S3.tsx | 2 +- .../StorageSettings/StorageFormNew/README.md | 216 ++++++++++++++++++ .../Steps/ProviderDetailsStep.tsx | 138 +++-------- .../Steps/ProviderSelectionStep.tsx | 58 ----- .../StorageFormNew/Steps/ReviewStep.tsx | 67 +++++- .../StorageFormNew/Steps/Stepper.tsx | 107 ++++++--- .../components/FieldRenderer.tsx | 145 ++++++++++++ .../components/ProviderForm.tsx | 31 +++ .../StorageSettings/StorageFormNew/index.tsx | 168 +++++++------- .../StorageFormNew/providers/azure.ts | 34 +++ .../StorageFormNew/providers/example.ts | 78 +++++++ .../StorageFormNew/providers/gcs.ts | 35 +++ .../StorageFormNew/providers/index.ts | 54 +++++ .../StorageFormNew/providers/localFiles.ts | 23 ++ .../StorageFormNew/providers/redis.ts | 33 +++ .../StorageFormNew/providers/s3.ts | 136 +++++++++++ .../StorageFormNew/types/provider.ts | 143 ++++++++++++ 17 files changed, 1168 insertions(+), 300 deletions(-) create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/README.md create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/components/FieldRenderer.tsx create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/components/ProviderForm.tsx create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/providers/azure.ts create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/providers/example.ts create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/providers/gcs.ts create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/providers/index.ts create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/providers/localFiles.ts create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/providers/redis.ts create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/providers/s3.ts create mode 100644 web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/types/provider.ts diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.tsx index 117670ffc141..02f9f1e4c024 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.tsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3.tsx @@ -7,7 +7,7 @@ import Input from "apps/labelstudio/src/components/Form/Elements/Input/Input"; // S3 Form validation schema const s3FormSchema = z.object({ bucket: z.string().min(1, "Bucket name is required"), - prefix: z.string().optional(), + prefix: z.string().optional().describe("Bucket prefix"), regex_filter: z.string().optional(), region_name: z.string().optional(), s3_endpoint: z.string().optional(), diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/README.md b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/README.md new file mode 100644 index 000000000000..a0f08ad72573 --- /dev/null +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/README.md @@ -0,0 +1,216 @@ +# Storage Provider Configuration System + +This system allows you to easily add new storage providers by defining their configuration in a declarative way. + +## How to Add a New Provider + +### 1. Create Provider Configuration + +Create a new file in `providers/` directory (e.g., `providers/myProvider.ts`): + +```typescript +import { z } from "zod"; +import { ProviderConfig } from "../types/provider"; + +export const myProvider: ProviderConfig = { + name: "myprovider", + title: "My Storage Provider", + description: "Configure your My Storage Provider connection", + fields: [ + { + name: "api_key", + type: "password", + label: "API Key", + required: true, + placeholder: "Enter your API key", + schema: z.string().min(1, "API Key is required"), + }, + { + name: "endpoint", + type: "text", + label: "API Endpoint", + required: true, + placeholder: "https://api.mystorage.com", + schema: z.string().url("Must be a valid URL"), + }, + { + name: "use_ssl", + type: "toggle", + label: "Use SSL", + description: "Enable SSL for secure connections", + schema: z.boolean().default(true), // Default value defined in schema + }, + { + name: "timeout", + type: "counter", + label: "Connection Timeout (seconds)", + min: 1, + max: 300, + step: 5, + schema: z.number().min(1).max(300).default(30), // Default value defined in schema + }, + ], + layout: [ + { + fields: ["api_key"], + }, + { + fields: ["endpoint"], + }, + { + fields: ["use_ssl", "timeout"], + }, + ], +}; +``` + +### 2. Register the Provider + +Add your provider to the registry in `providers/index.ts`: + +```typescript +import { myProvider } from "./myProvider"; + +export const providerRegistry: Record = { + s3: s3Provider, + gcp: gcpProvider, + azure: azureProvider, + redis: redisProvider, + localfiles: localFilesProvider, + myprovider: myProvider, // Add your provider here +}; +``` + +## Field Types + +### Available Field Types + +- `text`: Regular text input +- `password`: Password input (hidden) +- `number`: Numeric input +- `select`: Dropdown selection +- `toggle`: Boolean toggle switch +- `counter`: Numeric counter with min/max +- `textarea`: Multi-line text input + +### Field Properties + +```typescript +{ + name: string; // Field name (used in form data) + type: FieldType; // Field type (see above) + label: string; // Display label + description?: string; // Help text + placeholder?: string; // Placeholder text + required?: boolean; // Whether field is required + schema: z.ZodTypeAny; // Zod validation schema with defaults + options?: Array<{ value: string; label: string }>; // For select fields + min?: number; // For number/counter fields + max?: number; // For number/counter fields + step?: number; // For number/counter fields + autoComplete?: string; // For input fields + gridCols?: number; // How many columns this field should span (1-12) +} +``` + +## Default Values + +Default values are now defined directly in the Zod schema using `.default()`: + +```typescript +{ + name: "use_ssl", + type: "toggle", + label: "Use SSL", + schema: z.boolean().default(true), // Default: true +}, +{ + name: "timeout", + type: "counter", + label: "Connection Timeout", + schema: z.number().min(1).max(300).default(30), // Default: 30 +}, +{ + name: "region", + type: "select", + label: "Region", + schema: z.string().default("us-east-1"), // Default: "us-east-1" +}, +``` + +The system automatically extracts these default values from the schemas using the `extractDefaultValues()` function. + +## Layout Configuration + +The `layout` array defines how fields are arranged in rows: + +```typescript +layout: [ + { + fields: ["field1"], // Single field on one row + }, + { + fields: ["field2", "field3"], // Two fields on the same row + }, + { + fields: ["field4"], // Another single field + }, +] +``` + +## Validation + +Each field includes a Zod schema for validation: + +```typescript +{ + name: "api_key", + type: "password", + label: "API Key", + required: true, + schema: z.string().min(1, "API Key is required"), +} +``` + +The system automatically assembles all field schemas into a complete validation schema for the entire form. + +## Helper Functions + +The system provides several helper functions: + +- `getProviderConfig(providerName)`: Get provider configuration +- `getProviderSchema(providerName)`: Get validation schema for provider +- `getProviderDefaultValues(providerName)`: Get default values for provider +- `extractDefaultValues(fields)`: Extract defaults from field schemas + +## Example: Complete Provider + +See `providers/example.ts` for a complete example of a new provider configuration. + +## Benefits + +1. **Declarative**: Define providers in a simple configuration object +2. **Type-safe**: Full TypeScript support with Zod validation +3. **Flexible**: Easy to add new field types and layouts +4. **Maintainable**: All provider logic in one place +5. **Consistent**: All providers follow the same structure +6. **Extensible**: Easy to add new features to all providers at once +7. **Single Source of Truth**: Default values are defined in the schema, not separately + +## Migration from Old System + +The old system used hardcoded React components for each provider. The new system: + +1. Uses a generic `ProviderForm` component +2. Renders fields based on configuration +3. Handles validation automatically +4. Makes adding new providers much easier +5. Uses Zod schemas for both validation and defaults + +To migrate an existing provider: + +1. Extract field definitions from the old component +2. Create a new provider configuration file +3. Define the layout and validation schemas with defaults +4. Register the provider in the registry +5. Remove the old component file \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderDetailsStep.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderDetailsStep.tsx index b19c212c0e21..16b995393c8d 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderDetailsStep.tsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderDetailsStep.tsx @@ -1,134 +1,60 @@ -import { Form, Input } from "apps/labelstudio/src/components/Form"; -import { InlineError } from "apps/labelstudio/src/components/Error/InlineError"; -import { S3 } from "apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/S3"; -import { GCP } from "apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/GCP"; -import { Azure } from "apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/Azure"; -import { Redis } from "apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/Redis"; -import { LocalFiles } from "apps/labelstudio/src/pages/Settings/StorageSettings/FormDetails/LocalFiles"; +import { getProviderConfig } from "../providers"; +import { ProviderForm } from "../components/ProviderForm"; +import Input from "apps/labelstudio/src/components/Form/Elements/Input/Input"; interface ProviderDetailsStepProps { formData: any; errors: Record; - handleChange: (e: React.ChangeEvent) => void; handleProviderFieldChange: (name: string, value: any) => void; - action: string; - target: string; - type: string; - project: string; - storage?: any; - onSubmit: () => void; - formRef: React.RefObject; + provider?: string; } -// Provider form components mapping -const providerComponents = { - s3: S3, - s3s: S3, - gcp: GCP, - gcs: GCP, - azure: Azure, - redis: Redis, - localfiles: LocalFiles, -}; - -// Provider display names -const providerDisplayNames = { - s3: "AWS S3", - s3s: "AWS S3", - gcp: "Google Cloud Storage", - gcs: "Google Cloud Storage", - azure: "Azure Blob Storage", - redis: "Redis", - localfiles: "Local Files", -}; - -// Provider descriptions -const providerDescriptions = { - s3: "Configure your AWS S3 connection with all required Label Studio settings", - s3s: "Configure your AWS S3 connection with all required Label Studio settings", - gcp: "Configure your Google Cloud Storage connection with all required Label Studio settings", - gcs: "Configure your Google Cloud Storage connection with all required Label Studio settings", - azure: "Configure your Azure Blob Storage connection with all required Label Studio settings", - redis: "Configure your Redis connection for file storage", - localfiles: "Configure your local file system connection", -}; - export const ProviderDetailsStep = ({ formData, errors, - handleChange, handleProviderFieldChange, - action, - target, - type, - project, - storage, - onSubmit, - formRef, + provider, }: ProviderDetailsStepProps) => { - console.log('ProviderDetailsStep received errors:', errors); - - const selectedProvider = formData.provider?.toLowerCase() as keyof typeof providerComponents; - const ProviderComponent = selectedProvider ? providerComponents[selectedProvider] : null; - const displayName = selectedProvider ? providerDisplayNames[selectedProvider] : "Storage Configuration"; - const description = selectedProvider ? providerDescriptions[selectedProvider] : ""; + const providerConfig = getProviderConfig(provider); + + if (!provider || !providerConfig) { + return
{!provider ? "No provider selected" : `Unknown provider: ${provider}`}
; + } return (
-

{displayName}

-

{description}

+

{providerConfig.title}

+

{providerConfig.description}

-
- + ) => handleProviderFieldChange("title", e.target.value)} + placeholder="Enter a descriptive name (e.g., 'Legal Documents', 'Training Data')" validate="" - required={false} skip={false} labelProps={{}} ghost={false} tooltip="" tooltipIcon={null} + required={true} + label="Storage Title" + description="This name will help you identify this connection in your project" + footer={errors.title || ""} + className={errors.title ? "border-red-500" : ""} /> - - {ProviderComponent ? ( - {}} - handleChange={handleChange} - handleProviderFieldChange={handleProviderFieldChange} - validationErrors={errors} - /> - ) : ( -
-

Please select a storage provider first

-
- )} - - - +
+
); -}; \ No newline at end of file +}; diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderSelectionStep.tsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderSelectionStep.tsx index 7021f64b43ef..185c78599c8b 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderSelectionStep.tsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageFormNew/Steps/ProviderSelectionStep.tsx @@ -5,16 +5,12 @@ import { ProviderGrid } from "../components"; interface ProviderSelectionStepProps { formData: { - title: string; provider: string; }; errors: { provider?: string; - title?: string; }; - handleChange: (e: React.ChangeEvent) => void; handleSelectChange: (name: string, value: string) => void; - handleStep1FieldChange: (name: string, value: any) => void; setFormState: (updater: (prevState: any) => any) => void; storageTypes?: any[]; storageTypesLoading?: boolean; @@ -25,9 +21,7 @@ interface ProviderSelectionStepProps { export const ProviderSelectionStep = ({ formData, errors, - handleChange, handleSelectChange, - handleStep1FieldChange, setFormState, storageTypes = [], storageTypesLoading = false, @@ -50,46 +44,7 @@ export const ProviderSelectionStep = ({ } }, [storageTypeOptions, formData.provider, handleSelectChange]); - // Handle input blur for validation - const handleInputBlur = useCallback( - (e: React.FocusEvent) => { - const { name, value } = e.target; - // Trigger validation on blur - onValidationChange?.(Object.keys(errors).length === 0); - }, - [errors, onValidationChange], - ); - - // Handle input change for real-time validation - const handleInputChange = useCallback( - (e: React.ChangeEvent) => { - const { name, value } = e.target; - - // Use centralized validation if available, otherwise use local validation - if (handleStep1FieldChange) { - handleStep1FieldChange(name, value); - } else { - // Fallback to local validation - onValidationChange?.(Object.keys(errors).length === 0); - } - }, - [errors, onValidationChange, handleStep1FieldChange], - ); - // Default props for Input component - const getInputProps = (fieldName: string, label: string, required = false) => ({ - validate: "", - skip: false, - labelProps: {}, - ghost: false, - tooltip: "", - tooltipIcon: null, - required, - label, - description: "", - footer: errors[fieldName as keyof typeof errors] || "", - className: errors[fieldName as keyof typeof errors] ? "border-red-500" : "", - }); @@ -100,19 +55,6 @@ export const ProviderSelectionStep = ({

Select the cloud storage service where your data is stored

-
- -
-
) : ( @@ -278,4 +280,4 @@ export const PreviewStep = ({
); -}; \ No newline at end of file +}; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/ReviewStep.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/ReviewStep.tsx index 48007635a5b2..d5b812879417 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/ReviewStep.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/ReviewStep.tsx @@ -81,4 +81,4 @@ export const ReviewStep = ({ formData, filesPreview, formatSize }: ReviewStepPro
); -}; \ No newline at end of file +}; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/index.ts b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/index.ts index 6e3ec97637e8..f03442f6dbb0 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/index.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/index.ts @@ -2,4 +2,4 @@ export { Stepper } from "./Stepper"; export { ProviderSelectionStep } from "./ProviderSelectionStep"; export { ProviderDetailsStep } from "./ProviderDetailsStep"; export { PreviewStep } from "./PreviewStep"; -export { ReviewStep } from "./ReviewStep"; \ No newline at end of file +export { ReviewStep } from "./ReviewStep"; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/FieldRenderer.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/components/FieldRenderer.tsx index 1e7eac77266e..e9b475943b6f 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/components/FieldRenderer.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/components/FieldRenderer.tsx @@ -145,24 +145,24 @@ export const FieldRenderer: React.FC = ({
); - case "counter": - const counterValue = value !== undefined && value !== null ? value : (field.min || 0); - return ( - - ); + case "counter": + const counterValue = value !== undefined && value !== null ? value : field.min || 0; + return ( + + ); default: return
Unknown field type: {field.type}
; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/ProviderForm.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/components/ProviderForm.tsx index 1ae8349de10a..08606cf2090d 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/components/ProviderForm.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/components/ProviderForm.tsx @@ -1,6 +1,6 @@ -import React from "react"; +import type React from "react"; import { FieldRenderer } from "./FieldRenderer"; -import { ProviderConfig, getFieldsForRow } from "../types/provider"; +import { type ProviderConfig, getFieldsForRow } from "../types/provider"; interface ProviderFormProps { provider: ProviderConfig; @@ -11,7 +11,14 @@ interface ProviderFormProps { isEditMode?: boolean; } -export const ProviderForm: React.FC = ({ provider, formData, errors, onChange, onBlur, isEditMode = false }) => { +export const ProviderForm: React.FC = ({ + provider, + formData, + errors, + onChange, + onBlur, + isEditMode = false, +}) => { return (
{provider.layout.map((row, rowIndex) => ( @@ -32,4 +39,4 @@ export const ProviderForm: React.FC = ({ provider, formData, ))}
); -}; \ No newline at end of file +}; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/index.ts b/web/libs/app-common/src/blocks/StorageProviderForm/components/index.ts index f097f04ae552..c72e576cfa83 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/components/index.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/components/index.ts @@ -1,2 +1,2 @@ export { ProviderGrid } from "./provider-grid"; -export { default as ProviderGridStories } from "./provider-grid.stories"; \ No newline at end of file +export { default as ProviderGridStories } from "./provider-grid.stories"; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-grid.stories.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-grid.stories.tsx index c8c2c62478ae..83d565160b7d 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-grid.stories.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-grid.stories.tsx @@ -71,4 +71,4 @@ export const Loading: Story = { providers: [], disabled: true, }, -}; \ No newline at end of file +}; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/providers/azure.ts b/web/libs/app-common/src/blocks/StorageProviderForm/providers/azure.ts index 22d3dae928f2..5fdfccc8547a 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/providers/azure.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/providers/azure.ts @@ -71,4 +71,4 @@ export const azureProvider: ProviderConfig = { ], }; -export default azureProvider; \ No newline at end of file +export default azureProvider; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/providers/example.ts b/web/libs/app-common/src/blocks/StorageProviderForm/providers/example.ts index 433e7e90a1a8..ff689e91e9ac 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/providers/example.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/providers/example.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { ProviderConfig } from "../types/provider"; +import type { ProviderConfig } from "../types/provider"; // Example: Adding a new provider is now as simple as defining this configuration export const exampleProvider: ProviderConfig = { @@ -76,4 +76,4 @@ export const exampleProvider: ProviderConfig = { fields: ["use_ssl", "timeout"], }, ], -}; \ No newline at end of file +}; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/providers/localFiles.ts b/web/libs/app-common/src/blocks/StorageProviderForm/providers/localFiles.ts index 717524187764..31bd1d0b95a5 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/providers/localFiles.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/providers/localFiles.ts @@ -22,4 +22,4 @@ export const localFilesProvider: ProviderConfig = { ], }; -export default localFilesProvider; \ No newline at end of file +export default localFilesProvider; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/types/provider.ts b/web/libs/app-common/src/blocks/StorageProviderForm/types/provider.ts index ea010b161c1d..067140b37f62 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/types/provider.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/types/provider.ts @@ -37,7 +37,7 @@ export interface ProviderConfig { } // Helper function to assemble the complete schema from field definitions -export function assembleSchema(fields: FieldDefinition[], isEditMode: boolean = false): z.ZodObject { +export function assembleSchema(fields: FieldDefinition[], isEditMode = false): z.ZodObject { const schemaObject: Record = {}; fields.forEach((field) => { diff --git a/web/libs/ui/package.json b/web/libs/ui/package.json index 01edc6039b65..2f20fa771342 100644 --- a/web/libs/ui/package.json +++ b/web/libs/ui/package.json @@ -4,11 +4,7 @@ "license": "MIT", "private": true, "main": "src/index.ts", - "files": [ - "src/tailwind.css", - "src/shad/components", - "src/ui/assets" - ], + "files": ["src/tailwind.css", "src/shad/components", "src/ui/assets"], "dependencies": { "@radix-ui/react-radio-group": "^1.3.7" } diff --git a/web/libs/ui/src/shad/components/ui/radio-group.tsx b/web/libs/ui/src/shad/components/ui/radio-group.tsx index 04d57a34dcad..30617ec8721b 100644 --- a/web/libs/ui/src/shad/components/ui/radio-group.tsx +++ b/web/libs/ui/src/shad/components/ui/radio-group.tsx @@ -1,22 +1,16 @@ -import * as React from "react" -import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" -import { Circle } from "lucide-react" +import * as React from "react"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { Circle } from "lucide-react"; -import { cn } from "@humansignal/shad/utils" +import { cn } from "@humansignal/shad/utils"; const RadioGroup = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => { - return ( - - ) -}) -RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + return ; +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; const RadioGroupItem = React.forwardRef< React.ElementRef, @@ -27,7 +21,7 @@ const RadioGroupItem = React.forwardRef< ref={ref} className={cn( "aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", - className + className, )} {...props} > @@ -35,8 +29,8 @@ const RadioGroupItem = React.forwardRef< - ) -}) -RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; -export { RadioGroup, RadioGroupItem } +export { RadioGroup, RadioGroupItem }; From 95315cc37f26a81548ef890b17ce1ec31cdd9564 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Fri, 25 Jul 2025 16:13:03 +0100 Subject: [PATCH 27/68] Callout support and styles adjustments --- .../{file-renderer.tsx => field-renderer.tsx} | 0 .../components/provider-form.tsx | 30 ++++++++---- .../components/provider-grid.tsx | 2 +- .../StorageProviderForm/types/provider.ts | 23 +++++++-- .../core/src/lib/utils/feature-flags/ff.ts | 4 +- web/libs/ui/src/index.ts | 1 + .../ui/src/lib/callout/callout.module.scss | 49 +++++++++++++++---- web/libs/ui/src/lib/callout/callout.tsx | 3 +- 8 files changed, 86 insertions(+), 26 deletions(-) rename web/libs/app-common/src/blocks/StorageProviderForm/components/{file-renderer.tsx => field-renderer.tsx} (100%) diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/file-renderer.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/components/field-renderer.tsx similarity index 100% rename from web/libs/app-common/src/blocks/StorageProviderForm/components/file-renderer.tsx rename to web/libs/app-common/src/blocks/StorageProviderForm/components/field-renderer.tsx diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-form.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-form.tsx index f118dfec45bd..ceadc40d1858 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-form.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-form.tsx @@ -1,6 +1,7 @@ import type React from "react"; -import { FieldRenderer } from "./file-renderer"; +import { FieldRenderer } from "./field-renderer"; import { type ProviderConfig, getFieldsForRow } from "../types/provider"; +import { Callout } from "@humansignal/ui"; interface ProviderFormProps { provider: ProviderConfig; @@ -30,15 +31,24 @@ export const ProviderForm: React.FC = ({ }} > {getFieldsForRow(provider.fields, row.fields).map((field) => ( -
- +
+ {field.type === "message" ? ( + {field.content} + ) : ( + + )}
))}
diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-grid.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-grid.tsx index c493e3fcaaae..8aeeac743b85 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-grid.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-grid.tsx @@ -39,7 +39,7 @@ export const ProviderGrid = ({ providers, selectedProvider, onProviderSelect, er > {Icon && }
-

{provider.title}

+

{provider.title}

); diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/types/provider.ts b/web/libs/app-common/src/blocks/StorageProviderForm/types/provider.ts index 7c1df02e5c79..76b72c95049a 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/types/provider.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/types/provider.ts @@ -1,3 +1,4 @@ +import type { CalloutVariant } from "@humansignal/ui"; import type { FC } from "react"; import { z } from "zod"; @@ -12,7 +13,7 @@ export interface FieldDefinition { description?: string; placeholder?: string; required?: boolean; - schema: z.ZodTypeAny; + schema: z.ZodSchema; options?: Array<{ value: string | boolean | number; label: string }>; // For select fields min?: number; // For number/counter fields max?: number; // For number/counter fields @@ -22,6 +23,14 @@ export interface FieldDefinition { accessKey?: boolean; // Whether this field is an access key/credential that should be handled specially in edit mode } +export interface MessageDefinition { + name: string; + type: "message"; + content: JSX.Element; + gridCols?: number; + variant?: CalloutVariant; +} + // Layout row definition export interface LayoutRow { fields: string[]; // Array of field names that should be on the same row @@ -33,7 +42,7 @@ export interface ProviderConfig { name: string; title: string; description: string; - fields: FieldDefinition[]; + fields: (FieldDefinition | MessageDefinition)[]; layout: LayoutRow[]; icon?: FC; } @@ -139,11 +148,17 @@ export function extractDefaultValues(fields: FieldDefinition[]): Record field.name === name); } // Helper function to get fields for a specific row -export function getFieldsForRow(fields: FieldDefinition[], rowFields: string[]): FieldDefinition[] { +export function getFieldsForRow( + fields: (FieldDefinition | MessageDefinition)[], + rowFields: string[], +): (FieldDefinition | MessageDefinition)[] { return rowFields.map((fieldName) => getFieldByName(fields, fieldName)).filter(Boolean) as FieldDefinition[]; } diff --git a/web/libs/core/src/lib/utils/feature-flags/ff.ts b/web/libs/core/src/lib/utils/feature-flags/ff.ts index 1f96327d62ea..f3c5748bc59d 100644 --- a/web/libs/core/src/lib/utils/feature-flags/ff.ts +++ b/web/libs/core/src/lib/utils/feature-flags/ff.ts @@ -1,3 +1,5 @@ +import { FF_NEW_STORAGES } from "./flags"; + const FEATURE_FLAGS = window.APP_SETTINGS?.feature_flags || {}; // TODO: remove the override + if statement once LSE and LSO start building @@ -9,7 +11,7 @@ const FLAGS_OVERRIDE: Record = { // // Add your flags overrides as following: // [FF_FLAG_NAME]: boolean - // [FF_NEW_STORAGES]: false, + [FF_NEW_STORAGES]: true, }; /** diff --git a/web/libs/ui/src/index.ts b/web/libs/ui/src/index.ts index 3ad5e721741a..a4f2608a3c9d 100644 --- a/web/libs/ui/src/index.ts +++ b/web/libs/ui/src/index.ts @@ -7,6 +7,7 @@ export * from "./lib/Userpic/Userpic"; export * from "./lib/badge/badge"; export * from "./lib/button/button"; export * from "./lib/checkbox/checkbox"; +export * from "./lib/callout/callout"; export * from "./lib/code-block/code-block"; export * from "./lib/code-block/code-block"; export * from "./lib/code-editor/code-editor"; diff --git a/web/libs/ui/src/lib/callout/callout.module.scss b/web/libs/ui/src/lib/callout/callout.module.scss index 373ba3f54c89..d525696c09c7 100644 --- a/web/libs/ui/src/lib/callout/callout.module.scss +++ b/web/libs/ui/src/lib/callout/callout.module.scss @@ -2,10 +2,9 @@ display: flex; gap: var(--spacing-tight); flex-direction: column; - padding: var(--spacing-base) var(--spacing-wide, 24px) var(--spacing-wide, 24px) var(--spacing-wide, 24px); + padding: var(--spacing-base) var(--spacing-wide); border-radius: var(--corner-radius-small); - border: 1px solid var(--color-warning-border-subtlest, #FFD3B1); - background: var(--color-warning-background, #FFF6EF); + border: 1px solid; } .header { @@ -14,19 +13,51 @@ gap: var(--spacing-tight); } +.icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--spacing-wide); + height: var(--spacing-wide); +} + .title { color: var(--color-neutral-content); - font-size: var(--font-size-title-medium, 16px); - font-weight: var(--font-weight-medium, 500); + font-size: var(--font-size-body-medium); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-body-medium); } .content { color: var(--color-neutral-content); - padding-left: calc(24px + var(--spacing-tigth, 8px)); + font-size: var(--font-size-body-small); + line-height: var(--line-height-body-small); + padding-left: calc(var(--spacing-wide) + var(--spacing-tight)); } .variantWarning { - color: #E69559; - border-color: var(--color-warning-border-subtlest, #FFD3B1); - background: var(--color-warning-border-subtlest, #FFF6EF); + border-color: var(--color-warning-border-subtlest); + background: var(--color-warning-background); + + .icon { + color: var(--color-warning-content); + } + + .title { + color: var(--color-warning-content); + } +} + +.variantInfo { + border-color: var(--color-info-border-subtlest); + background: var(--color-info-background); + + .icon { + color: var(--color-info-content); + } + + .title { + color: var(--color-info-content); + } } diff --git a/web/libs/ui/src/lib/callout/callout.tsx b/web/libs/ui/src/lib/callout/callout.tsx index 50f47a92b30a..63a09ab3722d 100644 --- a/web/libs/ui/src/lib/callout/callout.tsx +++ b/web/libs/ui/src/lib/callout/callout.tsx @@ -4,6 +4,7 @@ import styles from "./callout.module.scss"; export const CalloutVariants = { warning: clsx(styles.variantWarning), + info: clsx(styles.variantInfo), }; export type CalloutVariant = keyof typeof CalloutVariants; @@ -23,7 +24,7 @@ export function Callout({ } export function CalloutIcon({ children, className, ...rest }: PropsWithChildren>) { - const cls = clsx("", className); + const cls = clsx(styles.icon, className); return (
{children} From a53c0d313dae58b8a0c38f1f4a03a51b27ca0cec Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Fri, 25 Jul 2025 16:48:22 +0100 Subject: [PATCH 28/68] Remove callout --- .../blocks/StorageProviderForm/components/provider-form.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-form.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-form.tsx index ceadc40d1858..cf1a144a6cd4 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-form.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-form.tsx @@ -1,7 +1,6 @@ import type React from "react"; import { FieldRenderer } from "./field-renderer"; import { type ProviderConfig, getFieldsForRow } from "../types/provider"; -import { Callout } from "@humansignal/ui"; interface ProviderFormProps { provider: ProviderConfig; @@ -38,7 +37,7 @@ export const ProviderForm: React.FC = ({ }} > {field.type === "message" ? ( - {field.content} +
{field.content}
) : ( Date: Fri, 25 Jul 2025 17:10:21 +0100 Subject: [PATCH 29/68] Resolve circular deps --- web/libs/app-common/package.json | 9 ++- .../hooks/useStorageForm.ts | 27 +++++---- .../blocks/StorageProviderForm/providers.ts | 12 ++-- .../StorageProviderForm/types/common.ts | 48 ++++++++++++++++ .../StorageProviderForm/types/provider.ts | 55 +------------------ web/package.json | 2 +- web/yarn.lock | 41 +++----------- 7 files changed, 89 insertions(+), 105 deletions(-) create mode 100644 web/libs/app-common/src/blocks/StorageProviderForm/types/common.ts diff --git a/web/libs/app-common/package.json b/web/libs/app-common/package.json index c2ccfdde276b..6bd1d002aded 100644 --- a/web/libs/app-common/package.json +++ b/web/libs/app-common/package.json @@ -3,7 +3,12 @@ "version": "0.0.0", "license": "MIT", "private": true, - "dependencies": {}, + "dependencies": { + "zod": "^4.0.10" + }, "main": "src/index.ts", - "files": ["src/lib", "src/pages"] + "files": [ + "src/lib", + "src/pages" + ] } diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/hooks/useStorageForm.ts b/web/libs/app-common/src/blocks/StorageProviderForm/hooks/useStorageForm.ts index 241bacb33bdb..869f12cdf337 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/hooks/useStorageForm.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/hooks/useStorageForm.ts @@ -1,8 +1,8 @@ import { useCallback, useState, useEffect } from "react"; import { useAtom } from "jotai"; import { z } from "zod"; -import { formStateAtom, type FormState } from "../atoms"; -import { formatValidationErrors, getProviderSchema, step1Schema } from "../schemas"; +import { formStateAtom } from "../atoms"; +import { formatValidationErrors } from "../schemas"; import { getProviderConfig } from "../providers"; import { extractDefaultValues } from "../types/provider"; @@ -45,7 +45,7 @@ export const useStorageForm = ({ project, isEditMode, steps, storage }: UseStora if (providerConfig) { providerConfig.fields.forEach((field) => { - if (field.accessKey) { + if (field.type !== "message" && field.accessKey) { // Fill access key fields with placeholder values in edit mode formDataWithPlaceholders[field.name] = "••••••••••••••••"; } else if (field.type === "counter") { @@ -130,6 +130,7 @@ export const useStorageForm = ({ project, isEditMode, steps, storage }: UseStora // Validate entire form const validateEntireForm = useCallback(() => { const currentSchema = steps[currentStep]?.schema; + console.log(currentSchema, steps, currentStep, formData); if (!currentSchema) return true; try { @@ -137,6 +138,7 @@ export const useStorageForm = ({ project, isEditMode, steps, storage }: UseStora setErrors({}); return true; } catch (error) { + console.log(error, error instanceof z.ZodError, currentSchema); if (error instanceof z.ZodError) { const formattedErrors = formatValidationErrors(error); setErrors(formattedErrors); @@ -150,7 +152,7 @@ export const useStorageForm = ({ project, isEditMode, steps, storage }: UseStora const handleProviderFieldChange = useCallback( (name: string, value: any) => { // If changing provider, get new defaults first - if (name === 'provider') { + if (name === "provider") { const providerConfig = getProviderConfig(value); if (providerConfig) { const defaultValues = extractDefaultValues(providerConfig.fields); @@ -190,12 +192,15 @@ export const useStorageForm = ({ project, isEditMode, steps, storage }: UseStora [validateSingleField], ); - const setCurrentStep = useCallback((step: number) => { - setFormState((prevState) => ({ - ...prevState, - currentStep: step, - })); - }, [setFormState]); + const setCurrentStep = useCallback( + (step: number) => { + setFormState((prevState) => ({ + ...prevState, + currentStep: step, + })); + }, + [setFormState], + ); const resetForm = useCallback(() => { setFormState({ @@ -225,4 +230,4 @@ export const useStorageForm = ({ project, isEditMode, steps, storage }: UseStora setCurrentStep, resetForm, }; -}; \ No newline at end of file +}; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/providers.ts b/web/libs/app-common/src/blocks/StorageProviderForm/providers.ts index c46258ac377d..531836be9aff 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/providers.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/providers.ts @@ -1,5 +1,6 @@ import { z } from "zod"; -import { type ProviderConfig, assembleSchema, extractDefaultValues } from "./types/provider"; +import type { ProviderConfig, FieldDefinition } from "./types/common"; +import { assembleSchema, extractDefaultValues } from "./types/provider"; // Registry of all available providers export const providerRegistry: Record = {}; @@ -21,7 +22,8 @@ export function getProviderSchema(providerName: string) { if (!config) { return z.object({}); // Empty schema for unknown providers } - return assembleSchema(config.fields); + const fieldDefinitions = config.fields.filter((field): field is FieldDefinition => 'type' in field && field.type !== 'message'); + return assembleSchema(fieldDefinitions); } // Helper function to get default values for a provider @@ -30,7 +32,8 @@ export function getProviderDefaultValues(providerName: string): Record 'type' in field && field.type !== 'message'); + return extractDefaultValues(fieldDefinitions); } // Helper function to get all available providers @@ -42,6 +45,3 @@ export function getAvailableProviders(): ProviderConfig[] { export function getProviderByName(name: string): ProviderConfig | undefined { return providerRegistry[name.toLowerCase()]; } - -// Re-export extractDefaultValues for convenience -export { extractDefaultValues } from "./types/provider"; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/types/common.ts b/web/libs/app-common/src/blocks/StorageProviderForm/types/common.ts new file mode 100644 index 000000000000..0242511fbe9b --- /dev/null +++ b/web/libs/app-common/src/blocks/StorageProviderForm/types/common.ts @@ -0,0 +1,48 @@ +import type { CalloutVariant } from "@humansignal/ui"; +import type { FC } from "react"; +import { z } from "zod"; + +// Field types that can be rendered +export type FieldType = "text" | "password" | "number" | "select" | "toggle" | "counter" | "textarea"; + +// Field definition interface +export interface FieldDefinition { + name: string; + type: FieldType; + label: string; + description?: string; + placeholder?: string; + required?: boolean; + schema: z.ZodType; + options?: Array<{ value: string | boolean | number; label: string }>; // For select fields + min?: number; // For number/counter fields + max?: number; // For number/counter fields + step?: number; // For number/counter fields + autoComplete?: string; // For input fields + gridCols?: number; // How many columns this field should span (1-12) + accessKey?: boolean; // Whether this field is an access key/credential that should be handled specially in edit mode +} + +export interface MessageDefinition { + name: string; + type: "message"; + content: JSX.Element; + gridCols?: number; + variant?: CalloutVariant; +} + +// Layout row definition +export interface LayoutRow { + fields: string[]; // Array of field names that should be on the same row + gap?: number; // Gap between fields in the row +} + +// Provider configuration interface +export interface ProviderConfig { + name: string; + title: string; + description: string; + fields: (FieldDefinition | MessageDefinition)[]; + layout: LayoutRow[]; + icon?: FC; +} \ No newline at end of file diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/types/provider.ts b/web/libs/app-common/src/blocks/StorageProviderForm/types/provider.ts index 76b72c95049a..3e294bdb3389 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/types/provider.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/types/provider.ts @@ -1,51 +1,5 @@ -import type { CalloutVariant } from "@humansignal/ui"; -import type { FC } from "react"; import { z } from "zod"; - -// Field types that can be rendered -export type FieldType = "text" | "password" | "number" | "select" | "toggle" | "counter" | "textarea"; - -// Field definition interface -export interface FieldDefinition { - name: string; - type: FieldType; - label: string; - description?: string; - placeholder?: string; - required?: boolean; - schema: z.ZodSchema; - options?: Array<{ value: string | boolean | number; label: string }>; // For select fields - min?: number; // For number/counter fields - max?: number; // For number/counter fields - step?: number; // For number/counter fields - autoComplete?: string; // For input fields - gridCols?: number; // How many columns this field should span (1-12) - accessKey?: boolean; // Whether this field is an access key/credential that should be handled specially in edit mode -} - -export interface MessageDefinition { - name: string; - type: "message"; - content: JSX.Element; - gridCols?: number; - variant?: CalloutVariant; -} - -// Layout row definition -export interface LayoutRow { - fields: string[]; // Array of field names that should be on the same row - gap?: number; // Gap between fields in the row -} - -// Provider configuration interface -export interface ProviderConfig { - name: string; - title: string; - description: string; - fields: (FieldDefinition | MessageDefinition)[]; - layout: LayoutRow[]; - icon?: FC; -} +import type { FieldDefinition, MessageDefinition } from "./common"; // Helper function to assemble the complete schema from field definitions export function assembleSchema(fields: FieldDefinition[], isEditMode = false): z.ZodObject { @@ -69,10 +23,11 @@ export function assembleSchema(fields: FieldDefinition[], isEditMode = false): z } // Helper function to extract default values from Zod schemas -export function extractDefaultValues(fields: FieldDefinition[]): Record { +export function extractDefaultValues(fields: (FieldDefinition | MessageDefinition)[]): Record { const defaultValues: Record = {}; fields.forEach((field) => { + if (field.type === "message") return; try { // Try to get the default value from the schema by accessing the internal structure const schemaAny = field.schema as any; @@ -116,8 +71,6 @@ export function extractDefaultValues(fields: FieldDefinition[]): Record Date: Fri, 25 Jul 2025 17:16:35 +0100 Subject: [PATCH 30/68] Structure fixes --- .../src/blocks/StorageProviderForm/schemas.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/schemas.ts b/web/libs/app-common/src/blocks/StorageProviderForm/schemas.ts index f919aeb2eff1..c2e8bb6af873 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/schemas.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/schemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { FieldDefinition } from "./types/common"; import { getProviderConfig } from "./providers"; import { assembleSchema } from "./types/provider"; @@ -15,17 +16,22 @@ export const getProviderSchema = (provider: string, isEditMode = false) => { } // Combine provider-specific fields with common fields like title - const commonFields = [ + const commonFields: FieldDefinition[] = [ { name: "title", - type: "text" as const, + type: "text", label: "Storage Title", required: true, schema: z.string().min(1, "Storage title is required"), }, ]; - const allFields = [...commonFields, ...providerConfig.fields]; + // Filter out message fields and combine with common fields + const providerFields = providerConfig.fields.filter((field): field is FieldDefinition => + 'type' in field && field.type !== 'message' + ); + + const allFields = [...commonFields, ...providerFields]; return assembleSchema(allFields, isEditMode); }; @@ -33,10 +39,10 @@ export const getProviderSchema = (provider: string, isEditMode = false) => { export const formatValidationErrors = (zodError: z.ZodError): Record => { const errors: Record = {}; - zodError.errors.forEach((error) => { - const fieldName = error.path.join("."); + zodError.issues.forEach((issue) => { + const fieldName = issue.path.join("."); if (fieldName) { - errors[fieldName] = error.message; + errors[fieldName] = issue.message; } }); From d343e3b5a72718ae75ba68154742e937daa14402 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Fri, 25 Jul 2025 17:35:55 +0100 Subject: [PATCH 31/68] Validation --- .../Steps/provider-details-step.tsx | 4 +- .../components/FieldRenderer.tsx | 170 ------------------ .../components/field-renderer.tsx | 42 ++--- .../hooks/useStorageForm.ts | 2 - 4 files changed, 23 insertions(+), 195 deletions(-) delete mode 100644 web/libs/app-common/src/blocks/StorageProviderForm/components/FieldRenderer.tsx diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/provider-details-step.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/provider-details-step.tsx index e7e9f6c332fb..28f72da58702 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/provider-details-step.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/provider-details-step.tsx @@ -48,8 +48,8 @@ export const ProviderDetailsStep = ({ required={true} label="Storage Title" description="This name will help you identify this connection in your project" - footer={errors.title || ""} - className={errors.title ? "border-red-500" : ""} + footer={errors.title ? {errors.title} : ""} + className={errors.title ? "border-negative-content" : ""} />
diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/FieldRenderer.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/components/FieldRenderer.tsx deleted file mode 100644 index e9b475943b6f..000000000000 --- a/web/libs/app-common/src/blocks/StorageProviderForm/components/FieldRenderer.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import type React from "react"; -import { Label, Toggle, Select } from "@humansignal/ui"; -import Counter from "apps/labelstudio/src/components/Form/Elements/Counter/Counter"; -import Input from "apps/labelstudio/src/components/Form/Elements/Input/Input"; -import type { FieldDefinition } from "../types/provider"; - -interface FieldRendererProps { - field: FieldDefinition; - value: any; - onChange: (name: string, value: any) => void; - onBlur?: (name: string, value: any) => void; - error?: string; - isEditMode?: boolean; -} - -export const FieldRenderer: React.FC = ({ - field, - value, - onChange, - onBlur, - error, - isEditMode = false, -}) => { - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value: inputValue, type } = e.target; - const parsedValue = type === "number" ? Number(inputValue) : inputValue; - onChange(name, parsedValue); - }; - - const handleInputBlur = (e: React.FocusEvent) => { - if (onBlur) { - const { name, value: inputValue, type } = e.target; - const parsedValue = type === "number" ? Number(inputValue) : inputValue; - onBlur(name, parsedValue); - } - }; - - const handleToggleChange = (checked: boolean) => { - onChange(field.name, checked); - }; - - const handleSelectChange = (value: string) => { - onChange(field.name, value); - }; - - const handleCounterChange = (e: any) => { - onChange(field.name, Number(e.target.value)); - }; - - // Common props for Input component - const getInputProps = () => ({ - validate: "", - skip: false, - labelProps: {}, - ghost: false, - tooltip: "", - tooltipIcon: null, - required: field.required, - label: field.label, - description: field.description || "", - footer: error ?
{error}
: "", - className: error ? "border-red-500" : "", - placeholder: field.placeholder, - autoComplete: field.autoComplete, - }); - - // Check if this is an access key field with placeholder value in edit mode - const isAccessKeyWithPlaceholder = field.accessKey && isEditMode && value === "••••••••••••••••"; - - // Enhanced description for access key fields in edit mode - const getEnhancedDescription = () => { - return field.description || ""; - }; - - switch (field.type) { - case "text": - case "password": - return ( - - ); - - case "number": - return ( - - ); - - case "textarea": - return ( - - ); - - case "select": - return ( -
-
- - {/* Import Method */} -
- -

Choose how to import your data from storage

- - {errors.connectionName && ( -

{errors.connectionName}

- )} -
- -
- - handleSelectChange("provider", value)} - className="space-y-3" - > - {/* AWS Option */} -
- -
- - - - - -
- -
- -

Amazon Simple Storage Service

-
- -
- {formData.provider === "aws" && ( -
- )} -
-
- - {/* GCP Option */} -
- -
- - - - - -
- -
- -

Unified object storage for developers and enterprises

-
- -
- {formData.provider === "gcp" && ( -
- )} -
-
- - {/* Azure Option */} -
- -
- - - - - - -
- -
- -

Microsoft's object storage solution for the cloud

-
- -
- {formData.provider === "azure" && ( -
- )} -
-
-
- - {errors.provider && ( -

{errors.provider}

- )} -
-
-); From 5fa3aff72c9ba7d1d4d050decab441881edde883 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Fri, 25 Jul 2025 17:48:48 +0100 Subject: [PATCH 34/68] Restore changed files -- unnecessary changes --- web/libs/ui/src/assets/icons/index.ts | 5 -- web/libs/ui/src/index.ts | 3 -- .../ui/src/lib/callout/callout.module.scss | 49 ++++--------------- web/libs/ui/src/lib/callout/callout.tsx | 3 +- web/libs/ui/src/lib/label/label.module.scss | 2 +- web/libs/ui/src/lib/label/label.tsx | 9 ++-- web/libs/ui/src/shadcn.ts | 7 ++- 7 files changed, 17 insertions(+), 61 deletions(-) diff --git a/web/libs/ui/src/assets/icons/index.ts b/web/libs/ui/src/assets/icons/index.ts index c7c59758e553..3468500a8651 100644 --- a/web/libs/ui/src/assets/icons/index.ts +++ b/web/libs/ui/src/assets/icons/index.ts @@ -254,8 +254,3 @@ export { ReactComponent as IconWarningCircle } from "./warning-circle.svg"; export { ReactComponent as IconWarningCircleFilled } from "./warning-circle-filled.svg"; export { ReactComponent as IconZoomIn } from "./zoom-in.svg"; export { ReactComponent as IconZoomOut } from "./zoom-out.svg"; - -export { ReactComponent as IconCloudProviderS3 } from "./cloud-provider-s3.svg"; -export { ReactComponent as IconCloudProviderRedis } from "./cloud-provider-redis.svg"; -export { ReactComponent as IconCloudProviderGCS } from "./cloud-provider-gcs.svg"; -export { ReactComponent as IconCloudProviderAzure } from "./cloud-provider-azure.svg"; diff --git a/web/libs/ui/src/index.ts b/web/libs/ui/src/index.ts index a4f2608a3c9d..7a906b8517a5 100644 --- a/web/libs/ui/src/index.ts +++ b/web/libs/ui/src/index.ts @@ -7,7 +7,6 @@ export * from "./lib/Userpic/Userpic"; export * from "./lib/badge/badge"; export * from "./lib/button/button"; export * from "./lib/checkbox/checkbox"; -export * from "./lib/callout/callout"; export * from "./lib/code-block/code-block"; export * from "./lib/code-block/code-block"; export * from "./lib/code-editor/code-editor"; @@ -32,5 +31,3 @@ export * from "./utils/utils"; // TODO: Remove when DIA-2142 and DIA-2175 are delivered export * from "./shadcn"; export * from "./utils/utils"; - -export { RadioGroup, RadioGroupItem } from "./shad/components/ui/radio-group"; diff --git a/web/libs/ui/src/lib/callout/callout.module.scss b/web/libs/ui/src/lib/callout/callout.module.scss index d525696c09c7..373ba3f54c89 100644 --- a/web/libs/ui/src/lib/callout/callout.module.scss +++ b/web/libs/ui/src/lib/callout/callout.module.scss @@ -2,9 +2,10 @@ display: flex; gap: var(--spacing-tight); flex-direction: column; - padding: var(--spacing-base) var(--spacing-wide); + padding: var(--spacing-base) var(--spacing-wide, 24px) var(--spacing-wide, 24px) var(--spacing-wide, 24px); border-radius: var(--corner-radius-small); - border: 1px solid; + border: 1px solid var(--color-warning-border-subtlest, #FFD3B1); + background: var(--color-warning-background, #FFF6EF); } .header { @@ -13,51 +14,19 @@ gap: var(--spacing-tight); } -.icon { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: var(--spacing-wide); - height: var(--spacing-wide); -} - .title { color: var(--color-neutral-content); - font-size: var(--font-size-body-medium); - font-weight: var(--font-weight-medium); - line-height: var(--line-height-body-medium); + font-size: var(--font-size-title-medium, 16px); + font-weight: var(--font-weight-medium, 500); } .content { color: var(--color-neutral-content); - font-size: var(--font-size-body-small); - line-height: var(--line-height-body-small); - padding-left: calc(var(--spacing-wide) + var(--spacing-tight)); + padding-left: calc(24px + var(--spacing-tigth, 8px)); } .variantWarning { - border-color: var(--color-warning-border-subtlest); - background: var(--color-warning-background); - - .icon { - color: var(--color-warning-content); - } - - .title { - color: var(--color-warning-content); - } -} - -.variantInfo { - border-color: var(--color-info-border-subtlest); - background: var(--color-info-background); - - .icon { - color: var(--color-info-content); - } - - .title { - color: var(--color-info-content); - } + color: #E69559; + border-color: var(--color-warning-border-subtlest, #FFD3B1); + background: var(--color-warning-border-subtlest, #FFF6EF); } diff --git a/web/libs/ui/src/lib/callout/callout.tsx b/web/libs/ui/src/lib/callout/callout.tsx index 63a09ab3722d..50f47a92b30a 100644 --- a/web/libs/ui/src/lib/callout/callout.tsx +++ b/web/libs/ui/src/lib/callout/callout.tsx @@ -4,7 +4,6 @@ import styles from "./callout.module.scss"; export const CalloutVariants = { warning: clsx(styles.variantWarning), - info: clsx(styles.variantInfo), }; export type CalloutVariant = keyof typeof CalloutVariants; @@ -24,7 +23,7 @@ export function Callout({ } export function CalloutIcon({ children, className, ...rest }: PropsWithChildren>) { - const cls = clsx(styles.icon, className); + const cls = clsx("", className); return (
{children} diff --git a/web/libs/ui/src/lib/label/label.module.scss b/web/libs/ui/src/lib/label/label.module.scss index 9643bedcca7d..af4396ddcbc9 100644 --- a/web/libs/ui/src/lib/label/label.module.scss +++ b/web/libs/ui/src/lib/label/label.module.scss @@ -50,7 +50,7 @@ &[data-required] &__text::after { content: "Required"; font-size: 0.825rem; - color: var(--color-neutral-content-subtler); + color: var(--sand_500); margin-left: 0.325rem; } diff --git a/web/libs/ui/src/lib/label/label.tsx b/web/libs/ui/src/lib/label/label.tsx index 948e7b9335fa..1ccce9bed380 100644 --- a/web/libs/ui/src/lib/label/label.tsx +++ b/web/libs/ui/src/lib/label/label.tsx @@ -2,7 +2,7 @@ import { forwardRef, type PropsWithChildren } from "react"; import clsx from "clsx"; import styles from "./label.module.scss"; type LabelProps = PropsWithChildren<{ - text?: string; + text: string; required?: boolean; placement?: "right" | "left"; description?: string; @@ -11,7 +11,6 @@ type LabelProps = PropsWithChildren<{ style?: any; simple?: boolean; flat?: boolean; - htmlFor?: string; }>; export const Label = forwardRef( @@ -26,7 +25,6 @@ export const Label = forwardRef( style: inlineStyle, simple, flat, - htmlFor, }: LabelProps) => { const TagName = simple ? "div" : "label"; @@ -34,7 +32,6 @@ export const Label = forwardRef( - {text ?? children} + {text} {description && {description}} - {text && {children}} + {children} ); }, diff --git a/web/libs/ui/src/shadcn.ts b/web/libs/ui/src/shadcn.ts index 8aac3e13133e..deee408ad46a 100644 --- a/web/libs/ui/src/shadcn.ts +++ b/web/libs/ui/src/shadcn.ts @@ -1,5 +1,4 @@ /// Raw shadcn components for re-export -export * from "./shad/components/ui/badge"; -export * from "./shad/components/ui/skeleton"; -export * from "./shad/components/ui/radio-group"; -export * from "./utils/utils"; +export { Badge, type BadgeProps, badgeVariants } from "./shad/components/ui/badge"; +export { Skeleton } from "./shad/components/ui/skeleton"; +export { cn } from "./utils/utils"; From cf6e1f0c6a93803803dfcc1ccd8e7486b90c13ec Mon Sep 17 00:00:00 2001 From: makseq Date: Fri, 25 Jul 2025 19:54:20 +0300 Subject: [PATCH 35/68] Add None if more than 100 files --- label_studio/io_storages/api.py | 2 +- .../Steps/preview-step.tsx | 12 ++++++--- .../StorageProviderForm/Steps/review-step.tsx | 25 ++++++++++++++++++- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/label_studio/io_storages/api.py b/label_studio/io_storages/api.py index f6882076421c..de160ec244bb 100644 --- a/label_studio/io_storages/api.py +++ b/label_studio/io_storages/api.py @@ -176,7 +176,7 @@ def create(self, request, *args, **kwargs): for object in instance.iter_objects(): files.append(instance.get_unified_metadata(object)) if len(files) >= limit: - files.append({'key': '...', 'last_modified': '...', 'size': '...'}) + files.append({'key': None, 'last_modified': None, 'size': None}) break return Response({'files': files}) diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx index 736ecea95e5b..2bdea17a5160 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx @@ -263,11 +263,17 @@ export const PreviewStep = ({ key={index} className="flex justify-between py-0.5 px-2 bg-gray-50 hover:bg-gray-100 border-b last:border-b-0 rounded-md" > -
{file.key}
+
+ {file.key ? ( + file.key + ) : ( + ... preview limit reached ... + )} +
- {formatDistanceToNow(new Date(file.last_modified), { addSuffix: true })} + {file.last_modified && formatDistanceToNow(new Date(file.last_modified), { addSuffix: true })} - {formatSize(file.size)} + {file.size && formatSize(file.size)}
))} diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/review-step.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/review-step.tsx index 48007635a5b2..0565307a19f5 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/review-step.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/review-step.tsx @@ -22,12 +22,35 @@ export const ReviewStep = ({ formData, filesPreview, formatSize }: ReviewStepPro const getFileCount = () => { if (!filesPreview) return "0 files"; + + // Check if the last file is the "preview limit reached" indicator + const lastFile = filesPreview[filesPreview.length - 1]; + const hasMoreFiles = lastFile && lastFile.key === null; + + if (hasMoreFiles) { + // Subtract 1 to exclude the placeholder file + const visibleFileCount = filesPreview.length - 1; + return `More than ${visibleFileCount} files`; + } + return `${filesPreview.length} files`; }; const getTotalSize = () => { if (!filesPreview || !formatSize) return "0 Bytes"; - const totalBytes = filesPreview.reduce((sum: number, file: any) => sum + (file.size || 0), 0); + + // Check if the last file is the "preview limit reached" indicator + const lastFile = filesPreview[filesPreview.length - 1]; + const hasMoreFiles = lastFile && lastFile.key === null; + + // Calculate total size excluding the placeholder file if it exists + const filesToCount = hasMoreFiles ? filesPreview.slice(0, -1) : filesPreview; + const totalBytes = filesToCount.reduce((sum: number, file: any) => sum + (file.size || 0), 0); + + if (hasMoreFiles) { + return `More than ${formatSize(totalBytes)}`; + } + return formatSize(totalBytes); }; From 86220b7388a9b248fa92e3f10213e27c7f269a39 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Fri, 25 Jul 2025 17:49:50 +0100 Subject: [PATCH 36/68] Revert "Restore changed files -- unnecessary changes" This reverts commit 5fa3aff72c9ba7d1d4d050decab441881edde883. --- web/libs/ui/src/assets/icons/index.ts | 5 ++ web/libs/ui/src/index.ts | 3 ++ .../ui/src/lib/callout/callout.module.scss | 49 +++++++++++++++---- web/libs/ui/src/lib/callout/callout.tsx | 3 +- web/libs/ui/src/lib/label/label.module.scss | 2 +- web/libs/ui/src/lib/label/label.tsx | 9 ++-- web/libs/ui/src/shadcn.ts | 7 +-- 7 files changed, 61 insertions(+), 17 deletions(-) diff --git a/web/libs/ui/src/assets/icons/index.ts b/web/libs/ui/src/assets/icons/index.ts index 3468500a8651..c7c59758e553 100644 --- a/web/libs/ui/src/assets/icons/index.ts +++ b/web/libs/ui/src/assets/icons/index.ts @@ -254,3 +254,8 @@ export { ReactComponent as IconWarningCircle } from "./warning-circle.svg"; export { ReactComponent as IconWarningCircleFilled } from "./warning-circle-filled.svg"; export { ReactComponent as IconZoomIn } from "./zoom-in.svg"; export { ReactComponent as IconZoomOut } from "./zoom-out.svg"; + +export { ReactComponent as IconCloudProviderS3 } from "./cloud-provider-s3.svg"; +export { ReactComponent as IconCloudProviderRedis } from "./cloud-provider-redis.svg"; +export { ReactComponent as IconCloudProviderGCS } from "./cloud-provider-gcs.svg"; +export { ReactComponent as IconCloudProviderAzure } from "./cloud-provider-azure.svg"; diff --git a/web/libs/ui/src/index.ts b/web/libs/ui/src/index.ts index 7a906b8517a5..a4f2608a3c9d 100644 --- a/web/libs/ui/src/index.ts +++ b/web/libs/ui/src/index.ts @@ -7,6 +7,7 @@ export * from "./lib/Userpic/Userpic"; export * from "./lib/badge/badge"; export * from "./lib/button/button"; export * from "./lib/checkbox/checkbox"; +export * from "./lib/callout/callout"; export * from "./lib/code-block/code-block"; export * from "./lib/code-block/code-block"; export * from "./lib/code-editor/code-editor"; @@ -31,3 +32,5 @@ export * from "./utils/utils"; // TODO: Remove when DIA-2142 and DIA-2175 are delivered export * from "./shadcn"; export * from "./utils/utils"; + +export { RadioGroup, RadioGroupItem } from "./shad/components/ui/radio-group"; diff --git a/web/libs/ui/src/lib/callout/callout.module.scss b/web/libs/ui/src/lib/callout/callout.module.scss index 373ba3f54c89..d525696c09c7 100644 --- a/web/libs/ui/src/lib/callout/callout.module.scss +++ b/web/libs/ui/src/lib/callout/callout.module.scss @@ -2,10 +2,9 @@ display: flex; gap: var(--spacing-tight); flex-direction: column; - padding: var(--spacing-base) var(--spacing-wide, 24px) var(--spacing-wide, 24px) var(--spacing-wide, 24px); + padding: var(--spacing-base) var(--spacing-wide); border-radius: var(--corner-radius-small); - border: 1px solid var(--color-warning-border-subtlest, #FFD3B1); - background: var(--color-warning-background, #FFF6EF); + border: 1px solid; } .header { @@ -14,19 +13,51 @@ gap: var(--spacing-tight); } +.icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--spacing-wide); + height: var(--spacing-wide); +} + .title { color: var(--color-neutral-content); - font-size: var(--font-size-title-medium, 16px); - font-weight: var(--font-weight-medium, 500); + font-size: var(--font-size-body-medium); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-body-medium); } .content { color: var(--color-neutral-content); - padding-left: calc(24px + var(--spacing-tigth, 8px)); + font-size: var(--font-size-body-small); + line-height: var(--line-height-body-small); + padding-left: calc(var(--spacing-wide) + var(--spacing-tight)); } .variantWarning { - color: #E69559; - border-color: var(--color-warning-border-subtlest, #FFD3B1); - background: var(--color-warning-border-subtlest, #FFF6EF); + border-color: var(--color-warning-border-subtlest); + background: var(--color-warning-background); + + .icon { + color: var(--color-warning-content); + } + + .title { + color: var(--color-warning-content); + } +} + +.variantInfo { + border-color: var(--color-info-border-subtlest); + background: var(--color-info-background); + + .icon { + color: var(--color-info-content); + } + + .title { + color: var(--color-info-content); + } } diff --git a/web/libs/ui/src/lib/callout/callout.tsx b/web/libs/ui/src/lib/callout/callout.tsx index 50f47a92b30a..63a09ab3722d 100644 --- a/web/libs/ui/src/lib/callout/callout.tsx +++ b/web/libs/ui/src/lib/callout/callout.tsx @@ -4,6 +4,7 @@ import styles from "./callout.module.scss"; export const CalloutVariants = { warning: clsx(styles.variantWarning), + info: clsx(styles.variantInfo), }; export type CalloutVariant = keyof typeof CalloutVariants; @@ -23,7 +24,7 @@ export function Callout({ } export function CalloutIcon({ children, className, ...rest }: PropsWithChildren>) { - const cls = clsx("", className); + const cls = clsx(styles.icon, className); return (
{children} diff --git a/web/libs/ui/src/lib/label/label.module.scss b/web/libs/ui/src/lib/label/label.module.scss index af4396ddcbc9..9643bedcca7d 100644 --- a/web/libs/ui/src/lib/label/label.module.scss +++ b/web/libs/ui/src/lib/label/label.module.scss @@ -50,7 +50,7 @@ &[data-required] &__text::after { content: "Required"; font-size: 0.825rem; - color: var(--sand_500); + color: var(--color-neutral-content-subtler); margin-left: 0.325rem; } diff --git a/web/libs/ui/src/lib/label/label.tsx b/web/libs/ui/src/lib/label/label.tsx index 1ccce9bed380..948e7b9335fa 100644 --- a/web/libs/ui/src/lib/label/label.tsx +++ b/web/libs/ui/src/lib/label/label.tsx @@ -2,7 +2,7 @@ import { forwardRef, type PropsWithChildren } from "react"; import clsx from "clsx"; import styles from "./label.module.scss"; type LabelProps = PropsWithChildren<{ - text: string; + text?: string; required?: boolean; placement?: "right" | "left"; description?: string; @@ -11,6 +11,7 @@ type LabelProps = PropsWithChildren<{ style?: any; simple?: boolean; flat?: boolean; + htmlFor?: string; }>; export const Label = forwardRef( @@ -25,6 +26,7 @@ export const Label = forwardRef( style: inlineStyle, simple, flat, + htmlFor, }: LabelProps) => { const TagName = simple ? "div" : "label"; @@ -32,6 +34,7 @@ export const Label = forwardRef( - {text} + {text ?? children} {description && {description}} - {children} + {text && {children}} ); }, diff --git a/web/libs/ui/src/shadcn.ts b/web/libs/ui/src/shadcn.ts index deee408ad46a..8aac3e13133e 100644 --- a/web/libs/ui/src/shadcn.ts +++ b/web/libs/ui/src/shadcn.ts @@ -1,4 +1,5 @@ /// Raw shadcn components for re-export -export { Badge, type BadgeProps, badgeVariants } from "./shad/components/ui/badge"; -export { Skeleton } from "./shad/components/ui/skeleton"; -export { cn } from "./utils/utils"; +export * from "./shad/components/ui/badge"; +export * from "./shad/components/ui/skeleton"; +export * from "./shad/components/ui/radio-group"; +export * from "./utils/utils"; From 911838456f8ed23b3a803c4e803589862c5cf437 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Fri, 25 Jul 2025 17:51:25 +0100 Subject: [PATCH 37/68] Remove unnecessary changes --- web/libs/ui/src/index.ts | 3 -- .../ui/src/lib/callout/callout.module.scss | 49 ++++--------------- web/libs/ui/src/lib/callout/callout.tsx | 3 +- web/libs/ui/src/lib/label/label.module.scss | 2 +- web/libs/ui/src/lib/label/label.tsx | 9 ++-- web/libs/ui/src/shadcn.ts | 7 ++- 6 files changed, 17 insertions(+), 56 deletions(-) diff --git a/web/libs/ui/src/index.ts b/web/libs/ui/src/index.ts index a4f2608a3c9d..7a906b8517a5 100644 --- a/web/libs/ui/src/index.ts +++ b/web/libs/ui/src/index.ts @@ -7,7 +7,6 @@ export * from "./lib/Userpic/Userpic"; export * from "./lib/badge/badge"; export * from "./lib/button/button"; export * from "./lib/checkbox/checkbox"; -export * from "./lib/callout/callout"; export * from "./lib/code-block/code-block"; export * from "./lib/code-block/code-block"; export * from "./lib/code-editor/code-editor"; @@ -32,5 +31,3 @@ export * from "./utils/utils"; // TODO: Remove when DIA-2142 and DIA-2175 are delivered export * from "./shadcn"; export * from "./utils/utils"; - -export { RadioGroup, RadioGroupItem } from "./shad/components/ui/radio-group"; diff --git a/web/libs/ui/src/lib/callout/callout.module.scss b/web/libs/ui/src/lib/callout/callout.module.scss index d525696c09c7..373ba3f54c89 100644 --- a/web/libs/ui/src/lib/callout/callout.module.scss +++ b/web/libs/ui/src/lib/callout/callout.module.scss @@ -2,9 +2,10 @@ display: flex; gap: var(--spacing-tight); flex-direction: column; - padding: var(--spacing-base) var(--spacing-wide); + padding: var(--spacing-base) var(--spacing-wide, 24px) var(--spacing-wide, 24px) var(--spacing-wide, 24px); border-radius: var(--corner-radius-small); - border: 1px solid; + border: 1px solid var(--color-warning-border-subtlest, #FFD3B1); + background: var(--color-warning-background, #FFF6EF); } .header { @@ -13,51 +14,19 @@ gap: var(--spacing-tight); } -.icon { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: var(--spacing-wide); - height: var(--spacing-wide); -} - .title { color: var(--color-neutral-content); - font-size: var(--font-size-body-medium); - font-weight: var(--font-weight-medium); - line-height: var(--line-height-body-medium); + font-size: var(--font-size-title-medium, 16px); + font-weight: var(--font-weight-medium, 500); } .content { color: var(--color-neutral-content); - font-size: var(--font-size-body-small); - line-height: var(--line-height-body-small); - padding-left: calc(var(--spacing-wide) + var(--spacing-tight)); + padding-left: calc(24px + var(--spacing-tigth, 8px)); } .variantWarning { - border-color: var(--color-warning-border-subtlest); - background: var(--color-warning-background); - - .icon { - color: var(--color-warning-content); - } - - .title { - color: var(--color-warning-content); - } -} - -.variantInfo { - border-color: var(--color-info-border-subtlest); - background: var(--color-info-background); - - .icon { - color: var(--color-info-content); - } - - .title { - color: var(--color-info-content); - } + color: #E69559; + border-color: var(--color-warning-border-subtlest, #FFD3B1); + background: var(--color-warning-border-subtlest, #FFF6EF); } diff --git a/web/libs/ui/src/lib/callout/callout.tsx b/web/libs/ui/src/lib/callout/callout.tsx index 63a09ab3722d..50f47a92b30a 100644 --- a/web/libs/ui/src/lib/callout/callout.tsx +++ b/web/libs/ui/src/lib/callout/callout.tsx @@ -4,7 +4,6 @@ import styles from "./callout.module.scss"; export const CalloutVariants = { warning: clsx(styles.variantWarning), - info: clsx(styles.variantInfo), }; export type CalloutVariant = keyof typeof CalloutVariants; @@ -24,7 +23,7 @@ export function Callout({ } export function CalloutIcon({ children, className, ...rest }: PropsWithChildren>) { - const cls = clsx(styles.icon, className); + const cls = clsx("", className); return (
{children} diff --git a/web/libs/ui/src/lib/label/label.module.scss b/web/libs/ui/src/lib/label/label.module.scss index 9643bedcca7d..af4396ddcbc9 100644 --- a/web/libs/ui/src/lib/label/label.module.scss +++ b/web/libs/ui/src/lib/label/label.module.scss @@ -50,7 +50,7 @@ &[data-required] &__text::after { content: "Required"; font-size: 0.825rem; - color: var(--color-neutral-content-subtler); + color: var(--sand_500); margin-left: 0.325rem; } diff --git a/web/libs/ui/src/lib/label/label.tsx b/web/libs/ui/src/lib/label/label.tsx index 948e7b9335fa..1ccce9bed380 100644 --- a/web/libs/ui/src/lib/label/label.tsx +++ b/web/libs/ui/src/lib/label/label.tsx @@ -2,7 +2,7 @@ import { forwardRef, type PropsWithChildren } from "react"; import clsx from "clsx"; import styles from "./label.module.scss"; type LabelProps = PropsWithChildren<{ - text?: string; + text: string; required?: boolean; placement?: "right" | "left"; description?: string; @@ -11,7 +11,6 @@ type LabelProps = PropsWithChildren<{ style?: any; simple?: boolean; flat?: boolean; - htmlFor?: string; }>; export const Label = forwardRef( @@ -26,7 +25,6 @@ export const Label = forwardRef( style: inlineStyle, simple, flat, - htmlFor, }: LabelProps) => { const TagName = simple ? "div" : "label"; @@ -34,7 +32,6 @@ export const Label = forwardRef( - {text ?? children} + {text} {description && {description}} - {text && {children}} + {children} ); }, diff --git a/web/libs/ui/src/shadcn.ts b/web/libs/ui/src/shadcn.ts index 8aac3e13133e..deee408ad46a 100644 --- a/web/libs/ui/src/shadcn.ts +++ b/web/libs/ui/src/shadcn.ts @@ -1,5 +1,4 @@ /// Raw shadcn components for re-export -export * from "./shad/components/ui/badge"; -export * from "./shad/components/ui/skeleton"; -export * from "./shad/components/ui/radio-group"; -export * from "./utils/utils"; +export { Badge, type BadgeProps, badgeVariants } from "./shad/components/ui/badge"; +export { Skeleton } from "./shad/components/ui/skeleton"; +export { cn } from "./utils/utils"; From 8c47fcfdf7d1497bdd0458c961d2888c962d040d Mon Sep 17 00:00:00 2001 From: makseq Date: Sat, 26 Jul 2025 03:10:23 +0300 Subject: [PATCH 38/68] Fix backend serializer. Add helper for Treat each JSON as task --- label_studio/io_storages/urls.py | 8 +- .../Steps/preview-step.tsx | 241 ++++++++++++------ .../src/blocks/StorageProviderForm/index.tsx | 4 +- 3 files changed, 163 insertions(+), 90 deletions(-) diff --git a/label_studio/io_storages/urls.py b/label_studio/io_storages/urls.py index 0d405d04487f..54c43f94d206 100644 --- a/label_studio/io_storages/urls.py +++ b/label_studio/io_storages/urls.py @@ -103,7 +103,7 @@ path('azure//sync', AzureBlobImportStorageSyncAPI.as_view(), name='storage-azure-sync'), path('azure/validate', AzureBlobImportStorageValidateAPI.as_view(), name='storage-azure-validate'), path('azure/form', AzureBlobImportStorageFormLayoutAPI.as_view(), name='storage-azure-form'), - path('azure/files', ImportStorageListFilesAPI(AzureBlobImportStorageSerializer).as_view(), name='storage-azure-list-files'), + path('azure/files', ImportStorageListFilesAPI().as_view(serializer_class=AzureBlobImportStorageSerializer), name='storage-azure-list-files'), path('export/azure', AzureBlobExportStorageListAPI.as_view(), name='export-storage-azure-list'), path('export/azure/', AzureBlobExportStorageDetailAPI.as_view(), name='export-storage-azure-detail'), path('export/azure//sync', AzureBlobExportStorageSyncAPI.as_view(), name='export-storage-azure-sync'), @@ -115,7 +115,7 @@ path('gcs//sync', GCSImportStorageSyncAPI.as_view(), name='storage-gcs-sync'), path('gcs/validate', GCSImportStorageValidateAPI.as_view(), name='storage-gcs-validate'), path('gcs/form', GCSImportStorageFormLayoutAPI.as_view(), name='storage-gcs-form'), - path('gcs/files', ImportStorageListFilesAPI(GCSImportStorageSerializer).as_view(), name='storage-gcs-list-files'), + path('gcs/files', ImportStorageListFilesAPI().as_view(serializer_class=GCSImportStorageSerializer), name='storage-gcs-list-files'), path('export/gcs', GCSExportStorageListAPI.as_view(), name='export-storage-gcs-list'), path('export/gcs/', GCSExportStorageDetailAPI.as_view(), name='export-storage-gcs-detail'), path('export/gcs//sync', GCSExportStorageSyncAPI.as_view(), name='export-storage-gcs-sync'), @@ -127,7 +127,7 @@ path('redis//sync', RedisImportStorageSyncAPI.as_view(), name='storage-redis-sync'), path('redis/validate', RedisImportStorageValidateAPI.as_view(), name='storage-redis-validate'), path('redis/form', RedisImportStorageFormLayoutAPI.as_view(), name='storage-redis-form'), - path('redis/files', ImportStorageListFilesAPI(RedisImportStorageSerializer).as_view(), name='storage-redis-list-files'), + path('redis/files', ImportStorageListFilesAPI().as_view(serializer_class=RedisImportStorageSerializer), name='storage-redis-list-files'), path('export/redis', RedisExportStorageListAPI.as_view(), name='export-storage-redis-list'), path('export/redis/', RedisExportStorageDetailAPI.as_view(), name='export-storage-redis-detail'), path('export/redis//sync', RedisExportStorageSyncAPI.as_view(), name='export-storage-redis-sync'), @@ -142,7 +142,7 @@ path('localfiles//sync', LocalFilesImportStorageSyncAPI.as_view(), name='storage-localfiles-sync'), path('localfiles/validate', LocalFilesImportStorageValidateAPI.as_view(), name='storage-localfiles-validate'), path('localfiles/form', LocalFilesImportStorageFormLayoutAPI.as_view(), name='storage-localfiles-form'), - path('localfiles/files', ImportStorageListFilesAPI(LocalFilesImportStorageSerializer).as_view(), name='storage-localfiles-list-files'), + path('localfiles/files', ImportStorageListFilesAPI().as_view(serializer_class=LocalFilesImportStorageSerializer), name='storage-localfiles-list-files'), path('export/localfiles', LocalFilesExportStorageListAPI.as_view(), name='export-storage-localfiles-list'), path( 'export/localfiles/', diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx index 2bdea17a5160..573b754dcf62 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx @@ -66,7 +66,7 @@ export const PreviewStep = ({
{/* File Filter Section */}
- +
{/* Import Method */}
- +
+ )} + {/* File Filter Section */}
); diff --git a/web/libs/ui/src/lib/label/label.module.scss b/web/libs/ui/src/lib/label/label.module.scss index af4396ddcbc9..12c2df15423d 100644 --- a/web/libs/ui/src/lib/label/label.module.scss +++ b/web/libs/ui/src/lib/label/label.module.scss @@ -15,8 +15,9 @@ margin-top: 5px; font-size: 14px; line-height: 22px; - color: var(--color-neutral-content-subtle); display: block; + + @apply text-neutral-content-subtler; } &__field { @@ -78,6 +79,7 @@ &_placement_left:not(.label_withDescription) &__field { display: flex; align-items: center; + } &_placement_right.label_withDescription &__field, From 1525890eb7c3cf83ec2da944462f7236d49acf96 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Sat, 26 Jul 2025 18:37:32 +0100 Subject: [PATCH 52/68] Nicer error display --- .../src/components/Error/Error.scss | 59 ++++++++++++------- .../labelstudio/src/providers/ApiProvider.tsx | 1 + .../Steps/preview-step.tsx | 3 + .../Steps/provider-details-step.tsx | 3 + .../hooks/useStorageApi.ts | 37 ++++++------ 5 files changed, 63 insertions(+), 40 deletions(-) diff --git a/web/apps/labelstudio/src/components/Error/Error.scss b/web/apps/labelstudio/src/components/Error/Error.scss index a99898b7d311..e2540a2f7909 100644 --- a/web/apps/labelstudio/src/components/Error/Error.scss +++ b/web/apps/labelstudio/src/components/Error/Error.scss @@ -1,13 +1,13 @@ .inline-error { width: 100%; - padding: 16px; - border-radius: 5px; + border-radius: 0.5rem; box-sizing: border-box; - background-color: var(--color-neutral-background); + background-color: var(--color-negative-background); } .error-message { max-width: 100%; + padding: 1rem; &__heidi { display: block; @@ -15,64 +15,62 @@ } &__title { - text-transform: uppercase; - text-align: center; - font-size: 20px; - margin: 32px auto; color: var(--color-negative-content); + font-size: 1.25rem; + font-weight: 600; } &__detail { - font-size: 24px; - font-weight: bold; + font-size: 1rem; + font-weight: 400; color: var(--color-neutral-content); - margin: 16px 0; + margin-top: 0.5rem; white-space: pre-line; word-break: break-word; } &__exception { - margin: 15px 0; + margin: 0.5rem 1rem; } - &__stacktrace { - margin: 16px 0; + &__stracktrace { + margin: 0.5rem 1rem; padding: 16px; overflow: auto; line-height: 26px; max-height: 200px; white-space: pre; border-radius: 5px; - content: var(--color-neutral-content); - background-color: var(--color-neutral-surface); + color: var(--color-neutral-content); font-family: var(--font-mono); } &__version { font-size: 14px; - font-weight: bold; - margin: 16px 0; + font-weight: 600; + margin: 0.5rem 0; + color: var(--color-neutral-content-subtlest); } &__validation { padding: 0; - margin: 16px 0; + margin: 0.5rem 1rem; list-style-type: none; max-height: 300px; overflow-y: auto; } &__message { - margin: 5px 0; - color: var(--color-neutral-content-subtle); + color: var(--color-neutral-content-subtler); padding: 0; white-space: pre-line; + line-height: 1.4; word-break: break-word; } &__actions { display: flex; - padding: 16px 0 0; + margin: 1rem; } &__slack { @@ -86,4 +84,21 @@ margin-right: 8px; } } -} \ No newline at end of file + + &_kind_paused { + padding: 32px; + } + + &_kind_paused &__detail { + margin-block: 16px; + } + + &_kind_paused &__actions { + margin-inline: 0; + } +} + +.paused-error .modal-ls__content { + border-radius: 16px; + overflow: hidden; +} diff --git a/web/apps/labelstudio/src/providers/ApiProvider.tsx b/web/apps/labelstudio/src/providers/ApiProvider.tsx index 3b9674ffd35b..98510d14337b 100644 --- a/web/apps/labelstudio/src/providers/ApiProvider.tsx +++ b/web/apps/labelstudio/src/providers/ApiProvider.tsx @@ -128,6 +128,7 @@ const handleError = async ( const errorDetails = errorFormatter(result); // Allow inline error handling + console.log(showGlobalError); if (!showGlobalError) { return errorDetails.isShutdown; } diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx index ad358514ddfd..ad5502e618fe 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx @@ -3,6 +3,7 @@ import { Form, Input } from "apps/labelstudio/src/components/Form"; import { IconDocument, IconSearch } from "@humansignal/icons"; import { formatDistanceToNow } from "date-fns"; import { type ForwardedRef } from "react"; +import { InlineError } from "apps/labelstudio/src/components/Error/InlineError"; interface PreviewStepProps { formData: any; @@ -394,6 +395,8 @@ export const PreviewStep = ({
+ +
); }; \ No newline at end of file diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/provider-details-step.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/provider-details-step.tsx index 28f72da58702..47311a6fab66 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/provider-details-step.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/provider-details-step.tsx @@ -1,6 +1,7 @@ import { getProviderConfig } from "../providers"; import { ProviderForm } from "../components/provider-form"; import Input from "apps/labelstudio/src/components/Form/Elements/Input/Input"; +import { InlineError } from "apps/labelstudio/src/components/Error/InlineError"; interface ProviderDetailsStepProps { formData: any; @@ -61,6 +62,8 @@ export const ProviderDetailsStep = ({ onBlur={handleFieldBlur} isEditMode={isEditMode} /> + +
); }; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/hooks/useStorageApi.ts b/web/libs/app-common/src/blocks/StorageProviderForm/hooks/useStorageApi.ts index d6881988ddf5..9bb05bff620e 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/hooks/useStorageApi.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/hooks/useStorageApi.ts @@ -17,23 +17,22 @@ export const useStorageApi = ({ target, storage, project, onSubmit, onClose }: U const action = storage ? "updateStorage" : "createStorage"; // Clean form data for submission - const cleanFormDataForSubmission = useCallback((data: any) => { - if (!isEditMode) return data; - - const cleanedData = { ...data }; - // Remove empty access key fields in edit mode - Object.keys(cleanedData).forEach((key) => { - if ( - cleanedData[key] === "" || - cleanedData[key] === undefined || - cleanedData[key] === "••••••••••••••••" - ) { - delete cleanedData[key]; - } - }); + const cleanFormDataForSubmission = useCallback( + (data: any) => { + if (!isEditMode) return data; + + const cleanedData = { ...data }; + // Remove empty access key fields in edit mode + Object.keys(cleanedData).forEach((key) => { + if (cleanedData[key] === "" || cleanedData[key] === undefined || cleanedData[key] === "••••••••••••••••") { + delete cleanedData[key]; + } + }); - return cleanedData; - }, [isEditMode]); + return cleanedData; + }, + [isEditMode], + ); // Test connection mutation const testConnectionMutation = useMutation({ @@ -47,13 +46,15 @@ export const useStorageApi = ({ target, storage, project, onSubmit, onClose }: U body.id = storage.id; } - return api.callApi("validateStorage", { + const result = await api.callApi("validateStorage", { params: { target, type: connectionData.provider, }, body, }); + console.log(result); + return result; }, }); @@ -112,4 +113,4 @@ export const useStorageApi = ({ target, storage, project, onSubmit, onClose }: U isEditMode, action, }; -}; \ No newline at end of file +}; From 30197d9ca05f26260569b9cacce24b902cfe731a Mon Sep 17 00:00:00 2001 From: makseq Date: Mon, 28 Jul 2025 01:25:56 +0300 Subject: [PATCH 53/68] Fix permissions, page_size in gcs, use presinged urls text --- label_studio/io_storages/api.py | 3 ++- label_studio/io_storages/gcs/models.py | 1 - label_studio/io_storages/gcs/utils.py | 4 +--- label_studio/io_storages/s3/models.py | 1 + .../src/pages/Settings/StorageSettings/providers/azure.ts | 2 +- .../src/pages/Settings/StorageSettings/providers/gcs.ts | 2 +- .../src/pages/Settings/StorageSettings/providers/s3.ts | 2 +- 7 files changed, 7 insertions(+), 8 deletions(-) diff --git a/label_studio/io_storages/api.py b/label_studio/io_storages/api.py index 3d4e8641247b..faac2c9f4b94 100644 --- a/label_studio/io_storages/api.py +++ b/label_studio/io_storages/api.py @@ -158,7 +158,8 @@ def create(self, request, *args, **kwargs): class ImportStorageListFilesAPI(generics.CreateAPIView): - # permission_required = all_permissions.projects_change + + permission_required = all_permissions.projects_change parser_classes = (JSONParser, FormParser, MultiPartParser) serializer_class = None # Default serializer diff --git a/label_studio/io_storages/gcs/models.py b/label_studio/io_storages/gcs/models.py index c1105fb903f8..2609dcec722a 100644 --- a/label_studio/io_storages/gcs/models.py +++ b/label_studio/io_storages/gcs/models.py @@ -183,7 +183,6 @@ def iter_objects(self): prefix=self.prefix, regex_filter=self.regex_filter, return_key=False, - page_size=100, ) def iter_keys(self): diff --git a/label_studio/io_storages/gcs/utils.py b/label_studio/io_storages/gcs/utils.py index f3718d3633bc..84c536fe9946 100644 --- a/label_studio/io_storages/gcs/utils.py +++ b/label_studio/io_storages/gcs/utils.py @@ -115,7 +115,6 @@ def iter_blobs( regex_filter: str = None, limit: int = None, return_key: bool = False, - page_size: int = None, ): """ Iterate files on the bucket. Optionally return limited number of files that match provided extensions @@ -125,11 +124,10 @@ def iter_blobs( :param regex_filter: RegEx filter :param limit: specify limit for max files :param return_key: return object key string instead of gcs.Blob object - :param page_size: number of blobs to return per page :return: Iterator object """ total_read = 0 - blob_iter = client.list_blobs(bucket_name, prefix=prefix, page_size=page_size) + blob_iter = client.list_blobs(bucket_name, prefix=prefix) prefix = str(prefix) if prefix else '' regex = re.compile(str(regex_filter)) if regex_filter else None for blob in blob_iter: diff --git a/label_studio/io_storages/s3/models.py b/label_studio/io_storages/s3/models.py index 38010544c42f..e366afebfcd2 100644 --- a/label_studio/io_storages/s3/models.py +++ b/label_studio/io_storages/s3/models.py @@ -210,6 +210,7 @@ def iter_objects(self): if regex and not regex.match(key): logger.debug(key + ' is skipped by regex filter') continue + logger.debug(f's3 {key} has passed the regex filter') yield obj @catch_and_reraise_from_none diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/providers/azure.ts b/web/apps/labelstudio/src/pages/Settings/StorageSettings/providers/azure.ts index 39fc04d9f774..40f601e36908 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/providers/azure.ts +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/providers/azure.ts @@ -37,7 +37,7 @@ export const azureProvider: ProviderConfig = { { name: "presign", type: "toggle", - label: "Use pre-signed URLs (On)\n Proxy through the platform (Off)", + label: "Use pre-signed URLs (On) / Proxy through the platform (Off)", description: "When pre-signed URLs are enabled, all data bypasses the platform and user browsers directly read data from storage", schema: z.boolean().default(true), diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/providers/gcs.ts b/web/apps/labelstudio/src/pages/Settings/StorageSettings/providers/gcs.ts index 73b87469f1d1..59fcda06bfb1 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/providers/gcs.ts +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/providers/gcs.ts @@ -34,7 +34,7 @@ export const gcsProvider: ProviderConfig = { { name: "presign", type: "toggle", - label: "Use pre-signed URLs (On)\n Proxy through the platform (Off)", + label: "Use pre-signed URLs (On) / Proxy through the platform (Off)", description: "When pre-signed URLs are enabled, all data bypasses the platform and user browsers directly read data from storage", schema: z.boolean().default(true), diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/providers/s3.ts b/web/apps/labelstudio/src/pages/Settings/StorageSettings/providers/s3.ts index 2d782272bbf4..1e1a6de29a5d 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/providers/s3.ts +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/providers/s3.ts @@ -61,7 +61,7 @@ export const s3Provider: ProviderConfig = { { name: "presign", type: "toggle", - label: "Use pre-signed URLs", + label: "Use pre-signed URLs (On) / Proxy through the platform (Off)", description: "When pre-signed URLs are enabled, all data bypasses the platform and user browsers directly read data from storage", schema: z.boolean().default(true), From bd6cd460476710ced2b4ff5727f24d9d87a31fa0 Mon Sep 17 00:00:00 2001 From: makseq Date: Mon, 28 Jul 2025 01:36:49 +0300 Subject: [PATCH 54/68] Add customized placeholder for regex filter --- .../src/blocks/StorageProviderForm/Steps/preview-step.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx index ad5502e618fe..0ca6a796d248 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx @@ -108,7 +108,11 @@ export const PreviewStep = ({ name="regex_filter" value={formData.regex_filter ?? ""} onChange={handleChange} - placeholder=".*\.(jpg|png)$ - imports only JPG, PNG files" + placeholder={ + formData.use_blob_urls + ? ".*\\.(jpg|png)$ - imports only JPG, PNG files" + : ".*\\.(json|jsonl|parquet)$ - imports task definitions" + } style={{ width: "100%" }} label="" description="" From 51fa4ca0cdadc15c44a0f9f35991c18323fc2730 Mon Sep 17 00:00:00 2001 From: makseq Date: Mon, 28 Jul 2025 01:40:23 +0300 Subject: [PATCH 55/68] Linters --- label_studio/io_storages/api.py | 10 ++-- label_studio/io_storages/functions.py | 2 +- label_studio/io_storages/urls.py | 42 ++++++++++++----- .../Steps/preview-step.tsx | 47 +++++++++---------- .../StorageProviderForm/Steps/review-step.tsx | 16 +++---- .../src/blocks/StorageProviderForm/atoms.ts | 2 +- .../components/form-footer.tsx | 4 +- .../components/form-header.tsx | 2 +- .../StorageProviderForm/components/index.ts | 2 +- .../components/provider-grid.stories.tsx | 2 +- .../hooks/useStorageForm.ts | 15 +++++- .../src/blocks/StorageProviderForm/index.tsx | 12 +++-- .../blocks/StorageProviderForm/providers.ts | 8 +++- .../src/blocks/StorageProviderForm/schemas.ts | 6 +-- .../StorageProviderForm/types/common.ts | 4 +- web/libs/ui/package.json | 6 +-- .../ui/src/shad/components/ui/radio-group.tsx | 30 +++++------- 17 files changed, 120 insertions(+), 90 deletions(-) diff --git a/label_studio/io_storages/api.py b/label_studio/io_storages/api.py index faac2c9f4b94..6a97a54c3cee 100644 --- a/label_studio/io_storages/api.py +++ b/label_studio/io_storages/api.py @@ -158,7 +158,7 @@ def create(self, request, *args, **kwargs): class ImportStorageListFilesAPI(generics.CreateAPIView): - + permission_required = all_permissions.projects_change parser_classes = (JSONParser, FormParser, MultiPartParser) serializer_class = None # Default serializer @@ -166,7 +166,7 @@ class ImportStorageListFilesAPI(generics.CreateAPIView): def __init__(self, serializer_class=None, *args, **kwargs): self.serializer_class = serializer_class super().__init__(*args, **kwargs) - + def create(self, request, *args, **kwargs): from .functions import validate_storage_instance @@ -177,15 +177,15 @@ def create(self, request, *args, **kwargs): files = [] start_time = time.time() timeout_seconds = 30 - + for object in instance.iter_objects(): files.append(instance.get_unified_metadata(object)) - + # Check if we've reached the file limit if len(files) >= limit: files.append({'key': None, 'last_modified': None, 'size': None}) break - + # Check if we've exceeded the timeout if time.time() - start_time > timeout_seconds: files.append({'key': '... storage scan timeout reached ...', 'last_modified': None, 'size': None}) diff --git a/label_studio/io_storages/functions.py b/label_studio/io_storages/functions.py index b986a41da7ee..e2a11a3a6601 100644 --- a/label_studio/io_storages/functions.py +++ b/label_studio/io_storages/functions.py @@ -34,7 +34,7 @@ def validate_storage_instance(request, serializer_class): """ if not serializer_class or not hasattr(serializer_class, 'Meta'): raise ValidationError('Invalid or missing serializer class') - + storage_id = request.data.get('id') instance = None diff --git a/label_studio/io_storages/urls.py b/label_studio/io_storages/urls.py index 54c43f94d206..42e686eb8ff1 100644 --- a/label_studio/io_storages/urls.py +++ b/label_studio/io_storages/urls.py @@ -3,13 +3,13 @@ from django.conf import settings from django.urls import include, path from io_storages import proxy_api -from io_storages.api import ImportStorageListFilesAPI from io_storages.all_api import ( AllExportStorageListAPI, AllExportStorageTypesAPI, AllImportStorageListAPI, AllImportStorageTypesAPI, ) +from io_storages.api import ImportStorageListFilesAPI from io_storages.azure_blob.api import ( AzureBlobExportStorageDetailAPI, AzureBlobExportStorageFormLayoutAPI, @@ -19,9 +19,9 @@ AzureBlobImportStorageDetailAPI, AzureBlobImportStorageFormLayoutAPI, AzureBlobImportStorageListAPI, + AzureBlobImportStorageSerializer, AzureBlobImportStorageSyncAPI, AzureBlobImportStorageValidateAPI, - AzureBlobImportStorageSerializer, ) from io_storages.gcs.api import ( GCSExportStorageDetailAPI, @@ -32,9 +32,9 @@ GCSImportStorageDetailAPI, GCSImportStorageFormLayoutAPI, GCSImportStorageListAPI, + GCSImportStorageSerializer, GCSImportStorageSyncAPI, GCSImportStorageValidateAPI, - GCSImportStorageSerializer, ) from io_storages.localfiles.api import ( LocalFilesExportStorageDetailAPI, @@ -45,9 +45,9 @@ LocalFilesImportStorageDetailAPI, LocalFilesImportStorageFormLayoutAPI, LocalFilesImportStorageListAPI, + LocalFilesImportStorageSerializer, LocalFilesImportStorageSyncAPI, LocalFilesImportStorageValidateAPI, - LocalFilesImportStorageSerializer, ) from io_storages.redis.api import ( RedisExportStorageDetailAPI, @@ -58,9 +58,9 @@ RedisImportStorageDetailAPI, RedisImportStorageFormLayoutAPI, RedisImportStorageListAPI, + RedisImportStorageSerializer, RedisImportStorageSyncAPI, RedisImportStorageValidateAPI, - RedisImportStorageSerializer, ) from io_storages.s3.api import ( S3ExportStorageDetailAPI, @@ -71,9 +71,9 @@ S3ImportStorageDetailAPI, S3ImportStorageFormLayoutAPI, S3ImportStorageListAPI, + S3ImportStorageSerializer, S3ImportStorageSyncAPI, S3ImportStorageValidateAPI, - S3ImportStorageSerializer, ) app_name = 'storages' @@ -91,7 +91,11 @@ path('s3//sync', S3ImportStorageSyncAPI.as_view(), name='storage-s3-sync'), path('s3/validate', S3ImportStorageValidateAPI.as_view(), name='storage-s3-validate'), path('s3/form', S3ImportStorageFormLayoutAPI.as_view(), name='storage-s3-form'), - path('s3/files', ImportStorageListFilesAPI().as_view(serializer_class=S3ImportStorageSerializer), name='storage-s3-list-files'), + path( + 's3/files', + ImportStorageListFilesAPI().as_view(serializer_class=S3ImportStorageSerializer), + name='storage-s3-list-files', + ), path('export/s3', S3ExportStorageListAPI.as_view(), name='export-storage-s3-list'), path('export/s3/', S3ExportStorageDetailAPI.as_view(), name='export-storage-s3-detail'), path('export/s3//sync', S3ExportStorageSyncAPI.as_view(), name='export-storage-s3-sync'), @@ -103,7 +107,11 @@ path('azure//sync', AzureBlobImportStorageSyncAPI.as_view(), name='storage-azure-sync'), path('azure/validate', AzureBlobImportStorageValidateAPI.as_view(), name='storage-azure-validate'), path('azure/form', AzureBlobImportStorageFormLayoutAPI.as_view(), name='storage-azure-form'), - path('azure/files', ImportStorageListFilesAPI().as_view(serializer_class=AzureBlobImportStorageSerializer), name='storage-azure-list-files'), + path( + 'azure/files', + ImportStorageListFilesAPI().as_view(serializer_class=AzureBlobImportStorageSerializer), + name='storage-azure-list-files', + ), path('export/azure', AzureBlobExportStorageListAPI.as_view(), name='export-storage-azure-list'), path('export/azure/', AzureBlobExportStorageDetailAPI.as_view(), name='export-storage-azure-detail'), path('export/azure//sync', AzureBlobExportStorageSyncAPI.as_view(), name='export-storage-azure-sync'), @@ -115,7 +123,11 @@ path('gcs//sync', GCSImportStorageSyncAPI.as_view(), name='storage-gcs-sync'), path('gcs/validate', GCSImportStorageValidateAPI.as_view(), name='storage-gcs-validate'), path('gcs/form', GCSImportStorageFormLayoutAPI.as_view(), name='storage-gcs-form'), - path('gcs/files', ImportStorageListFilesAPI().as_view(serializer_class=GCSImportStorageSerializer), name='storage-gcs-list-files'), + path( + 'gcs/files', + ImportStorageListFilesAPI().as_view(serializer_class=GCSImportStorageSerializer), + name='storage-gcs-list-files', + ), path('export/gcs', GCSExportStorageListAPI.as_view(), name='export-storage-gcs-list'), path('export/gcs/', GCSExportStorageDetailAPI.as_view(), name='export-storage-gcs-detail'), path('export/gcs//sync', GCSExportStorageSyncAPI.as_view(), name='export-storage-gcs-sync'), @@ -127,7 +139,11 @@ path('redis//sync', RedisImportStorageSyncAPI.as_view(), name='storage-redis-sync'), path('redis/validate', RedisImportStorageValidateAPI.as_view(), name='storage-redis-validate'), path('redis/form', RedisImportStorageFormLayoutAPI.as_view(), name='storage-redis-form'), - path('redis/files', ImportStorageListFilesAPI().as_view(serializer_class=RedisImportStorageSerializer), name='storage-redis-list-files'), + path( + 'redis/files', + ImportStorageListFilesAPI().as_view(serializer_class=RedisImportStorageSerializer), + name='storage-redis-list-files', + ), path('export/redis', RedisExportStorageListAPI.as_view(), name='export-storage-redis-list'), path('export/redis/', RedisExportStorageDetailAPI.as_view(), name='export-storage-redis-detail'), path('export/redis//sync', RedisExportStorageSyncAPI.as_view(), name='export-storage-redis-sync'), @@ -142,7 +158,11 @@ path('localfiles//sync', LocalFilesImportStorageSyncAPI.as_view(), name='storage-localfiles-sync'), path('localfiles/validate', LocalFilesImportStorageValidateAPI.as_view(), name='storage-localfiles-validate'), path('localfiles/form', LocalFilesImportStorageFormLayoutAPI.as_view(), name='storage-localfiles-form'), - path('localfiles/files', ImportStorageListFilesAPI().as_view(serializer_class=LocalFilesImportStorageSerializer), name='storage-localfiles-list-files'), + path( + 'localfiles/files', + ImportStorageListFilesAPI().as_view(serializer_class=LocalFilesImportStorageSerializer), + name='storage-localfiles-list-files', + ), path('export/localfiles', LocalFilesExportStorageListAPI.as_view(), name='export-storage-localfiles-list'), path( 'export/localfiles/', diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx index 0ca6a796d248..a3c64cf6425d 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx @@ -2,7 +2,7 @@ import { Label, Toggle, Select } from "@humansignal/ui"; import { Form, Input } from "apps/labelstudio/src/components/Form"; import { IconDocument, IconSearch } from "@humansignal/icons"; import { formatDistanceToNow } from "date-fns"; -import { type ForwardedRef } from "react"; +import type { ForwardedRef } from "react"; import { InlineError } from "apps/labelstudio/src/components/Error/InlineError"; interface PreviewStepProps { @@ -72,10 +72,9 @@ export const PreviewStep = ({
@@ -366,8 +367,8 @@ export const PreviewStep = ({

No Files Found

- No files matching your current criteria were found. Try adjusting your filter settings and reload - the preview. + No files matching your current criteria were found. Try adjusting your filter settings and reload the + preview.

) : ( @@ -380,14 +381,12 @@ export const PreviewStep = ({ className="flex justify-between py-0.5 px-2 bg-gray-50 hover:bg-gray-100 border-b last:border-b-0 rounded-md" >
- {file.key ? ( - file.key - ) : ( - ... preview limit reached ... - )} + {file.key ? file.key : ... preview limit reached ...}
- {file.last_modified && formatDistanceToNow(new Date(file.last_modified), { addSuffix: true })} + + {file.last_modified && formatDistanceToNow(new Date(file.last_modified), { addSuffix: true })} + {file.size && formatSize(file.size)}
@@ -403,4 +402,4 @@ export const PreviewStep = ({
); -}; \ No newline at end of file +}; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/review-step.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/review-step.tsx index 0565307a19f5..3f3b56acf122 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/review-step.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/review-step.tsx @@ -22,35 +22,35 @@ export const ReviewStep = ({ formData, filesPreview, formatSize }: ReviewStepPro const getFileCount = () => { if (!filesPreview) return "0 files"; - + // Check if the last file is the "preview limit reached" indicator const lastFile = filesPreview[filesPreview.length - 1]; const hasMoreFiles = lastFile && lastFile.key === null; - + if (hasMoreFiles) { // Subtract 1 to exclude the placeholder file const visibleFileCount = filesPreview.length - 1; return `More than ${visibleFileCount} files`; } - + return `${filesPreview.length} files`; }; const getTotalSize = () => { if (!filesPreview || !formatSize) return "0 Bytes"; - + // Check if the last file is the "preview limit reached" indicator const lastFile = filesPreview[filesPreview.length - 1]; const hasMoreFiles = lastFile && lastFile.key === null; - + // Calculate total size excluding the placeholder file if it exists const filesToCount = hasMoreFiles ? filesPreview.slice(0, -1) : filesPreview; const totalBytes = filesToCount.reduce((sum: number, file: any) => sum + (file.size || 0), 0); - + if (hasMoreFiles) { return `More than ${formatSize(totalBytes)}`; } - + return formatSize(totalBytes); }; @@ -104,4 +104,4 @@ export const ReviewStep = ({ formData, filesPreview, formatSize }: ReviewStepPro
); -}; \ No newline at end of file +}; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/atoms.ts b/web/libs/app-common/src/blocks/StorageProviderForm/atoms.ts index 3633f1a46406..f4c01c9826eb 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/atoms.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/atoms.ts @@ -25,4 +25,4 @@ export const formStateAtom = atom({ regex_filter: "", }, isComplete: false, -}); \ No newline at end of file +}); diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/form-footer.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/components/form-footer.tsx index a6551b2e0618..8acb63992128 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/components/form-footer.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/components/form-footer.tsx @@ -66,9 +66,7 @@ export const FormFooter = ({ diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/form-header.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/components/form-header.tsx index 7125753c62f2..9244b4c6afb7 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/components/form-header.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/components/form-header.tsx @@ -18,4 +18,4 @@ export const FormHeader = ({ title, onClose }: FormHeaderProps) => {
); -}; \ No newline at end of file +}; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/index.ts b/web/libs/app-common/src/blocks/StorageProviderForm/components/index.ts index f097f04ae552..c72e576cfa83 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/components/index.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/components/index.ts @@ -1,2 +1,2 @@ export { ProviderGrid } from "./provider-grid"; -export { default as ProviderGridStories } from "./provider-grid.stories"; \ No newline at end of file +export { default as ProviderGridStories } from "./provider-grid.stories"; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-grid.stories.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-grid.stories.tsx index c8c2c62478ae..83d565160b7d 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-grid.stories.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/components/provider-grid.stories.tsx @@ -71,4 +71,4 @@ export const Loading: Story = { providers: [], disabled: true, }, -}; \ No newline at end of file +}; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/hooks/useStorageForm.ts b/web/libs/app-common/src/blocks/StorageProviderForm/hooks/useStorageForm.ts index f8a8331179e6..f0008e537e5a 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/hooks/useStorageForm.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/hooks/useStorageForm.ts @@ -181,7 +181,20 @@ export const useStorageForm = ({ project, isEditMode, steps, storage }: UseStora }); // Reset validation state when connection settings change - const connectionFields = ['bucket', 'container', 'path', 'host', 'port', 'db', 'password', 'account_name', 'account_key', 'google_application_credentials', 'region_name', 's3_endpoint']; + const connectionFields = [ + "bucket", + "container", + "path", + "host", + "port", + "db", + "password", + "account_name", + "account_key", + "google_application_credentials", + "region_name", + "s3_endpoint", + ]; if (connectionFields.includes(name)) { onConnectionChange?.(); } diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/index.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/index.tsx index dc0f070ac567..4e0a7abaf1c6 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/index.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/index.tsx @@ -93,7 +93,7 @@ export const StorageProviderForm = forwardRef setType("s3"); onHide(); }; - + // Call onHide immediately to set up the handler handleModalHide(); } @@ -232,9 +232,15 @@ export const StorageProviderForm = forwardRef handleChange={(e) => { const { name, value } = e.target as HTMLInputElement; handleProviderFieldChange(name, value); - + // Reset validation state when import settings change - const importSettingsFields = ['prefix', 'path', 'regex_filter', 'use_blob_urls', 'recursive_scan']; + const importSettingsFields = [ + "prefix", + "path", + "regex_filter", + "use_blob_urls", + "recursive_scan", + ]; if (importSettingsFields.includes(name)) { setFilesPreview(null); setConnectionChecked(false); diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/providers.ts b/web/libs/app-common/src/blocks/StorageProviderForm/providers.ts index 531836be9aff..f056e4441340 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/providers.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/providers.ts @@ -22,7 +22,9 @@ export function getProviderSchema(providerName: string) { if (!config) { return z.object({}); // Empty schema for unknown providers } - const fieldDefinitions = config.fields.filter((field): field is FieldDefinition => 'type' in field && field.type !== 'message'); + const fieldDefinitions = config.fields.filter( + (field): field is FieldDefinition => "type" in field && field.type !== "message", + ); return assembleSchema(fieldDefinitions); } @@ -32,7 +34,9 @@ export function getProviderDefaultValues(providerName: string): Record 'type' in field && field.type !== 'message'); + const fieldDefinitions = config.fields.filter( + (field): field is FieldDefinition => "type" in field && field.type !== "message", + ); return extractDefaultValues(fieldDefinitions); } diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/schemas.ts b/web/libs/app-common/src/blocks/StorageProviderForm/schemas.ts index c2e8bb6af873..abd8be778030 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/schemas.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/schemas.ts @@ -27,10 +27,10 @@ export const getProviderSchema = (provider: string, isEditMode = false) => { ]; // Filter out message fields and combine with common fields - const providerFields = providerConfig.fields.filter((field): field is FieldDefinition => - 'type' in field && field.type !== 'message' + const providerFields = providerConfig.fields.filter( + (field): field is FieldDefinition => "type" in field && field.type !== "message", ); - + const allFields = [...commonFields, ...providerFields]; return assembleSchema(allFields, isEditMode); }; diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/types/common.ts b/web/libs/app-common/src/blocks/StorageProviderForm/types/common.ts index 0242511fbe9b..4f77c3224cb7 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/types/common.ts +++ b/web/libs/app-common/src/blocks/StorageProviderForm/types/common.ts @@ -1,6 +1,6 @@ import type { CalloutVariant } from "@humansignal/ui"; import type { FC } from "react"; -import { z } from "zod"; +import type { z } from "zod"; // Field types that can be rendered export type FieldType = "text" | "password" | "number" | "select" | "toggle" | "counter" | "textarea"; @@ -45,4 +45,4 @@ export interface ProviderConfig { fields: (FieldDefinition | MessageDefinition)[]; layout: LayoutRow[]; icon?: FC; -} \ No newline at end of file +} diff --git a/web/libs/ui/package.json b/web/libs/ui/package.json index 01edc6039b65..2f20fa771342 100644 --- a/web/libs/ui/package.json +++ b/web/libs/ui/package.json @@ -4,11 +4,7 @@ "license": "MIT", "private": true, "main": "src/index.ts", - "files": [ - "src/tailwind.css", - "src/shad/components", - "src/ui/assets" - ], + "files": ["src/tailwind.css", "src/shad/components", "src/ui/assets"], "dependencies": { "@radix-ui/react-radio-group": "^1.3.7" } diff --git a/web/libs/ui/src/shad/components/ui/radio-group.tsx b/web/libs/ui/src/shad/components/ui/radio-group.tsx index 04d57a34dcad..30617ec8721b 100644 --- a/web/libs/ui/src/shad/components/ui/radio-group.tsx +++ b/web/libs/ui/src/shad/components/ui/radio-group.tsx @@ -1,22 +1,16 @@ -import * as React from "react" -import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" -import { Circle } from "lucide-react" +import * as React from "react"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { Circle } from "lucide-react"; -import { cn } from "@humansignal/shad/utils" +import { cn } from "@humansignal/shad/utils"; const RadioGroup = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => { - return ( - - ) -}) -RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + return ; +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; const RadioGroupItem = React.forwardRef< React.ElementRef, @@ -27,7 +21,7 @@ const RadioGroupItem = React.forwardRef< ref={ref} className={cn( "aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", - className + className, )} {...props} > @@ -35,8 +29,8 @@ const RadioGroupItem = React.forwardRef< - ) -}) -RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; -export { RadioGroup, RadioGroupItem } +export { RadioGroup, RadioGroupItem }; From 66cbd0b4d2ff87a2d8a1ad90423931a98c41d43c Mon Sep 17 00:00:00 2001 From: makseq Date: Mon, 28 Jul 2025 02:44:43 +0300 Subject: [PATCH 56/68] Replace data.heartex.net to s3 bucket file --- label_studio/tests/data_import.tavern.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/label_studio/tests/data_import.tavern.yml b/label_studio/tests/data_import.tavern.yml index 7512843e3a54..09c957f5473f 100644 --- a/label_studio/tests/data_import.tavern.yml +++ b/label_studio/tests/data_import.tavern.yml @@ -169,7 +169,7 @@ stages: - name: stage request: data: - url: https://data.heartex.net/q/fffeaf81f4.json + url: https://hs-sandbox-pub.s3.us-east-1.amazonaws.com/pytests/fffeaf81f4.json headers: content-type: application/x-www-form-urlencoded method: POST From ad10d2dfcabcb4f7d4d144a66ada11dc920f99dd Mon Sep 17 00:00:00 2001 From: makseq Date: Mon, 28 Jul 2025 03:08:34 +0300 Subject: [PATCH 57/68] Fix docs and all urls json --- label_studio/core/all_urls.json | 30 ++++++++++++++++++++++++++++++ label_studio/io_storages/api.py | 1 + 2 files changed, 31 insertions(+) diff --git a/label_studio/core/all_urls.json b/label_studio/core/all_urls.json index 2b4d0e6dc840..b78b98c3a2ba 100644 --- a/label_studio/core/all_urls.json +++ b/label_studio/core/all_urls.json @@ -677,6 +677,12 @@ "name": "storages:api:storage-s3-form", "decorators": "" }, + { + "url": "/api/storages/s3/files", + "module": "io_storages.api.ImportStorageListFilesAPI", + "name": "storages:api:storage-s3-list-files", + "decorators": "" + }, { "url": "/api/storages/export/s3", "module": "io_storages.s3.api.S3ExportStorageListAPI", @@ -737,6 +743,12 @@ "name": "storages:api:storage-azure-form", "decorators": "" }, + { + "url": "/api/storages/azure/files", + "module": "io_storages.api.ImportStorageListFilesAPI", + "name": "storages:api:storage-azure-list-files", + "decorators": "" + }, { "url": "/api/storages/export/azure", "module": "io_storages.azure_blob.api.AzureBlobExportStorageListAPI", @@ -797,6 +809,12 @@ "name": "storages:api:storage-gcs-form", "decorators": "" }, + { + "url": "/api/storages/gcs/files", + "module": "io_storages.api.ImportStorageListFilesAPI", + "name": "storages:api:storage-gcs-list-files", + "decorators": "" + }, { "url": "/api/storages/export/gcs", "module": "io_storages.gcs.api.GCSExportStorageListAPI", @@ -857,6 +875,12 @@ "name": "storages:api:storage-redis-form", "decorators": "" }, + { + "url": "/api/storages/redis/files", + "module": "io_storages.api.ImportStorageListFilesAPI", + "name": "storages:api:storage-redis-list-files", + "decorators": "" + }, { "url": "/api/storages/export/redis", "module": "io_storages.redis.api.RedisExportStorageListAPI", @@ -917,6 +941,12 @@ "name": "storages:api:storage-localfiles-form", "decorators": "" }, + { + "url": "/api/storages/localfiles/files", + "module": "io_storages.api.ImportStorageListFilesAPI", + "name": "storages:api:storage-localfiles-list-files", + "decorators": "" + }, { "url": "/api/storages/export/localfiles", "module": "io_storages.localfiles.api.LocalFilesExportStorageListAPI", diff --git a/label_studio/io_storages/api.py b/label_studio/io_storages/api.py index 6a97a54c3cee..a80f854fc3ae 100644 --- a/label_studio/io_storages/api.py +++ b/label_studio/io_storages/api.py @@ -167,6 +167,7 @@ def __init__(self, serializer_class=None, *args, **kwargs): self.serializer_class = serializer_class super().__init__(*args, **kwargs) + @extend_schema(exclude=True) def create(self, request, *args, **kwargs): from .functions import validate_storage_instance From a4f45ff6a2b298021a79c98741ee435563b91f46 Mon Sep 17 00:00:00 2001 From: makseq Date: Mon, 28 Jul 2025 03:26:20 +0300 Subject: [PATCH 58/68] Fix api docs in pytest --- label_studio/io_storages/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/label_studio/io_storages/api.py b/label_studio/io_storages/api.py index a80f854fc3ae..0164ed36357b 100644 --- a/label_studio/io_storages/api.py +++ b/label_studio/io_storages/api.py @@ -157,6 +157,7 @@ def create(self, request, *args, **kwargs): return Response() +@extend_schema(exclude=True) class ImportStorageListFilesAPI(generics.CreateAPIView): permission_required = all_permissions.projects_change From 209dfe665d6c559398a282a7d1966759f52bf4ca Mon Sep 17 00:00:00 2001 From: makseq Date: Mon, 28 Jul 2025 11:45:32 +0300 Subject: [PATCH 59/68] Update instructions --- .cursor/rules/storage-provider.mdc | 397 ++++++++++++++++++++++++++++- 1 file changed, 392 insertions(+), 5 deletions(-) diff --git a/.cursor/rules/storage-provider.mdc b/.cursor/rules/storage-provider.mdc index 78aa59ce94ca..fe26f031e225 100644 --- a/.cursor/rules/storage-provider.mdc +++ b/.cursor/rules/storage-provider.mdc @@ -1,14 +1,330 @@ ---- -description: Set of rules to maintain and extend cloud storage provides like S3, Azure, etc. -globs: -alwaysApply: false ---- # Cursor Rule: Implementing New Storage Providers in Label Studio ## Overview This rule describes the process and best practices for adding a new storage provider to Label Studio using the declarative provider schema system. +See comprehensive overview about storages @io_storages/README.md. + +## Architecture Overview + +Label Studio supports 2 types of cloud storages: +1. **Import Storages** (Source Cloud Storages) - for importing tasks/data +2. **Export Storages** (Target Cloud Storages) - for exporting annotations + +Each storage type follows this inheritance hierarchy: +```mermaid +graph TD + Storage-->ImportStorage + Storage-->ExportStorage + + ProjectStorageMixin-->NewImportStorage + ImportStorage-->NewImportStorageBase + + NewImportStorageBase-->NewImportStorage + + subgraph New Provider + NewImportStorage + NewImportStorageBase + NewExportStorage + end +``` + +## Backend Implementation + +### 1. Create Storage Models + +#### File Structure +Create these files in `label_studio/io_storages/yourprovider/`: +- `__init__.py` +- `models.py` - Core storage models +- `serializers.py` - API serializers +- `api.py` - API views +- `utils.py` - Provider-specific utilities +- `form_layout.yml` - Form layout (optional, for compatibility) + +#### Storage Mixin Pattern +```python +# models.py +import logging +from django.db import models +from django.utils.translation import gettext_lazy as _ +from io_storages.base_models import ( + ImportStorage, ExportStorage, ImportStorageLink, ExportStorageLink, + ProjectStorageMixin + ) +from io_storages.utils import StorageObject, load_tasks_json + +logger = logging.getLogger(__name__) + +class YourProviderStorageMixin(models.Model): + """Base mixin containing common fields for your provider""" + + # Common fields + bucket = models.TextField(_('bucket'), null=True, blank=True, help_text='Bucket name') + prefix = models.TextField(_('prefix'), null=True, blank=True, help_text='Bucket prefix') + regex_filter = models.TextField(_('regex_filter'), null=True, blank=True, + help_text='Cloud storage regex for filtering objects') + use_blob_urls = models.BooleanField(_('use_blob_urls'), default=False, + help_text='Interpret objects as BLOBs and generate URLs') + + # Provider-specific credentials + api_key = models.TextField(_('api_key'), null=True, blank=True, help_text='API Key') + secret_key = models.TextField(_('secret_key'), null=True, blank=True, help_text='Secret Key') + endpoint_url = models.TextField(_('endpoint_url'), null=True, blank=True, help_text='API Endpoint') + + def get_client(self): + """Initialize and return provider client""" + # Implement provider-specific client initialization + # Cache clients to avoid repeated initialization + pass + + def validate_connection(self, client=None): + """Validate storage connection and credentials""" + # Required method - implement provider-specific validation + # Should raise appropriate exceptions for different error types + pass + + class Meta: + abstract = True + +class YourProviderImportStorageBase(YourProviderStorageMixin, ImportStorage): + """Base class for import functionality""" + + def iter_objects(self): + """Iterate over storage objects""" + # Implement provider-specific object iteration + # Apply regex_filter if specified + # Skip directories and empty objects + pass + + def get_data(self, key) -> list[StorageObject]: + """Get task data from storage object""" + uri = f'{self.url_scheme}://{self.bucket}/{key}' + if self.use_blob_urls: + # Return blob URL + data_key = settings.DATA_UNDEFINED_NAME + task = {data_key: uri} + return [StorageObject(key=key, task_data=task)] + + # Load and parse JSON task data + obj_data = self.get_object_data(key) + return load_tasks_json(obj_data, key) + + def generate_http_url(self, url): + """Generate HTTP URL for storage object""" + # Implement provider-specific URL generation + # Support both presigned URLs and proxy mode + pass + + def can_resolve_url(self, url: str) -> bool: + """Check if this storage can resolve given URL""" + # Check if URL matches this storage's pattern + pass + + class Meta: + abstract = True + +class YourProviderImportStorage(ProjectStorageMixin, YourProviderImportStorageBase): + """Concrete import storage implementation""" + class Meta: + abstract = False + +class YourProviderExportStorage(YourProviderStorageMixin, ExportStorage): + """Export storage implementation""" + + def save_annotation(self, annotation): + """Save annotation to storage""" + # Serialize annotation data + ser_annotation = self._get_serialized_data(annotation) + + # Generate storage key + key = YourProviderExportStorageLink.get_key(annotation) + if self.prefix: + key = f"{self.prefix}/{key}" + + # Save to storage + # Handle provider-specific upload logic + + # Create storage link + YourProviderExportStorageLink.create(annotation, self) + + def delete_annotation(self, annotation): + """Delete annotation from storage""" + # Delete from storage + # Remove storage link + pass + +# Storage link models +class YourProviderImportStorageLink(ImportStorageLink): + storage = models.ForeignKey(YourProviderImportStorage, on_delete=models.CASCADE, related_name='links') + +class YourProviderExportStorageLink(ExportStorageLink): + storage = models.ForeignKey(YourProviderExportStorage, on_delete=models.CASCADE, related_name='links') + +# Signal handlers for automatic export +from django.db.models.signals import post_save, pre_delete +from django.dispatch import receiver +from tasks.models import Annotation + +@receiver(post_save, sender=Annotation) +def export_annotation_to_yourprovider_storages(sender, instance, **kwargs): + # Auto-export logic + pass + +@receiver(pre_delete, sender=Annotation) +def delete_annotation_from_yourprovider_storages(sender, instance, **kwargs): + # Auto-delete logic + pass +``` + +### 2. Create Serializers + +```python +# serializers.py +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from io_storages.serializers import ImportStorageSerializer, ExportStorageSerializer +from .models import YourProviderImportStorage, YourProviderExportStorage + +class YourProviderStorageSerializerMixin: + """Common serializer functionality""" + secure_fields = ['api_key', 'secret_key'] # Fields to hide in responses + + def to_representation(self, instance): + result = super().to_representation(instance) + # Hide secure fields in responses + for field in self.secure_fields: + result.pop(field, None) + return result + + def validate(self, data): + """Validate storage configuration""" + data = super().validate(data) + + # Create temporary storage instance for validation + storage = self.instance or self.Meta.model(**data) + if self.instance: + for key, value in data.items(): + setattr(storage, key, value) + + try: + storage.validate_connection() + except Exception as e: + raise ValidationError(f"Connection failed: {str(e)}") + + return data + +class YourProviderImportStorageSerializer(YourProviderStorageSerializerMixin, ImportStorageSerializer): + type = serializers.ReadOnlyField(default=os.path.basename(os.path.dirname(__file__))) + + class Meta: + model = YourProviderImportStorage + fields = '__all__' + +class YourProviderExportStorageSerializer(YourProviderStorageSerializerMixin, ExportStorageSerializer): + type = serializers.ReadOnlyField(default=os.path.basename(os.path.dirname(__file__))) + + class Meta: + model = YourProviderExportStorage + fields = '__all__' +``` + +### 3. Create API Views + +```python +# api.py +from django.utils.decorators import method_decorator +from drf_spectacular.utils import extend_schema +from io_storages.api import ( + ImportStorageListAPI, ImportStorageDetailAPI, ImportStorageSyncAPI, + ImportStorageValidateAPI, ImportStorageFormLayoutAPI, + ExportStorageListAPI, ExportStorageDetailAPI, ExportStorageSyncAPI, + ExportStorageValidateAPI, ExportStorageFormLayoutAPI +) +from .models import YourProviderImportStorage, YourProviderExportStorage +from .serializers import YourProviderImportStorageSerializer, YourProviderExportStorageSerializer + +@method_decorator( + name='get', + decorator=extend_schema( + tags=['Storage: YourProvider'], + summary='List YourProvider import storage', + description='Get a list of all YourProvider import storage connections.', + ), +) +@method_decorator( + name='post', + decorator=extend_schema( + tags=['Storage: YourProvider'], + summary='Create new YourProvider storage', + description='Create new YourProvider import storage', + ), +) +class YourProviderImportStorageListAPI(ImportStorageListAPI): + queryset = YourProviderImportStorage.objects.all() + serializer_class = YourProviderImportStorageSerializer + +class YourProviderImportStorageDetailAPI(ImportStorageDetailAPI): + queryset = YourProviderImportStorage.objects.all() + serializer_class = YourProviderImportStorageSerializer + +class YourProviderImportStorageSyncAPI(ImportStorageSyncAPI): + serializer_class = YourProviderImportStorageSerializer + +class YourProviderImportStorageValidateAPI(ImportStorageValidateAPI): + serializer_class = YourProviderImportStorageSerializer + +class YourProviderImportStorageFormLayoutAPI(ImportStorageFormLayoutAPI): + pass + +# Export APIs follow same pattern +class YourProviderExportStorageListAPI(ExportStorageListAPI): + queryset = YourProviderExportStorage.objects.all() + serializer_class = YourProviderExportStorageSerializer + +# ... other export APIs +``` + +### 4. Register URLs + +Add to `label_studio/io_storages/urls.py`: + +```python +# In urlpatterns, add: +path('api/storages/yourprovider/', include(('io_storages.yourprovider.urls', 'io_storages'), namespace='yourprovider-api')), + +# Import the APIs at the top +from io_storages.yourprovider.api import ( + YourProviderImportStorageListAPI, + YourProviderImportStorageDetailAPI, + # ... other APIs +) +``` + +Create `label_studio/io_storages/yourprovider/urls.py`: + +```python +from django.urls import path +from . import api + +urlpatterns = [ + # Import storage URLs + path('import/', api.YourProviderImportStorageListAPI.as_view(), name='yourprovider-import-list'), + path('import//', api.YourProviderImportStorageDetailAPI.as_view(), name='yourprovider-import-detail'), + path('import//sync/', api.YourProviderImportStorageSyncAPI.as_view(), name='yourprovider-import-sync'), + path('import/validate/', api.YourProviderImportStorageValidateAPI.as_view(), name='yourprovider-import-validate'), + path('import/form-layout/', api.YourProviderImportStorageFormLayoutAPI.as_view(), name='yourprovider-import-form-layout'), + + # Export storage URLs + path('export/', api.YourProviderExportStorageListAPI.as_view(), name='yourprovider-export-list'), + path('export//', api.YourProviderExportStorageDetailAPI.as_view(), name='yourprovider-export-detail'), + path('export//sync/', api.YourProviderExportStorageSyncAPI.as_view(), name='yourprovider-export-sync'), + path('export/validate/', api.YourProviderExportStorageValidateAPI.as_view(), name='yourprovider-export-validate'), + path('export/form-layout/', api.YourProviderExportStorageFormLayoutAPI.as_view(), name='yourprovider-export-form-layout'), +] +``` + ## Steps to Add a New Storage Provider 1. **Create a Provider Config File** @@ -75,3 +391,74 @@ This rule describes the process and best practices for adding a new storage prov - Keep field and layout definitions minimal and focused on provider-specific configuration. - Test your provider in both create and edit modes to ensure correct behavior. +## Testing + +Create tests in `label_studio/io_storages/tests/test_yourprovider.py`: + +```python +from django.test import TestCase +from io_storages.yourprovider.models import YourProviderImportStorage + +class TestYourProviderStorage(TestCase): + def test_connection_validation(self): + # Test connection validation logic + pass + + def test_object_iteration(self): + # Test object listing and filtering + pass + + def test_data_loading(self): + # Test task data loading + pass +``` + +## Implementation Checklist + +### Backend Implementation +- [ ] Create provider directory structure +- [ ] Implement storage mixin with common fields +- [ ] Create import storage base class with required methods: + - [ ] `iter_objects()` - iterate over storage objects + - [ ] `get_data()` - load task data from objects + - [ ] `generate_http_url()` - create HTTP URLs + - [ ] `can_resolve_url()` - check URL resolution capability + - [ ] `validate_connection()` - validate credentials and connectivity +- [ ] Create concrete import/export storage classes +- [ ] Implement storage link models +- [ ] Create serializers with validation logic +- [ ] Implement API views following existing patterns +- [ ] Register URLs in storage URL configuration +- [ ] Add signal handlers for auto-export functionality +- [ ] Create database migrations + +### Frontend Implementation +- [ ] Create provider configuration file with: + - [ ] All required fields with proper types + - [ ] Zod validation schemas + - [ ] Meaningful labels and placeholders + - [ ] Proper field layout definition +- [ ] Register provider in central registry +- [ ] Mark credential fields with `accessKey: true` +- [ ] Test form rendering and validation +- [ ] Verify edit mode behavior for credentials + +### Testing & Documentation +- [ ] Write backend unit tests +- [ ] Test connection validation +- [ ] Test object iteration and filtering +- [ ] Test task data loading +- [ ] Test frontend form functionality +- [ ] Test both create and edit modes +- [ ] Update API documentation +- [ ] Add provider to storage documentation + +### Integration & Deployment +- [ ] Test end-to-end storage workflow +- [ ] Verify task import/export functionality +- [ ] Test URL resolution and proxy functionality +- [ ] Test with both presigned URLs and proxy mode +- [ ] Verify error handling and user feedback +- [ ] Test storage sync and status reporting + +When in doubt, use this checklist. Proactive implementation following these patterns ensures complete requirements coverage and maintains consistency with existing storage providers. From ca543240c732bd18816a17126b0ea2f2fba4826b Mon Sep 17 00:00:00 2001 From: makseq Date: Mon, 28 Jul 2025 11:51:50 +0300 Subject: [PATCH 60/68] Revert test --- label_studio/tests/data_import.tavern.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/label_studio/tests/data_import.tavern.yml b/label_studio/tests/data_import.tavern.yml index 09c957f5473f..7512843e3a54 100644 --- a/label_studio/tests/data_import.tavern.yml +++ b/label_studio/tests/data_import.tavern.yml @@ -169,7 +169,7 @@ stages: - name: stage request: data: - url: https://hs-sandbox-pub.s3.us-east-1.amazonaws.com/pytests/fffeaf81f4.json + url: https://data.heartex.net/q/fffeaf81f4.json headers: content-type: application/x-www-form-urlencoded method: POST From c3bd2e478d495eaec0c1d94e41b6223ac4ac4bd1 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 28 Jul 2025 10:05:21 +0100 Subject: [PATCH 61/68] Remove dependency --- .../ui/src/shad/components/ui/radio-group.tsx | 36 ------------------- web/package.json | 1 - web/yarn.lock | 5 --- 3 files changed, 42 deletions(-) delete mode 100644 web/libs/ui/src/shad/components/ui/radio-group.tsx diff --git a/web/libs/ui/src/shad/components/ui/radio-group.tsx b/web/libs/ui/src/shad/components/ui/radio-group.tsx deleted file mode 100644 index 30617ec8721b..000000000000 --- a/web/libs/ui/src/shad/components/ui/radio-group.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from "react"; -import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; -import { Circle } from "lucide-react"; - -import { cn } from "@humansignal/shad/utils"; - -const RadioGroup = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - return ; -}); -RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; - -const RadioGroupItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - return ( - - - - - - ); -}); -RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; - -export { RadioGroup, RadioGroupItem }; diff --git a/web/package.json b/web/package.json index f22f091bcdd6..de686fc1454c 100644 --- a/web/package.json +++ b/web/package.json @@ -86,7 +86,6 @@ "lodash.get": "^4.4.0", "lodash.ismatch": "^4.4.0", "lodash.throttle": "^4.1.1", - "lucide-react": "^0.525.0", "mobx": "^5.15.4", "mobx-react": "^6", "mobx-state-tree": "^3.16.0", diff --git a/web/yarn.lock b/web/yarn.lock index b07184940095..0a04f82c4dda 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -13202,11 +13202,6 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -lucide-react@^0.525.0: - version "0.525.0" - resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.525.0.tgz#5f7bcecd65e4f9b2b5b6b5d295e3376df032d5e3" - integrity sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ== - luxon@^3.2.1: version "3.6.1" resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.6.1.tgz#d283ffc4c0076cb0db7885ec6da1c49ba97e47b0" From add9c93a33c3fe9c2dd6080b09361db849db6cc8 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 28 Jul 2025 10:29:59 +0100 Subject: [PATCH 62/68] Update yarn.lock --- web/yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/yarn.lock b/web/yarn.lock index 0a04f82c4dda..87343bf4d9b2 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -19122,12 +19122,12 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== -zod@3.23.8: - version "3.23.8" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" - integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== - zod@^3.20.2: version "3.24.1" resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.1.tgz#27445c912738c8ad1e9de1bea0359fa44d9d35ee" integrity sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A== + +zod@^3.23.8: + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== From c5664ae6f789de4f69c94d92392703eb3c60124b Mon Sep 17 00:00:00 2001 From: makseq Date: Mon, 28 Jul 2025 12:47:31 +0300 Subject: [PATCH 63/68] Add pytest for list api --- .../test_import_storage_list_files_api.py | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 label_studio/io_storages/tests/test_import_storage_list_files_api.py diff --git a/label_studio/io_storages/tests/test_import_storage_list_files_api.py b/label_studio/io_storages/tests/test_import_storage_list_files_api.py new file mode 100644 index 000000000000..216c7a1c9a64 --- /dev/null +++ b/label_studio/io_storages/tests/test_import_storage_list_files_api.py @@ -0,0 +1,264 @@ +"""This file and its contents are licensed under the Apache License 2.0. Please see the included NOTICE for copyright information and LICENSE for a copy of the license. +""" +import time +import unittest +from unittest.mock import MagicMock, patch + +import pytest +from django.conf import settings +from io_storages.api import ImportStorageListFilesAPI +from io_storages.serializers import ImportStorageSerializer +from rest_framework import status +from rest_framework.exceptions import ValidationError +from rest_framework.test import APIRequestFactory, APITestCase + + +class TestImportStorageListFilesAPI(unittest.TestCase): + """Unit tests for ImportStorageListFilesAPI. + + This test class validates the file listing functionality of storage imports, + including limit handling, timeout behavior, and error scenarios. + """ + + def setUp(self): + """Set up test dependencies and mock objects for each test.""" + self.api = ImportStorageListFilesAPI(serializer_class=ImportStorageSerializer) + self.user = MagicMock() + self.storage_instance = MagicMock() + + # Configure default storage instance behavior + self.storage_instance.get_unified_metadata.side_effect = lambda obj: { + 'key': f'file_{obj}.txt', + 'last_modified': '2024-01-01T00:00:00Z', + 'size': 1024 + } + + def _create_mock_request(self, data=None): + """Helper method to create mock request with data attribute.""" + request = MagicMock() + request.data = data or {} + request.user = self.user + return request + + @patch('io_storages.functions.validate_storage_instance') + def test_successful_file_listing_under_limit(self, mock_validate): + """Test successful file listing when object count is under the limit. + + This test validates: + - Successful API response with 200 status + - Correct file metadata structure in response + - No limit marker added when under limit + - Proper integration with validate_storage_instance + """ + # Setup: Configure mock storage with 5 objects (under default limit of 100) + mock_validate.return_value = self.storage_instance + self.storage_instance.iter_objects.return_value = range(5) + + # Create mock request with data attribute + request = self._create_mock_request({'id': 1, 'limit': 100}) + + # Execute: Call the API + response = self.api.create(request) + + # Validate: Check response structure and content + assert response.status_code == status.HTTP_200_OK + assert response.data is not None + assert 'files' in response.data + assert len(response.data['files']) == 5 + + # Verify file metadata structure + for i, file_data in enumerate(response.data['files']): + assert file_data['key'] == f'file_{i}.txt' + assert file_data['last_modified'] == '2024-01-01T00:00:00Z' + assert file_data['size'] == 1024 + + # Verify storage validation was called + mock_validate.assert_called_once_with(request, ImportStorageSerializer) + + @patch('io_storages.functions.validate_storage_instance') + def test_file_listing_reaches_limit(self, mock_validate): + """Test file listing behavior when reaching the specified limit. + + This test validates: + - Limit enforcement stops iteration at specified count + - Limit marker is added as final entry when limit is reached + - File count matches exactly the specified limit + 1 (for marker) + """ + # Setup: Configure mock storage with many objects, set limit to 3 + mock_validate.return_value = self.storage_instance + self.storage_instance.iter_objects.return_value = range(100) # More than limit + + request = self._create_mock_request({'id': 1, 'limit': 3}) + + # Execute: Call the API + response = self.api.create(request) + + # Validate: Check limit enforcement + assert response.status_code == status.HTTP_200_OK + assert response.data is not None + assert len(response.data['files']) == 4 # 3 files + 1 limit marker + + # Verify first 3 entries are real files + for i in range(3): + assert response.data['files'][i]['key'] == f'file_{i}.txt' + + # Verify limit marker + limit_marker = response.data['files'][3] + assert limit_marker['key'] is None + assert limit_marker['last_modified'] is None + assert limit_marker['size'] is None + + @patch('io_storages.functions.validate_storage_instance') + def test_uses_default_limit_when_not_specified(self, mock_validate): + """Test that API uses DEFAULT_STORAGE_LIST_LIMIT when limit not in request. + + This test validates: + - Fallback to settings.DEFAULT_STORAGE_LIST_LIMIT (100) when no limit specified + - Proper handling of request data without limit parameter + """ + # Setup: Configure mock storage and no limit in request + mock_validate.return_value = self.storage_instance + self.storage_instance.iter_objects.return_value = range(50) # Under default limit + + request = self._create_mock_request({'id': 1}) + + # Execute: Call the API + response = self.api.create(request) + + # Validate: Should process all 50 files without limit marker + assert response.status_code == status.HTTP_200_OK + assert response.data is not None + assert len(response.data['files']) == 50 # No limit marker + + @patch('io_storages.api.time') + @patch('io_storages.functions.validate_storage_instance') + def test_timeout_handling(self, mock_validate, mock_time): + """Test timeout handling when file scanning exceeds 30 seconds. + + This test validates: + - Timeout detection after 30 seconds + - Timeout marker added to response + - Processing stops when timeout is reached + - Time checking occurs during iteration + """ + # Setup: Configure time mock to simulate timeout + mock_validate.return_value = self.storage_instance + mock_time.time.side_effect = [0, 35] # Start at 0, check at 35 seconds (timeout) + self.storage_instance.iter_objects.return_value = range(100) + + request = self._create_mock_request({'id': 1, 'limit': 100}) + + # Execute: Call the API + response = self.api.create(request) + + # Validate: Check timeout behavior + assert response.status_code == status.HTTP_200_OK + assert response.data is not None + assert len(response.data['files']) == 2 # 1 file + 1 timeout marker + + # Verify first entry is a real file + assert response.data['files'][0]['key'] == 'file_0.txt' + + # Verify timeout marker + timeout_marker = response.data['files'][1] + assert timeout_marker['key'] == '... storage scan timeout reached ...' + assert timeout_marker['last_modified'] is None + assert timeout_marker['size'] is None + + @patch('io_storages.functions.validate_storage_instance') + def test_iter_objects_exception_raises_validation_error(self, mock_validate): + """Test that exceptions during object iteration are converted to ValidationError. + + This test validates: + - Exception handling during storage object iteration + - Proper conversion to ValidationError for API consistency + - Error message preservation for debugging + """ + # Setup: Configure storage to raise exception during iteration + mock_validate.return_value = self.storage_instance + test_exception = Exception("Storage connection failed") + self.storage_instance.iter_objects.side_effect = test_exception + + request = self._create_mock_request({'id': 1}) + + # Execute & Validate: Should raise ValidationError + with pytest.raises(ValidationError) as exc_info: + self.api.create(request) + + # Verify exception details + assert str(exc_info.value.detail[0]) == "Storage connection failed" + + @patch('io_storages.functions.validate_storage_instance') + def test_get_unified_metadata_exception_raises_validation_error(self, mock_validate): + """Test that exceptions during metadata retrieval are converted to ValidationError. + + This test validates: + - Exception handling during metadata extraction + - Proper error propagation from get_unified_metadata + - ValidationError conversion for API consistency + """ + # Setup: Configure storage to raise exception during metadata retrieval + mock_validate.return_value = self.storage_instance + self.storage_instance.iter_objects.return_value = range(1) + test_exception = Exception("Metadata extraction failed") + self.storage_instance.get_unified_metadata.side_effect = test_exception + + request = self._create_mock_request({'id': 1}) + + # Execute & Validate: Should raise ValidationError + with pytest.raises(ValidationError) as exc_info: + self.api.create(request) + + # Verify exception details + assert str(exc_info.value.detail[0]) == "Metadata extraction failed" + + @patch('io_storages.functions.validate_storage_instance') + def test_validate_storage_instance_exception_propagates(self, mock_validate): + """Test that validate_storage_instance exceptions are properly propagated. + + This test validates: + - Exception handling during storage validation + - Proper propagation of validation errors + - No additional error wrapping occurs + """ + # Setup: Configure validate_storage_instance to raise ValidationError + validation_error = ValidationError("Invalid storage configuration") + mock_validate.side_effect = validation_error + + request = self._create_mock_request({'invalid': 'data'}) + + # Execute & Validate: Should propagate ValidationError + with pytest.raises(ValidationError) as exc_info: + self.api.create(request) + + # Verify it's the same exception + assert exc_info.value == validation_error + + def test_api_initialization_with_serializer_class(self): + """Test API initialization with custom serializer class. + + This test validates: + - Proper initialization with custom serializer + - Serializer class assignment + - Instance creation without errors + """ + # Execute: Initialize API with custom serializer + custom_serializer = ImportStorageSerializer + api = ImportStorageListFilesAPI(serializer_class=custom_serializer) + + # Validate: Check serializer assignment + assert api.serializer_class == custom_serializer + + def test_api_initialization_without_serializer_class(self): + """Test API initialization without serializer class (default behavior). + + This test validates: + - Initialization with None serializer (default) + - No errors during instance creation + - Proper handling of missing serializer + """ + # Execute: Initialize API without serializer + api = ImportStorageListFilesAPI() + + # Validate: Check default serializer state + assert api.serializer_class is None \ No newline at end of file From bc55949d27877f04cf5886361034c37670b88f47 Mon Sep 17 00:00:00 2001 From: makseq Date: Mon, 28 Jul 2025 13:06:45 +0300 Subject: [PATCH 64/68] Linter for python --- .../test_import_storage_list_files_api.py | 97 +++++++++---------- 1 file changed, 47 insertions(+), 50 deletions(-) diff --git a/label_studio/io_storages/tests/test_import_storage_list_files_api.py b/label_studio/io_storages/tests/test_import_storage_list_files_api.py index 216c7a1c9a64..878d350cbc56 100644 --- a/label_studio/io_storages/tests/test_import_storage_list_files_api.py +++ b/label_studio/io_storages/tests/test_import_storage_list_files_api.py @@ -1,21 +1,18 @@ """This file and its contents are licensed under the Apache License 2.0. Please see the included NOTICE for copyright information and LICENSE for a copy of the license. """ -import time import unittest from unittest.mock import MagicMock, patch import pytest -from django.conf import settings from io_storages.api import ImportStorageListFilesAPI from io_storages.serializers import ImportStorageSerializer from rest_framework import status from rest_framework.exceptions import ValidationError -from rest_framework.test import APIRequestFactory, APITestCase class TestImportStorageListFilesAPI(unittest.TestCase): """Unit tests for ImportStorageListFilesAPI. - + This test class validates the file listing functionality of storage imports, including limit handling, timeout behavior, and error scenarios. """ @@ -25,12 +22,12 @@ def setUp(self): self.api = ImportStorageListFilesAPI(serializer_class=ImportStorageSerializer) self.user = MagicMock() self.storage_instance = MagicMock() - + # Configure default storage instance behavior self.storage_instance.get_unified_metadata.side_effect = lambda obj: { 'key': f'file_{obj}.txt', 'last_modified': '2024-01-01T00:00:00Z', - 'size': 1024 + 'size': 1024, } def _create_mock_request(self, data=None): @@ -43,7 +40,7 @@ def _create_mock_request(self, data=None): @patch('io_storages.functions.validate_storage_instance') def test_successful_file_listing_under_limit(self, mock_validate): """Test successful file listing when object count is under the limit. - + This test validates: - Successful API response with 200 status - Correct file metadata structure in response @@ -53,32 +50,32 @@ def test_successful_file_listing_under_limit(self, mock_validate): # Setup: Configure mock storage with 5 objects (under default limit of 100) mock_validate.return_value = self.storage_instance self.storage_instance.iter_objects.return_value = range(5) - + # Create mock request with data attribute request = self._create_mock_request({'id': 1, 'limit': 100}) - + # Execute: Call the API response = self.api.create(request) - + # Validate: Check response structure and content assert response.status_code == status.HTTP_200_OK assert response.data is not None assert 'files' in response.data assert len(response.data['files']) == 5 - + # Verify file metadata structure for i, file_data in enumerate(response.data['files']): assert file_data['key'] == f'file_{i}.txt' assert file_data['last_modified'] == '2024-01-01T00:00:00Z' assert file_data['size'] == 1024 - + # Verify storage validation was called mock_validate.assert_called_once_with(request, ImportStorageSerializer) @patch('io_storages.functions.validate_storage_instance') def test_file_listing_reaches_limit(self, mock_validate): """Test file listing behavior when reaching the specified limit. - + This test validates: - Limit enforcement stops iteration at specified count - Limit marker is added as final entry when limit is reached @@ -87,21 +84,21 @@ def test_file_listing_reaches_limit(self, mock_validate): # Setup: Configure mock storage with many objects, set limit to 3 mock_validate.return_value = self.storage_instance self.storage_instance.iter_objects.return_value = range(100) # More than limit - + request = self._create_mock_request({'id': 1, 'limit': 3}) - + # Execute: Call the API response = self.api.create(request) - + # Validate: Check limit enforcement assert response.status_code == status.HTTP_200_OK assert response.data is not None assert len(response.data['files']) == 4 # 3 files + 1 limit marker - + # Verify first 3 entries are real files for i in range(3): assert response.data['files'][i]['key'] == f'file_{i}.txt' - + # Verify limit marker limit_marker = response.data['files'][3] assert limit_marker['key'] is None @@ -111,7 +108,7 @@ def test_file_listing_reaches_limit(self, mock_validate): @patch('io_storages.functions.validate_storage_instance') def test_uses_default_limit_when_not_specified(self, mock_validate): """Test that API uses DEFAULT_STORAGE_LIST_LIMIT when limit not in request. - + This test validates: - Fallback to settings.DEFAULT_STORAGE_LIST_LIMIT (100) when no limit specified - Proper handling of request data without limit parameter @@ -119,12 +116,12 @@ def test_uses_default_limit_when_not_specified(self, mock_validate): # Setup: Configure mock storage and no limit in request mock_validate.return_value = self.storage_instance self.storage_instance.iter_objects.return_value = range(50) # Under default limit - + request = self._create_mock_request({'id': 1}) - + # Execute: Call the API response = self.api.create(request) - + # Validate: Should process all 50 files without limit marker assert response.status_code == status.HTTP_200_OK assert response.data is not None @@ -134,7 +131,7 @@ def test_uses_default_limit_when_not_specified(self, mock_validate): @patch('io_storages.functions.validate_storage_instance') def test_timeout_handling(self, mock_validate, mock_time): """Test timeout handling when file scanning exceeds 30 seconds. - + This test validates: - Timeout detection after 30 seconds - Timeout marker added to response @@ -145,20 +142,20 @@ def test_timeout_handling(self, mock_validate, mock_time): mock_validate.return_value = self.storage_instance mock_time.time.side_effect = [0, 35] # Start at 0, check at 35 seconds (timeout) self.storage_instance.iter_objects.return_value = range(100) - + request = self._create_mock_request({'id': 1, 'limit': 100}) - + # Execute: Call the API response = self.api.create(request) - + # Validate: Check timeout behavior assert response.status_code == status.HTTP_200_OK assert response.data is not None assert len(response.data['files']) == 2 # 1 file + 1 timeout marker - + # Verify first entry is a real file assert response.data['files'][0]['key'] == 'file_0.txt' - + # Verify timeout marker timeout_marker = response.data['files'][1] assert timeout_marker['key'] == '... storage scan timeout reached ...' @@ -168,7 +165,7 @@ def test_timeout_handling(self, mock_validate, mock_time): @patch('io_storages.functions.validate_storage_instance') def test_iter_objects_exception_raises_validation_error(self, mock_validate): """Test that exceptions during object iteration are converted to ValidationError. - + This test validates: - Exception handling during storage object iteration - Proper conversion to ValidationError for API consistency @@ -176,22 +173,22 @@ def test_iter_objects_exception_raises_validation_error(self, mock_validate): """ # Setup: Configure storage to raise exception during iteration mock_validate.return_value = self.storage_instance - test_exception = Exception("Storage connection failed") + test_exception = Exception('Storage connection failed') self.storage_instance.iter_objects.side_effect = test_exception - + request = self._create_mock_request({'id': 1}) - + # Execute & Validate: Should raise ValidationError with pytest.raises(ValidationError) as exc_info: self.api.create(request) - + # Verify exception details - assert str(exc_info.value.detail[0]) == "Storage connection failed" + assert str(exc_info.value.detail[0]) == 'Storage connection failed' @patch('io_storages.functions.validate_storage_instance') def test_get_unified_metadata_exception_raises_validation_error(self, mock_validate): """Test that exceptions during metadata retrieval are converted to ValidationError. - + This test validates: - Exception handling during metadata extraction - Proper error propagation from get_unified_metadata @@ -200,43 +197,43 @@ def test_get_unified_metadata_exception_raises_validation_error(self, mock_valid # Setup: Configure storage to raise exception during metadata retrieval mock_validate.return_value = self.storage_instance self.storage_instance.iter_objects.return_value = range(1) - test_exception = Exception("Metadata extraction failed") + test_exception = Exception('Metadata extraction failed') self.storage_instance.get_unified_metadata.side_effect = test_exception - + request = self._create_mock_request({'id': 1}) - + # Execute & Validate: Should raise ValidationError with pytest.raises(ValidationError) as exc_info: self.api.create(request) - + # Verify exception details - assert str(exc_info.value.detail[0]) == "Metadata extraction failed" + assert str(exc_info.value.detail[0]) == 'Metadata extraction failed' @patch('io_storages.functions.validate_storage_instance') def test_validate_storage_instance_exception_propagates(self, mock_validate): """Test that validate_storage_instance exceptions are properly propagated. - + This test validates: - Exception handling during storage validation - Proper propagation of validation errors - No additional error wrapping occurs """ # Setup: Configure validate_storage_instance to raise ValidationError - validation_error = ValidationError("Invalid storage configuration") + validation_error = ValidationError('Invalid storage configuration') mock_validate.side_effect = validation_error - + request = self._create_mock_request({'invalid': 'data'}) - + # Execute & Validate: Should propagate ValidationError with pytest.raises(ValidationError) as exc_info: self.api.create(request) - + # Verify it's the same exception assert exc_info.value == validation_error def test_api_initialization_with_serializer_class(self): """Test API initialization with custom serializer class. - + This test validates: - Proper initialization with custom serializer - Serializer class assignment @@ -245,13 +242,13 @@ def test_api_initialization_with_serializer_class(self): # Execute: Initialize API with custom serializer custom_serializer = ImportStorageSerializer api = ImportStorageListFilesAPI(serializer_class=custom_serializer) - + # Validate: Check serializer assignment assert api.serializer_class == custom_serializer def test_api_initialization_without_serializer_class(self): """Test API initialization without serializer class (default behavior). - + This test validates: - Initialization with None serializer (default) - No errors during instance creation @@ -259,6 +256,6 @@ def test_api_initialization_without_serializer_class(self): """ # Execute: Initialize API without serializer api = ImportStorageListFilesAPI() - + # Validate: Check default serializer state - assert api.serializer_class is None \ No newline at end of file + assert api.serializer_class is None From 05cd1f022cf47abbeecf6bf8ad4e76458d3b68e8 Mon Sep 17 00:00:00 2001 From: makseq Date: Mon, 28 Jul 2025 13:12:50 +0300 Subject: [PATCH 65/68] Rename "JSON - Treat each JSON" to "Tasks - Treach each JSON" to match updated docs --- label_studio/io_storages/azure_blob/form_layout.yml | 2 +- label_studio/io_storages/gcs/form_layout.yml | 2 +- label_studio/io_storages/localfiles/form_layout.yml | 2 +- label_studio/io_storages/redis/form_layout.yml | 2 +- label_studio/io_storages/s3/form_layout.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/label_studio/io_storages/azure_blob/form_layout.yml b/label_studio/io_storages/azure_blob/form_layout.yml index 2011a21c2258..1633ed3e021d 100644 --- a/label_studio/io_storages/azure_blob/form_layout.yml +++ b/label_studio/io_storages/azure_blob/form_layout.yml @@ -59,7 +59,7 @@ ImportStorage: - value: true label: "Files - Automatically creates a task for each storage object (e.g. JPG, MP3, TXT)" - value: false - label: "JSON - Treat each JSON or JSONL file as a task definition (one or more tasks per file)" + label: "Tasks - Treat each JSON or JSONL file as a task definition (one or more tasks per file)" # 2 columns grid - columnCount: 2 diff --git a/label_studio/io_storages/gcs/form_layout.yml b/label_studio/io_storages/gcs/form_layout.yml index a479fc3c757e..92a4ffbe5401 100644 --- a/label_studio/io_storages/gcs/form_layout.yml +++ b/label_studio/io_storages/gcs/form_layout.yml @@ -63,7 +63,7 @@ ImportStorage: - value: true label: "Files - Automatically creates a task for each storage object (e.g. JPG, MP3, TXT)" - value: false - label: "JSON - Treat each JSON or JSONL file as a task definition (one or more tasks per file)" + label: "Tasks - Treat each JSON or JSONL file as a task definition (one or more tasks per file)" # 2 columns grid - columnCount: 2 diff --git a/label_studio/io_storages/localfiles/form_layout.yml b/label_studio/io_storages/localfiles/form_layout.yml index c9146a0bd115..8c0c5f2c928d 100644 --- a/label_studio/io_storages/localfiles/form_layout.yml +++ b/label_studio/io_storages/localfiles/form_layout.yml @@ -36,7 +36,7 @@ ImportStorage: - value: true label: "Files - Automatically creates a task for each storage object (e.g. JPG, MP3, TXT)" - value: false - label: "JSON - Treat each JSON or JSONL file as a task definition (one or more tasks per file)" + label: "Tasks - Treat each JSON or JSONL file as a task definition (one or more tasks per file)" ExportStorage: - columnCount: 3 diff --git a/label_studio/io_storages/redis/form_layout.yml b/label_studio/io_storages/redis/form_layout.yml index 38de52df3fc6..6a4efbb7bd09 100644 --- a/label_studio/io_storages/redis/form_layout.yml +++ b/label_studio/io_storages/redis/form_layout.yml @@ -55,7 +55,7 @@ ImportStorage: - value: true label: "Files - Automatically creates a task for each storage object (e.g. JPG, MP3, TXT)" - value: false - label: "JSON - Treat each JSON or JSONL file as a task definition (one or more tasks per file)" + label: "Tasks - Treat each JSON or JSONL file as a task definition (one or more tasks per file)" ExportStorage: - columnCount: 2 diff --git a/label_studio/io_storages/s3/form_layout.yml b/label_studio/io_storages/s3/form_layout.yml index 2fca946765b0..c5dd61bff462 100644 --- a/label_studio/io_storages/s3/form_layout.yml +++ b/label_studio/io_storages/s3/form_layout.yml @@ -112,7 +112,7 @@ ImportStorage: - value: true label: "Files - Automatically creates a task for each storage object (e.g. JPG, MP3, TXT)" - value: false - label: "JSON - Treat each JSON or JSONL file as a task definition (one or more tasks per file)" + label: "Tasks - Treat each JSON or JSONL file as a task definition (one or more tasks per file)" # 2 column grid - columnCount: 2 From 3e2504f5fa0f356ba0ea6a0d327f87069441dcdd Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 28 Jul 2025 11:15:29 +0100 Subject: [PATCH 66/68] Fix linter issues --- .../Steps/preview-step.tsx | 218 ++++++------------ .../StorageProviderForm/Steps/stepper.tsx | 1 + .../components/field-renderer.tsx | 6 +- 3 files changed, 68 insertions(+), 157 deletions(-) diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx index a3c64cf6425d..8393cecad08c 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx @@ -22,6 +22,49 @@ interface PreviewStepProps { onImportSettingsChange?: () => void; } +const regexFilters = [ + { + title: "Images", + regex: ".*.(jpe?g|png|gif)$", + blob: true, + }, + { + title: "Videos", + regex: ".*\\.(mp4|avi|mov|wmv|webm)$", + blob: true, + }, + { + title: "Audio", + regex: ".*\\.(mp3|wav|ogg|flac)$", + blob: true, + }, + { + title: "Tabular", + regex: ".*\\.(csv|tsv)$", + blob: true, + }, + { + title: "JSON", + regex: ".*\\.json$", + blob: false, + }, + { + title: "JSONL", + regex: ".*\\.jsonl$", + blob: false, + }, + { + title: "Parquet", + regex: ".*\\.parquet$", + blob: false, + }, + { + title: "All Tasks Files", + regex: ".*\\.(json|jsonl|parquet)$", + blob: false, + }, +] as const; + export const PreviewStep = ({ formData, formState, @@ -128,159 +171,28 @@ export const PreviewStep = ({
Common filters: - {formData.use_blob_urls ? ( - // Files mode - show media file filters - <> - { - e.preventDefault(); - setFormState((prevState) => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*.(jpe?g|png|gif)$", - }, - })); - }} - > - Images - - { - e.preventDefault(); - setFormState((prevState) => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.(mp4|avi|mov|wmv|webm)$", - }, - })); - }} - > - Videos - - { - e.preventDefault(); - setFormState((prevState) => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.(mp3|wav|ogg|flac)$", - }, - })); - }} - > - Audio - - { - e.preventDefault(); - setFormState((prevState) => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.(csv|tsv)$", - }, - })); - }} - > - Tabular - - { - e.preventDefault(); - setFormState((prevState) => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*", - }, - })); - }} - > - All Files - - - ) : ( - // Tasks mode - show structured data filters - <> - { - e.preventDefault(); - setFormState((prevState) => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.json$", - }, - })); - }} - > - JSON - - { - e.preventDefault(); - setFormState((prevState) => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.jsonl$", - }, - })); - }} - > - JSONL - - { - e.preventDefault(); - setFormState((prevState) => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.parquet$", - }, - })); - }} - > - Parquet - - { - e.preventDefault(); - setFormState((prevState) => ({ - ...prevState, - formData: { - ...prevState.formData, - regex_filter: ".*\\.(json|jsonl|parquet)$", - }, - })); - }} - > - All Task Files - - - )} + {regexFilters + .filter((r) => r.blob === formData.use_blob_urls) + .map((r) => { + return ( + + ); + })}
diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/stepper.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/stepper.tsx index c0a888339055..afd958287ee9 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/stepper.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/stepper.tsx @@ -99,6 +99,7 @@ export const Stepper = ({ steps, currentStep, onStepClick, isEditMode = false }: strokeLinejoin="round" className="w-3 h-3" > + Line ) : ( diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/components/field-renderer.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/components/field-renderer.tsx index 14af0543f7be..68900c7fb06d 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/components/field-renderer.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/components/field-renderer.tsx @@ -64,9 +64,6 @@ export const FieldRenderer: React.FC = ({ autoComplete: field.autoComplete, }); - // Check if this is an access key field with placeholder value in edit mode - const isAccessKeyWithPlaceholder = field.accessKey && isEditMode && value === "••••••••••••••••"; - // Enhanced description for access key fields in edit mode const getEnhancedDescription = () => { return field.description || ""; @@ -142,7 +139,7 @@ export const FieldRenderer: React.FC = ({
); - case "counter": + case "counter": { const counterValue = value !== undefined && value !== null ? value : field.min || 0; return ( = ({ labelProps={{}} /> ); + } default: return
Unknown field type: {field.type}
; From fcfe51c572ed506ceeb1c59f4298642fbcfcd449 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 28 Jul 2025 11:20:05 +0100 Subject: [PATCH 67/68] Fix linter --- .../src/blocks/StorageProviderForm/Steps/preview-step.tsx | 1 + .../src/blocks/StorageProviderForm/components/provider-form.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx index 8393cecad08c..2bf7cbf3c941 100644 --- a/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx +++ b/web/libs/app-common/src/blocks/StorageProviderForm/Steps/preview-step.tsx @@ -177,6 +177,7 @@ export const PreviewStep = ({ return (