diff --git a/web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx b/web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx index 3277174f5c03..0712f81302c6 100644 --- a/web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx +++ b/web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx @@ -13,7 +13,7 @@ import { Preview } from "./Preview"; import { DEFAULT_COLUMN, EMPTY_CONFIG, isEmptyConfig, Template } from "./Template"; import { TemplatesList } from "./TemplatesList"; -import tags from "./schema.json"; +import tags from "@humansignal/core/lib/utils/schema/tags.json"; import { UnsavedChanges } from "./UnsavedChanges"; import { Checkbox, CodeEditor, Select } from "@humansignal/ui"; import { toSnakeCase } from "strman"; diff --git a/web/apps/playground/.babelrc b/web/apps/playground/.babelrc new file mode 100644 index 000000000000..88ee27b140c6 --- /dev/null +++ b/web/apps/playground/.babelrc @@ -0,0 +1,11 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic" + } + ] + ], + "plugins": [] +} diff --git a/web/apps/playground/README.mdc b/web/apps/playground/README.mdc new file mode 100644 index 000000000000..504ea371aaaa --- /dev/null +++ b/web/apps/playground/README.mdc @@ -0,0 +1,134 @@ +--- +description: +globs: +alwaysApply: false +--- +# Playground + +## Overview +The LabelStudio Playground is a standalone, embeddable React application for editing and previewing LabelStudio XML labeling configurations. It is designed to be embedded via iframe in documentation or external web applications, providing a focused environment for working with XML-based labeling configs and live previewing the LabelStudio interface. + +## Main Features + +- **Live XML Config Editing:** Edit Label Studio XML configs in real time and instantly preview the result. +- **Sample Data Generation:** Automatically generates sample data for all supported media types (image, audio, video, PDF, website, CSV, OCR, etc.) using public domain URLs (primarily from Wikimedia Commons). This ensures copyright-safe, always-accessible previews. +- **Live Annotation Output:** The preview panel displays the current annotation as a live-updating JSON string, reflecting user interactions in real time. +- **Sticky Bottom Panel:** The right preview panel ensures the bottom panel (annotation controls) is always visible and sticky, with the main preview area scrollable. +- **Resizable Panels:** The left (editor) and right (preview) panels are resizable and fully responsive. +- **Robust Error Handling:** Displays clear error messages for invalid configs, network issues, or MobX State Tree (MST) errors. All async and MST errors are handled gracefully to avoid UI crashes. + +## Main Components + +### 1. `PlaygroundApp` +- **Location:** `src/components/PlaygroundApp.tsx` +- **Role:** The main application component. Handles: + - UI rendering and atom wiring only; all state is managed via Jotai atoms. + - Reads and writes config, loading, error, interfaces state via atoms. + - Uses utility functions for parsing URL parameters and interface options. + - Renders the code editor and preview panels side by side using Tailwind CSS for layout. + +### 2. `PlaygroundPreview` +- **Location:** `src/components/PlaygroundPreview.tsx` +- **Role:** Renders the live LabelStudio preview panel. + - Receives all state as props (from atoms in PlaygroundApp). + - Dynamically loads the `@humansignal/editor` package and instantiates a LabelStudio instance with the current config and interface options. + - Handles cleanup of the LabelStudio instance on config/interface changes or unmount. + - Observes MST annotation changes and updates a Jotai atom or local state with the serialized annotation JSON, which is displayed below the preview. + - Displays loading and error states using Tailwind classes. + +### 3. `main.tsx` +- **Role:** Entry point. Mounts the `PlaygroundApp` to the DOM. + +## State Management +- All application state (config, loading, error, interfaces, annotation, sample task) is managed using Jotai atoms, defined in `src/atoms/configAtoms.ts`. +- Components use the `useAtom` hook to read and write state. +- This ensures a strict separation of state logic from UI, and enables easy extension and testing. + +## Utility Functions +- All logic for parsing URL parameters and interface options is placed in strict utility functions in `src/utils/query.ts`. +- Sample data generation is handled in `src/utils/generateSampleTask.ts`. +- Components import and use these utilities for all non-UI logic. + +## Data Flow +- On load, `PlaygroundApp` uses utility functions to parse URL parameters: + - `?config` (base64-encoded XML config) + - `?configUrl` (URL to fetch XML config) + - `?interfaces` (comma-separated list of LabelStudio interface options) +- The config, loading, error, interfaces, annotation, and sample task state are set via Jotai atoms. +- The code editor is a controlled component, updating the config atom on change. +- The preview panel receives the current config and interface options as props and re-renders the LabelStudio instance accordingly. +- Annotation changes in the preview are observed and the serialized annotation is displayed live below the preview. + +## MobX State Tree (MST) Integration +- All MST model mutations (including cleanup) are performed via MST actions to avoid protection errors. +- The annotation model includes a `cleanup` action for safe teardown, which is called from React cleanup. +- Only plain objects (not MST models) are stored in React state or Jotai atoms. + +## Underlying Libraries + +### React +- The app is built with React (function components, hooks, strict mode). +- State and effects are managed with Jotai atoms and hooks. + +### Jotai +- Used for all state management (config, loading, error, interfaces, annotation, sample task). +- Atoms are defined in `src/atoms/configAtoms.ts`. +- Components use `useAtom` for state access and updates. + +### Tailwind CSS +- All layout, spacing, color, and typography is handled with Tailwind utility classes. +- Only semantic and token-based Tailwind classes are used, following project rules. + +### @humansignal/ui +- Provides the `CodeEditor` component for XML editing. +- Ensures consistent UI and design token usage across the app. + +### @humansignal/editor +- Provides the LabelStudio labeling interface for live preview. +- Dynamically imported in the preview panel for performance and to avoid loading unnecessary code until needed. +- The LabelStudio instance is created with the current config and interface options, and is destroyed/cleaned up on changes. + +## URL-based API +- The app can be configured via URL parameters: + - `?config` (base64-encoded XML config string) + - `?configUrl` (URL to fetch XML config) + - `?interfaces` (comma-separated list of LabelStudio interface options) +- This allows external documentation or apps to embed the playground with preloaded configs and custom preview options. + +## Embeddability +- The app is fully responsive and designed to be embedded via iframe. +- All UI is self-contained and does not require authentication or external state. + +## Extensibility +- Components are split into single-file-per-component for maintainability, and all live under `src/components/`. +- All state is managed via Jotai atoms in `src/atoms/`. +- All logic is placed in strict utility functions in `src/utils/`. +- The code editor and preview logic are decoupled, allowing for future enhancements (e.g., validation, additional preview options, custom data, etc). +- The app can be extended to support more URL parameters, additional LabelStudio features, or integration with other HumanSignal libraries. +- To add new sample data types, update `generateSampleTask.ts` with new logic and public domain URLs. +- To customize annotation output, update the preview logic to observe and display additional MST state as needed. + +## Directory Structure + +``` +web/apps/playground/ +├── src/ +│ ├── atoms/ # Jotai atoms for state management +│ ├── components/ # React components +│ │ ├── BottomPanel/ # Data Input/Output Bottom panel component +│ │ ├── EditorPanel/ # Labelling Config Editor panel component +│ │ ├── PlaygroundApp/ # Main app component +│ │ └── PreviewPanel/ # Labelling Preview panel component +│ ├── utils/ # Utility functions +│ ├── index.html # Entry HTML file +│ └── main.tsx # Entry point +├── .babelrc # Babel configuration +├── jest.config.ts # Jest configuration +├── project.json # Nx project configuration +├── tsconfig.json # TypeScript configuration +├── tsconfig.app.json # App-specific TypeScript configuration +└── tsconfig.spec.json # Test-specific TypeScript configuration +``` + +## Summary +The Playground app is a modern, modular, and embeddable tool for experimenting with and sharing LabelStudio configs. It leverages the HumanSignal UI and editor libraries, is styled with Tailwind, uses Jotai for state, and is designed for easy integration into documentation and external web applications. It now features robust sample data generation, live annotation output, and safe MobX State Tree integration for a seamless developer experience. diff --git a/web/apps/playground/jest.config.ts b/web/apps/playground/jest.config.ts new file mode 100644 index 000000000000..8d5cb6498343 --- /dev/null +++ b/web/apps/playground/jest.config.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +export default { + displayName: "playground", + preset: "../../jest.preset.js", + transform: { + "^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "@nx/react/plugins/jest", + "^.+\\.[tj]sx?$": ["babel-jest", { presets: ["@nx/react/babel"] }], + }, + moduleFileExtensions: ["ts", "tsx", "js", "jsx"], + moduleNameMapper: { + "^apps/playground/(.*)$": "/$1", + }, + coverageDirectory: "../../coverage/apps/playground", +}; diff --git a/web/apps/playground/project.json b/web/apps/playground/project.json new file mode 100644 index 000000000000..efb3fdfc5424 --- /dev/null +++ b/web/apps/playground/project.json @@ -0,0 +1,86 @@ +{ + "name": "playground", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/playground/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "compiler": "babel", + "outputPath": "dist/apps/playground", + "index": "apps/playground/src/index.html", + "baseHref": "/", + "main": "apps/playground/src/main.tsx", + "tsConfig": "apps/playground/tsconfig.app.json", + "assets": [], + "styles": [], + "scripts": [], + "isolatedConfig": true, + "webpackConfig": "webpack.config.js" + }, + "configurations": { + "development": { + "extractLicenses": false, + "optimization": false, + "sourceMap": true, + "vendorChunk": true + }, + "production": { + "fileReplacements": [ + { + "replace": "apps/playground/src/environments/environment.ts", + "with": "apps/playground/src/environments/environment.prod.ts" + } + ], + "optimization": true, + "sourceMap": false, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false + } + } + }, + "serve": { + "executor": "@nx/webpack:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "playground:build", + "port": 4200, + "hmr": true + }, + "configurations": { + "development": { + "buildTarget": "playground:build:development" + }, + "production": { + "buildTarget": "playground:build:production", + "hmr": false + } + } + }, + "serve-static": { + "executor": "@nx/web:file-server", + "options": { + "buildTarget": "playground:build" + } + }, + "unit": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/playground/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + }, + "tags": [] +} diff --git a/web/apps/playground/src/atoms/configAtoms.ts b/web/apps/playground/src/atoms/configAtoms.ts new file mode 100644 index 000000000000..af9b122f7921 --- /dev/null +++ b/web/apps/playground/src/atoms/configAtoms.ts @@ -0,0 +1,11 @@ +import { atom } from "jotai"; + +export const defaultConfig = "\n \n"; + +export const configAtom = atom(defaultConfig); +export const loadingAtom = atom(false); +export const errorAtom = atom(null); +export const interfacesAtom = atom(["side-column"]); +export const showPreviewAtom = atom(true); +export const sampleTaskAtom = atom({}); +export const annotationAtom = atom([]); diff --git a/web/apps/playground/src/components/BottomPanel/BottomPanel.tsx b/web/apps/playground/src/components/BottomPanel/BottomPanel.tsx new file mode 100644 index 000000000000..a3e8262dbb90 --- /dev/null +++ b/web/apps/playground/src/components/BottomPanel/BottomPanel.tsx @@ -0,0 +1,67 @@ +import type React from "react"; +import { forwardRef } from "react"; +import { useAtomValue } from "jotai"; +import { IconCollapseSmall, IconExpandSmall } from "@humansignal/icons"; +import { cnm } from "@humansignal/ui/utils/utils"; +import { annotationAtom, sampleTaskAtom } from "../../atoms/configAtoms"; + +export type BottomPanelRef = { + handleAnnotationUpdate: (annotation: any) => void; +}; + +interface BottomPanelProps { + isCollapsed: boolean; + setIsCollapsed: React.Dispatch>; +} + +const HEADER_HEIGHT = 33; + +export const BottomPanel = forwardRef(({ isCollapsed, setIsCollapsed }, ref) => { + const currentAnnotation = useAtomValue(annotationAtom); + const sampleTask = useAtomValue(sampleTaskAtom); + + return ( +
+ {/* Header (always visible, 33px) */} +
+
+
Data Input
+
+
Data Output
+
+ {/* Floating collapse/expand button */} + +
+ {/* Panel content (only when not collapsed) */} + {!isCollapsed && ( +
+ {/* Sample Data Panel */} +
+
{JSON.stringify(sampleTask.data, null, 2)}
+
+ {/* Annotation Output Panel */} +
+
+              {JSON.stringify(currentAnnotation || {}, null, 2)}
+            
+
+
+ )} +
+ ); +}); diff --git a/web/apps/playground/src/components/BottomPanel/index.ts b/web/apps/playground/src/components/BottomPanel/index.ts new file mode 100644 index 000000000000..2d7fb9ef1e91 --- /dev/null +++ b/web/apps/playground/src/components/BottomPanel/index.ts @@ -0,0 +1 @@ +export { BottomPanel } from "./BottomPanel"; diff --git a/web/apps/playground/src/components/EditorPanel/EditorPanel.tsx b/web/apps/playground/src/components/EditorPanel/EditorPanel.tsx new file mode 100644 index 000000000000..bda92cda2e21 --- /dev/null +++ b/web/apps/playground/src/components/EditorPanel/EditorPanel.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState, useCallback, useMemo } from "react"; +import type { MouseEvent } from "react"; +import { useAtom } from "jotai"; +import { CodeEditor } from "@humansignal/ui"; +import { BottomPanel } from "../BottomPanel"; +import { configAtom } from "../../atoms/configAtoms"; +import { editorExtensions, editorOptions } from "../../utils/codeEditor"; + +const COLLAPSED_PANEL_HEIGHT = 33; +const DEFAULT_PANEL_HEIGHT = 300; +const MIN_PANEL_HEIGHT = 100; +const MAX_PANEL_HEIGHT = 800; + +export const EditorPanel = ({ editorWidth }: { editorWidth: number }) => { + const [config, setConfig] = useAtom(configAtom); + const editorRef = useRef(null); + const [bottomPanelHeight, setBottomPanelHeight] = useState(DEFAULT_PANEL_HEIGHT); + const [isCollapsed, setIsCollapsed] = useState(false); + const dragging = useRef(false); + const startY = useRef(0); + const startHeight = useRef(0); + const containerRef = useRef(null); + + // Drag logic for vertical resize + const handleMouseDown = useCallback( + (e: MouseEvent) => { + if (e.button !== 0) return; + dragging.current = true; + startY.current = e.clientY; + startHeight.current = bottomPanelHeight; + }, + [bottomPanelHeight], + ); + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!dragging.current) return; + e.preventDefault(); + document.body.style.cursor = "row-resize"; + document.body.style.userSelect = "none"; + const delta = startY.current - e.clientY; + const newHeight = Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, startHeight.current + delta)); + setBottomPanelHeight(newHeight); + }, []); + + const handleMouseUp = useCallback(() => { + dragging.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }, []); + + const handleDividerDoubleClick = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + dragging.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + setBottomPanelHeight(DEFAULT_PANEL_HEIGHT); + }, + [setBottomPanelHeight], + ); + + useEffect(() => { + window.addEventListener("mousemove", handleMouseMove as unknown as EventListener); + window.addEventListener("mouseup", handleMouseUp as unknown as EventListener); + return () => { + window.removeEventListener("mousemove", handleMouseMove as unknown as EventListener); + window.removeEventListener("mouseup", handleMouseUp as unknown as EventListener); + }; + }, [handleMouseMove, handleMouseUp]); + + const bottomPanelStyle = useMemo(() => { + if (isCollapsed) return { height: COLLAPSED_PANEL_HEIGHT }; + return { + height: bottomPanelHeight, + }; + }, [bottomPanelHeight, isCollapsed]); + + return ( +
+ {/* CodeEditor (top) */} +
+ setConfig(value)} + border={false} + controlled + // @ts-ignore + autoCloseTags + smartIndent + detach + extensions={editorExtensions} + options={editorOptions} + /> +
+ {/* Divider for resizing (only when not collapsed) */} + {!isCollapsed && ( +
+ )} + {/* BottomPanel (Input/Output) */} +
+ +
+
+ ); +}; diff --git a/web/apps/playground/src/components/EditorPanel/index.ts b/web/apps/playground/src/components/EditorPanel/index.ts new file mode 100644 index 000000000000..8b9aac493232 --- /dev/null +++ b/web/apps/playground/src/components/EditorPanel/index.ts @@ -0,0 +1 @@ +export * from "./EditorPanel"; diff --git a/web/apps/playground/src/components/PlaygroundApp/PlaygroundApp.module.scss b/web/apps/playground/src/components/PlaygroundApp/PlaygroundApp.module.scss new file mode 100644 index 000000000000..c34e5bdd6981 --- /dev/null +++ b/web/apps/playground/src/components/PlaygroundApp/PlaygroundApp.module.scss @@ -0,0 +1,43 @@ +.root { + :global(.react-codemirror2 .CodeMirror) { + border: none; + border-radius: 0; + } + + :global(.lsf-tabs-panel__body) { + height: 100%; + } + + :global(.lsf-panel-tabs__tab) { + color: var(--color-neutral-content-subtler); + } + + :global(.lsf-panel-tabs__tab_active) { + transform: none; + border-width: 0; + color: var(--color-neutral-content); + box-shadow: 1px -1px 0 rgba(var(--color-neutral-shadow-raw) / 4%), -1px -1px 0 rgba(var(--color-neutral-shadow-raw) / 4%); + } + + :global(.lsf-tabs__tabs-row) { + border: none; + } + + :global(.lsf-sidepanels_collapsed .lsf-sidepanels__wrapper .lsf-tabs__contents) { + border: none; + } + + :global(.lsf-sidepanels_collapsed) { + flex: 1; + } + + :global(.lsf-wrapper) { + flex: 1; + display: flex; + flex-direction: column; + } +} + +body { + overflow: hidden; +} \ No newline at end of file diff --git a/web/apps/playground/src/components/PlaygroundApp/PlaygroundApp.tsx b/web/apps/playground/src/components/PlaygroundApp/PlaygroundApp.tsx new file mode 100644 index 000000000000..2b6584de4fd4 --- /dev/null +++ b/web/apps/playground/src/components/PlaygroundApp/PlaygroundApp.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; +import type { MouseEvent } from "react"; +import { useSetAtom } from "jotai"; +import { ToastProvider, ToastViewport } from "@humansignal/ui/lib/toast/toast"; +import { cnm } from "@humansignal/shad/utils"; +import { PreviewPanel } from "../PreviewPanel"; +import { configAtom, loadingAtom, errorAtom, interfacesAtom } from "../../atoms/configAtoms"; +import { + getQueryParams, + replaceBrTagsWithNewlines, + getInterfacesFromParams, + throwUnlessXmlLike, +} from "../../utils/query"; +import { TopBar } from "./TopBar"; +import { EditorPanel } from "../EditorPanel"; +import styles from "./PlaygroundApp.module.scss"; + +const DEFAULT_EDITOR_WIDTH_PERCENT = 50; +const MIN_EDITOR_WIDTH_PERCENT = 20; +const MAX_EDITOR_WIDTH_PERCENT = 80; + +export const PlaygroundApp = () => { + const setConfig = useSetAtom(configAtom); + const setLoading = useSetAtom(loadingAtom); + const setError = useSetAtom(errorAtom); + const setInterfaces = useSetAtom(interfacesAtom); + const [editorWidth, setEditorWidth] = useState(DEFAULT_EDITOR_WIDTH_PERCENT); + const dragging = useRef(false); + + useEffect(() => { + const params = getQueryParams(); + const configParam = params.get("config"); + const configUrl = params.get("configUrl"); + setInterfaces(getInterfacesFromParams(params)); + + async function loadConfig() { + let config = null; + + // Precedence: configUrl > configParam + if (configUrl) { + setLoading(true); + try { + const res = await fetch(configUrl); + if (!res.ok) throw new Error("Failed to fetch config from URL."); + const text = await res.text(); + // Replace all
tags with newlines + config = replaceBrTagsWithNewlines(text); + } catch (e) { + setError("Failed to fetch config from URL."); + } finally { + setLoading(false); + } + } + + config ??= configParam; + + if (config) { + try { + // Check if the config is already valid xml + // Otherwise, parse url encoded config + // Replace all
tags with newlines + try { + throwUnlessXmlLike(config); + setConfig(replaceBrTagsWithNewlines(config)); + } catch (e) { + setConfig(replaceBrTagsWithNewlines(decodeURIComponent(config))); + } + } catch (e) { + setError("Failed to decode config. Are you sure it's a valid urlencoded string?"); + } + return; + } + } + loadConfig(); + // eslint-disable-next-line + }, [setConfig, setError, setLoading, setInterfaces]); + + // Draggable divider logic + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!dragging.current) return; + e.preventDefault(); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + const percent = (e.clientX / window.innerWidth) * 100; + setEditorWidth(Math.max(MIN_EDITOR_WIDTH_PERCENT, Math.min(MAX_EDITOR_WIDTH_PERCENT, percent))); + }; + const onMouseUp = () => { + dragging.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + window.addEventListener("mousemove", onMouseMove as unknown as EventListener); + window.addEventListener("mouseup", onMouseUp as unknown as EventListener); + return () => { + window.removeEventListener("mousemove", onMouseMove as unknown as EventListener); + window.removeEventListener("mouseup", onMouseUp as unknown as EventListener); + }; + }, []); + + const handleDividerDoubleClick = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + setEditorWidth(DEFAULT_EDITOR_WIDTH_PERCENT); + }, + [setEditorWidth], + ); + + const previewPanelStyle = useMemo(() => ({ width: `${100 - editorWidth}%` }), [editorWidth]); + + return ( +
+ + {/* Minimal top bar */} + + {/* Editor/Preview split */} +
+ {/* Editor Panel */} + + {/* Resizable Divider */} +
{ + if (e.button !== 0) return; + e.preventDefault(); + dragging.current = true; + }} + onDoubleClick={handleDividerDoubleClick} + role="separator" + aria-orientation="vertical" + tabIndex={-1} + /> + {/* Preview Panel */} +
+
+ +
+
+
+ + +
+ ); +}; diff --git a/web/apps/playground/src/components/PlaygroundApp/TopBar.tsx b/web/apps/playground/src/components/PlaygroundApp/TopBar.tsx new file mode 100644 index 000000000000..ac566678e494 --- /dev/null +++ b/web/apps/playground/src/components/PlaygroundApp/TopBar.tsx @@ -0,0 +1,81 @@ +import { memo, useCallback } from "react"; +import { ThemeToggle, IconLink, IconCopyOutline, Tooltip, useToast } from "@humansignal/ui"; +import { useAtomValue } from "jotai"; +import { configAtom } from "../../atoms/configAtoms"; +import { getParentUrl } from "../../utils/url"; + +const ShareUrlButton = () => { + const config = useAtomValue(configAtom); + const toast = useToast(); + + const handleCopy = useCallback(() => { + const url = new URL(getParentUrl()); + url.searchParams.set("config", encodeURIComponent(config.replace(/\n/g, "
"))); + navigator.clipboard.writeText(url.toString()); + toast?.show({ message: "URL copied to clipboard" }); + }, [config, toast]); + + return ( + + + + ); +}; + +const CopyButton = () => { + const config = useAtomValue(configAtom); + const toast = useToast(); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(config); + toast?.show({ message: "Config copied to clipboard" }); + }, [config, toast]); + + return ( + + + + ); +}; + +const ShareButtons = () => { + return ( +
+ + +
+ ); +}; + +export const TopBar = memo( + () => { + return ( +
+
+ + Label Studio Playground + +
+
+ + +
+
+ ); + }, + () => true, +); diff --git a/web/apps/playground/src/components/PlaygroundApp/__tests__/PlaygroundApp.test.tsx b/web/apps/playground/src/components/PlaygroundApp/__tests__/PlaygroundApp.test.tsx new file mode 100644 index 000000000000..fd373d0e2938 --- /dev/null +++ b/web/apps/playground/src/components/PlaygroundApp/__tests__/PlaygroundApp.test.tsx @@ -0,0 +1,1271 @@ +import { render, waitFor } from "@testing-library/react"; +import { PlaygroundApp } from "../PlaygroundApp"; +import { useAtom, useSetAtom } from "jotai"; +import { configAtom, errorAtom, loadingAtom } from "../../../atoms/configAtoms"; + +// Mock CodeEditor and allow it to be spied on +jest.mock("../../EditorPanel", () => ({ + EditorPanel: () =>
EditorPanel
, +})); +jest.mock("../../PreviewPanel", () => ({ + PreviewPanel: () =>
PreviewPanel
, +})); +jest.mock("@humansignal/ui", () => ({ + ...jest.requireActual("@humansignal/ui"), + ThemeToggle: () =>
ThemeToggle
, + ToastProvider: ({ children }: { children: React.ReactNode }) => children, + useToast: () => ({ + show: jest.fn(), + }), +})); + +// Mock the atoms +jest.mock("jotai", () => { + const originalModule = jest.requireActual("jotai"); + return { + ...originalModule, + useAtom: jest.fn(), + useSetAtom: jest.fn(), + }; +}); + +// Mock the fetch function +global.fetch = jest.fn(); + +function removeAllSpaceLikeCharacters(str: string): string { + return str + .replace(/\s+/g, "") // Replace all whitespace characters with empty string + .replace(/·/g, ""); // Remove the special middle dot character +} + +describe("PlaygroundApp", () => { + const mockSetConfig = jest.fn(); + const mockSetError = jest.fn(); + const mockSetLoading = jest.fn(); + const mockSetInterfaces = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useAtom as jest.Mock).mockImplementation((atom) => { + if (atom === configAtom) return ["", mockSetConfig]; + if (atom === errorAtom) return ["", mockSetError]; + if (atom === loadingAtom) return [false, mockSetLoading]; + return [null, mockSetInterfaces]; + }); + (useSetAtom as jest.Mock).mockImplementation((atom) => { + if (atom === configAtom) return (c: string) => mockSetConfig(removeAllSpaceLikeCharacters(c)); + if (atom === errorAtom) return mockSetError; + if (atom === loadingAtom) return mockSetLoading; + return mockSetInterfaces; + }); + + // Reset window.location + Object.defineProperty(window, "location", { + value: new URL("http://localhost"), + writable: true, + configurable: true, + }); + }); + + it("should handle config parameter in URL", async () => { + // Mock URL with config parameter + const mockConfig = ''; + const encodedConfig = encodeURIComponent(mockConfig.replace(/\n/g, "
")); + Object.defineProperty(window, "location", { + value: new URL(`http://localhost?config=${encodedConfig}`), + writable: true, + configurable: true, + }); + + render(); + + await waitFor(() => { + expect(mockSetConfig).toHaveBeenCalledWith(removeAllSpaceLikeCharacters(mockConfig)); + expect(mockSetError).not.toHaveBeenCalled(); + }); + }); + + it("should handle invalid config parameter", async () => { + // Mock URL with invalid config parameter that will cause decodeURIComponent to fail + Object.defineProperty(window, "location", { + value: new URL("http://localhost?config=invalid%2"), // %2 is an incomplete percent encoding + writable: true, + configurable: true, + }); + + render(); + + await waitFor(() => { + expect(mockSetError).toHaveBeenCalledWith( + "Failed to decode config. Are you sure it's a valid urlencoded string?", + ); + }); + }); + + it("should handle configUrl parameter", async () => { + // Mock URL with configUrl parameter + const mockConfig = ''; + Object.defineProperty(window, "location", { + value: new URL("http://localhost?configUrl=http://example.com/config.xml"), + writable: true, + configurable: true, + }); + + // Mock successful fetch response + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(mockConfig), + }); + + render(); + + await waitFor(() => { + expect(mockSetLoading).toHaveBeenCalledWith(true); + }); + + await waitFor(() => { + expect(mockSetConfig).toHaveBeenCalledWith(removeAllSpaceLikeCharacters(mockConfig)); + expect(mockSetLoading).toHaveBeenCalledWith(false); + }); + }); + + it("should handle failed configUrl fetch", async () => { + // Mock URL with configUrl parameter + Object.defineProperty(window, "location", { + value: new URL("http://localhost?configUrl=http://example.com/config.xml"), + writable: true, + configurable: true, + }); + + // Mock failed fetch response + (global.fetch as jest.Mock).mockRejectedValueOnce(new Error("Failed to fetch")); + + render(); + + await waitFor(() => { + expect(mockSetLoading).toHaveBeenCalledWith(true); + }); + + await waitFor(() => { + expect(mockSetError).toHaveBeenCalledWith("Failed to fetch config from URL."); + expect(mockSetLoading).toHaveBeenCalledWith(false); + }); + }); + + it("should handle non-200 configUrl response", async () => { + // Mock URL with configUrl parameter + Object.defineProperty(window, "location", { + value: new URL("http://localhost?configUrl=http://example.com/config.xml"), + writable: true, + configurable: true, + }); + + // Mock non-200 fetch response + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + }); + + render(); + + await waitFor(() => { + expect(mockSetLoading).toHaveBeenCalledWith(true); + }); + + await waitFor(() => { + expect(mockSetError).toHaveBeenCalledWith("Failed to fetch config from URL."); + expect(mockSetLoading).toHaveBeenCalledWith(false); + }); + }); + + it("should handle interfaces parameter", async () => { + // Mock URL with interfaces parameter + Object.defineProperty(window, "location", { + value: new URL("http://localhost?interfaces=skip,submit"), + writable: true, + configurable: true, + }); + + render(); + + await waitFor(() => { + expect(mockSetInterfaces).toHaveBeenCalledWith(["skip", "submit"]); + }); + }); + + describe("PlaygroundApp: Loads configs from v1 URL", () => { + it.each([ + { + name: "Annotation templates: Audio regions labeling", + url: "https://localhost?config=%3CView%3E%3Cbr%3E%20%20%3CHeader%20value%3D%22Listen%20to%20the%20audio%22%2F%3E%3Cbr%3E%20%20%3CAudio%20name%3D%22audio%22%20value%3D%22%24audio%22%2F%3E%3Cbr%3E%20%20%3CHeader%20value%3D%22Select%20its%20topic%22%2F%3E%3Cbr%3E%20%20%3CChoices%20name%3D%22topic%22%20toName%3D%22audio%22%3Cbr%3E%20%20%20%20%20%20%20%20%20%20%20choice%3D%22single-radio%22%20showInline%3D%22true%22%3E%3Cbr%3E%20%20%20%20%3CChoice%20value%3D%22Politics%22%2F%3E%3Cbr%3E%20%20%20%20%3CChoice%20value%3D%22Business%22%2F%3E%3Cbr%3E%20%20%20%20%3CChoice%20value%3D%22Education%22%2F%3E%3Cbr%3E%20%20%20%20%3CChoice%20value%3D%22Other%22%2F%3E%3Cbr%3E%20%20%3C%2FChoices%3E%3Cbr%3E%3C%2FView%3E%3Cbr%3E", + expectedConfig: ` +
+