From 61212a7c37878dad1d87a3e8479e32f49a01f83a Mon Sep 17 00:00:00 2001 From: Geddez <43729527+Geddez@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:22:43 -0500 Subject: [PATCH 1/7] Delete .github/workflows/zendesk_task_solve.yml --- .github/workflows/zendesk_task_solve.yml | 35 ------------------------ 1 file changed, 35 deletions(-) delete mode 100644 .github/workflows/zendesk_task_solve.yml diff --git a/.github/workflows/zendesk_task_solve.yml b/.github/workflows/zendesk_task_solve.yml deleted file mode 100644 index 47b180849f78..000000000000 --- a/.github/workflows/zendesk_task_solve.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: "ZenDesk: Close GitHub Issue on Zendesk Ticket Solved" - -on: - workflow_dispatch: - inputs: - external_id: - description: "GitHub issue url" - required: true - type: string - -jobs: - close_issue: - runs-on: ubuntu-latest - steps: - - uses: hmarr/debug-action@v3.0.0 - - - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GIT_PAT_HEIDI }} - script: | - // Extract issue details from the Zendesk external_id - const parts = context.payload.inputs.external_id.split("/"); - const issue_number = parts[parts.length - 1]; - const issue_repo = parts[parts.length - 3]; - const issue_owner = parts[parts.length - 4]; - - // Close the GitHub issue - const { data: issue } await github.rest.issues.update({ - owner: issue_owner, - repo: issue_repo, - issue_number: issue_number, - state: "closed" - }); - - core.info(`GitHub issue ${issue.html_url} closed successfully.`); From de5b90e9dc4d5a938340bce7117c2ac08b00aaa3 Mon Sep 17 00:00:00 2001 From: Geddez <43729527+Geddez@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:23:18 -0500 Subject: [PATCH 2/7] Delete .github/workflows/zendesk_issue_commented.yml --- .github/workflows/zendesk_issue_commented.yml | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 .github/workflows/zendesk_issue_commented.yml diff --git a/.github/workflows/zendesk_issue_commented.yml b/.github/workflows/zendesk_issue_commented.yml deleted file mode 100644 index 539d125b0c04..000000000000 --- a/.github/workflows/zendesk_issue_commented.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: "ZenDesk: Push an issue comment to zendesk ticket" - -on: - issue_comment: - types: - - created - -jobs: - issue_commented: - name: Issue comment - if: ${{ !github.event.issue.pull_request && github.event.comment.user.login != 'heidi-humansignal' }} - runs-on: ubuntu-latest - steps: - - uses: hmarr/debug-action@v3.0.0 - - - env: - ZENDESK_HOST: ${{ vars.ZENDESK_HOST }} - ZENDESK_USER: ${{ vars.ZENDESK_USER }} - ZENDESK_TOKEN: ${{ secrets.ZENDESK_TOKEN }} - ISSUE_URL: ${{ github.event.issue.html_url }} - ISSUE_COMMENT_BODY: ${{ github.event.comment.body }} - ISSUE_USER: ${{ github.event.comment.user.login }} - WORKFLOW_RUN_LINK: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - run: | - echo "Looking up ticket by issue: ${ISSUE_URL}" - tickets=$(curl "https://${ZENDESK_HOST}/api/v2/search.json?query=external_id:${ISSUE_URL}" \ - --user "${ZENDESK_USER}/token:${ZENDESK_TOKEN}" \ - -H "Content-Type: application/json") - ticket_id=$(echo $tickets | jq '.results[0].id') - echo "Found Zendesk ticket ${ticket_id}" - - echo "Looking up user by issuer: ${ISSUE_USER}" - users=$(curl "https://labelstudio.zendesk.com/api/v2/users/search.json?query=$ISSUE_USER@users.noreply.github.com" \ - --user "${ZENDESK_USER}/token:${ZENDESK_TOKEN}" \ - --header "Content-Type: application/json") - user_id=$(echo $users | jq '.users[0].id') - if [[ "$user_id" == "null" ]]; then - echo "Fall back to generic github user" - user_id="388861316959" - else - echo "Found user ${user_id}" - fi - - body=$(jq -n --arg body "$ISSUE_COMMENT_BODY" '{body: $body}' | jq .body) - echo "$body" - - curl "https://${ZENDESK_HOST}/api/v2/tickets/${ticket_id}.json" \ - --request PUT \ - --user "${ZENDESK_USER}/token:${ZENDESK_TOKEN}" \ - --header "Content-Type: application/json" \ - --data-binary @- < Date: Tue, 3 Mar 2026 08:23:42 -0500 Subject: [PATCH 3/7] Delete .github/workflows/zendesk_issue_created.yml --- .github/workflows/zendesk_issue_created.yml | 49 --------------------- 1 file changed, 49 deletions(-) delete mode 100644 .github/workflows/zendesk_issue_created.yml diff --git a/.github/workflows/zendesk_issue_created.yml b/.github/workflows/zendesk_issue_created.yml deleted file mode 100644 index 0eed06036285..000000000000 --- a/.github/workflows/zendesk_issue_created.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: "ZenDesk: Create a zendesk ticket out of an issue" - -on: - issues: - types: - - opened - -jobs: - issue_created: - name: Issue created - runs-on: ubuntu-latest - steps: - - uses: hmarr/debug-action@v3.0.0 - - - env: - ZENDESK_HOST: ${{ vars.ZENDESK_HOST }} - ZENDESK_USER: ${{ vars.ZENDESK_USER }} - ZENDESK_TOKEN: ${{ secrets.ZENDESK_TOKEN }} - ISSUE_TITLE: ${{ github.event.issue.title }} - ISSUE_BODY: ${{ github.event.issue.body }} - ISSUE_USER: ${{ github.event.issue.user.login }} - ISSUE_URL: ${{ github.event.issue.html_url }} - WORKFLOW_RUN_LINK: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - REPO_NAME: ${{ github.event.repository.name }} - run: | - body=$(jq -n --arg body "$ISSUE_BODY" '{body: $body}' | jq .body) - echo "$body" - - curl https://${ZENDESK_HOST}/api/v2/tickets \ - --request POST \ - --user "${ZENDESK_USER}/token:${ZENDESK_TOKEN}" \ - --header "Content-Type: application/json" \ - --data-binary @- < Date: Tue, 3 Mar 2026 08:23:57 -0500 Subject: [PATCH 4/7] Delete .github/workflows/zendesk_task_comment.yml --- .github/workflows/zendesk_task_comment.yml | 104 --------------------- 1 file changed, 104 deletions(-) delete mode 100644 .github/workflows/zendesk_task_comment.yml diff --git a/.github/workflows/zendesk_task_comment.yml b/.github/workflows/zendesk_task_comment.yml deleted file mode 100644 index 667171a881b2..000000000000 --- a/.github/workflows/zendesk_task_comment.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: "ZenDesk: Comment GitHub Issue on Zendesk Ticket Comment" - -on: - workflow_dispatch: - inputs: - external_id: - description: "GitHub issue url" - required: true - type: string - custom_field: - description: "Space separated list of labels" - required: false - default: "" - type: string - comment_body: - description: "Zendesk comment body" - required: true - type: string - author: - description: "Zendesk comment author" - required: false - default: "" - type: string - -jobs: - process_comment_and_labels: - runs-on: ubuntu-latest - steps: - - uses: hmarr/debug-action@v3.0.0 - - - uses: actions/github-script@v8 - env: - WORKFLOW_RUN_LINK: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - with: - github-token: ${{ secrets.GIT_PAT_HEIDI }} - script: | - // Extract issue details from the Zendesk external_id - const parts = context.payload.inputs.external_id.split("/"); - const issue_number = parts[parts.length - 1]; - const issue_repo = parts[parts.length - 3]; - const issue_owner = parts[parts.length - 4]; - - // Extract comment details - const comment_author = context.payload.inputs.author || "HumanSignal Support"; - const comment_body = context.payload.inputs.comment_body; - const formatted_comment_body = - `${comment_body} - - > Comment by ${comment_author} - > [Workflow Run](${process.env.WORKFLOW_RUN_LINK})`; - - // Add a comment to the GitHub issue - if (comment_body.startsWith('[GITHUB_ISSUE_')) { - core.notice(`Skipping comment creation.`); - } else { - const { data: comment } = await github.rest.issues.createComment({ - owner: issue_owner, - repo: issue_repo, - issue_number: issue_number, - body: formatted_comment_body - }); - core.notice(`Comment created ${comment.html_url}`); - } - - // Extract labels from the custom_field - let new_labels = []; - if (context.payload.inputs.custom_field) { - new_labels = context.payload.inputs.custom_field.split(" ").map(label => label.trim()); - } - - // Get the current labels on the GitHub issue - const { data: current_labels } = await github.rest.issues.listLabelsOnIssue({ - owner: issue_owner, - repo: issue_repo, - issue_number: issue_number - }); - - const current_label_names = current_labels.map(label => label.name); - - // Labels to be added - const labels_to_add = new_labels.filter(label => !current_label_names.includes(label)); - - // Labels to be removed - const labels_to_remove = current_label_names.filter(label => !new_labels.includes(label)); - - // Remove labels that are not in the new labels list - for (const label of labels_to_remove) { - await github.rest.issues.removeLabel({ - owner: issue_owner, - repo: issue_repo, - issue_number: issue_number, - name: label - }); - } - - // Add the new labels - if (labels_to_add.length > 0) { - await github.rest.issues.addLabels({ - owner: issue_owner, - repo: issue_repo, - issue_number: issue_number, - labels: labels_to_add - }); - } From 38194cb610527622e425effc7eddcaf8a899f8d1 Mon Sep 17 00:00:00 2001 From: Geddez Date: Wed, 4 Mar 2026 15:24:21 -0500 Subject: [PATCH 5/7] Updated dev docker --- Dockerfile.development | 6 ++++++ .../app-docker/05-check-data-permissions.sh | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Dockerfile.development b/Dockerfile.development index 9353d776102e..96a6fde654a8 100644 --- a/Dockerfile.development +++ b/Dockerfile.development @@ -19,9 +19,15 @@ ARG BRANCH_OVERRIDE FROM --platform=${BUILDPLATFORM} node:${NODE_VERSION} AS frontend-builder WORKDIR /label-studio/web +# Install frontend deps and build production assets so /web/dist exists for the final image +COPY web/package.json web/yarn.lock ./ +RUN corepack enable && yarn install --frozen-lockfile + COPY web . COPY pyproject.toml ../pyproject.toml +RUN yarn build + ################################ Stage: venv-builder (prepare the virtualenv) FROM python:${PYTHON_VERSION}-slim AS venv-builder ARG POETRY_VERSION diff --git a/deploy/docker-entrypoint.d/app-docker/05-check-data-permissions.sh b/deploy/docker-entrypoint.d/app-docker/05-check-data-permissions.sh index 48afc64e94bb..c1cce0dc449a 120000 --- a/deploy/docker-entrypoint.d/app-docker/05-check-data-permissions.sh +++ b/deploy/docker-entrypoint.d/app-docker/05-check-data-permissions.sh @@ -1 +1,5 @@ -../common/05-check-data-permissions.sh \ No newline at end of file +#!/bin/bash + +# Delegate to shared data-permissions check script +SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) +exec "$SCRIPT_DIR/../common/05-check-data-permissions.sh" \ No newline at end of file From dc67c3963ac0443582e992fe1f3a60b1721624e4 Mon Sep 17 00:00:00 2001 From: Geddez Date: Fri, 6 Mar 2026 17:27:08 -0500 Subject: [PATCH 6/7] easier Dev docker, CSR need fixing. --- Dockerfile.development | 35 +++++++++++++++++ docker-compose.dev.yml | 39 +++++++++++++++++++ docker-compose.override.example.yml | 13 ------- .../src/components/HeidiTips/liveContent.json | 4 +- .../labelstudio/src/pages/Home/HomePage.tsx | 2 +- 5 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 docker-compose.dev.yml delete mode 100644 docker-compose.override.example.yml diff --git a/Dockerfile.development b/Dockerfile.development index 96a6fde654a8..f5eb44fad39b 100644 --- a/Dockerfile.development +++ b/Dockerfile.development @@ -28,6 +28,25 @@ COPY pyproject.toml ../pyproject.toml RUN yarn build +################################ Stage: frontend-dev (hot reload frontend) +FROM --platform=${BUILDPLATFORM} node:${NODE_VERSION} AS frontend-dev +WORKDIR /label-studio/web + +# Enable file watching inside Docker on Windows/macOS +ENV HOST=0.0.0.0 \ + PORT=3000 \ + CHOKIDAR_USEPOLLING=1 \ + WATCHPACK_POLLING=true + +COPY web/package.json web/yarn.lock ./ +RUN corepack enable && yarn install + +COPY web . +COPY pyproject.toml ../pyproject.toml + +EXPOSE 3000 +CMD ["yarn", "dev", "--host", "0.0.0.0", "--port", "3000"] + ################################ Stage: venv-builder (prepare the virtualenv) FROM python:${PYTHON_VERSION}-slim AS venv-builder ARG POETRY_VERSION @@ -75,6 +94,22 @@ RUN --mount=type=cache,target=$POETRY_CACHE_DIR,sharing=locked \ poetry install --only-root --extras uwsgi && \ python3 label_studio/manage.py collectstatic --no-input +################################ Stage: backend-dev (hot reload backend) +FROM venv-builder AS backend-dev + +ENV DJANGO_SETTINGS_MODULE=core.settings.label_studio \ + DEBUG=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +# Add file watcher for reliable reloads inside Docker +RUN --mount=type=cache,target=$POETRY_CACHE_DIR,sharing=locked \ + poetry run python -m pip install --no-cache-dir watchdog + +EXPOSE 8080 +# Run migrations automatically before starting the dev server +CMD ["sh", "-c", "poetry run python label_studio/manage.py migrate && poetry run python label_studio/manage.py runserver 0.0.0.0:8080"] + ################################ Stage: py-version-generator FROM venv-builder AS py-version-generator ARG VERSION_OVERRIDE diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 000000000000..9d13d3f47041 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,39 @@ +services: + backend: + build: + context: . + dockerfile: Dockerfile.development + target: backend-dev + args: + - INCLUDE_DEV=true + env_file: + - .env + environment: + - WATCHFILES_FORCE_POLLING=true + command: sh -c "poetry run python label_studio/manage.py migrate && poetry run python label_studio/manage.py runserver 0.0.0.0:8080" + volumes: + - ./label_studio:/label-studio/label_studio + - ./deploy:/label-studio/deploy + ports: + - "8080:8080" + + frontend: + build: + context: . + dockerfile: Dockerfile.development + target: frontend-dev + args: + - INCLUDE_DEV=true + environment: + - CHOKIDAR_USEPOLLING=1 + - WATCHPACK_POLLING=true + - NX_DAEMON=false + - FRONTEND_HMR=true + - FRONTEND_HOSTNAME=http://localhost:3000 + - DJANGO_HOSTNAME=http://backend:8080 + command: yarn dev --host 0.0.0.0 --port 3000 + volumes: + - ./web:/label-studio/web + - /label-studio/web/node_modules + ports: + - "3000:3000" diff --git a/docker-compose.override.example.yml b/docker-compose.override.example.yml deleted file mode 100644 index 29c70153cf6a..000000000000 --- a/docker-compose.override.example.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: "3.9" -services: - app: - build: - args: - - INCLUDE_DEV=true - env_file: - - .env - - nginx: - build: - args: - - INCLUDE_DEV=true \ No newline at end of file diff --git a/web/apps/labelstudio/src/components/HeidiTips/liveContent.json b/web/apps/labelstudio/src/components/HeidiTips/liveContent.json index bea4adaa728c..d7dd6e05868a 100644 --- a/web/apps/labelstudio/src/components/HeidiTips/liveContent.json +++ b/web/apps/labelstudio/src/components/HeidiTips/liveContent.json @@ -237,7 +237,7 @@ } }, { - "title": "Behind the benchmark", + "title": "Behind the TestMark", "description": "Learn how Legalbenchmarks.ai built and scaled a benchmark for practical contract drafting tasks using LLM-as-a-judge and human review in Label Studio Enterprise.", "link": { "label": "Learn more", @@ -285,4 +285,4 @@ } } ] -} +} \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Home/HomePage.tsx b/web/apps/labelstudio/src/pages/Home/HomePage.tsx index 7ab42abc1ab3..244ae821aaf7 100644 --- a/web/apps/labelstudio/src/pages/Home/HomePage.tsx +++ b/web/apps/labelstudio/src/pages/Home/HomePage.tsx @@ -139,7 +139,7 @@ export const HomePage: Page = () => { Welcome 👋 - Let's get you started. + Hot reload test: tweak text and save to see live refresh.
From 44e5fe4f29a5b72dc9663de54894017ea33e39e1 Mon Sep 17 00:00:00 2001 From: GENERALRSW Date: Sat, 7 Mar 2026 13:37:09 -0500 Subject: [PATCH 7/7] Task #9: resize left-right feature and add frontend unit tests --- web/.gitignore | 3 + .../src/components/Menubar/MenuSidebar.scss | 29 +++ .../SidePanels/TabPanels/PanelTabsBase.scss | 5 +- .../SidePanels/TabPanels/SideTabsPanels.tsx | 7 +- .../__tests__/sidebar-resize.test.ts | 191 ++++++++++++++++++ .../src/components/SidePanels/constants.ts | 1 + 6 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 web/libs/editor/src/components/SidePanels/TabPanels/__tests__/sidebar-resize.test.ts diff --git a/web/.gitignore b/web/.gitignore index 82545c86fbd7..213e8c1e2b61 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -36,6 +36,9 @@ testem.log /typings .nx/ migrations.json +.claude +.claude/ +/.claude # System Files .DS_Store diff --git a/web/apps/labelstudio/src/components/Menubar/MenuSidebar.scss b/web/apps/labelstudio/src/components/Menubar/MenuSidebar.scss index 6ecc7dcb6c81..fdca8e917393 100644 --- a/web/apps/labelstudio/src/components/Menubar/MenuSidebar.scss +++ b/web/apps/labelstudio/src/components/Menubar/MenuSidebar.scss @@ -64,4 +64,33 @@ opacity: 1; transform: rotate(-45deg); } + + &__resize-handle { + position: fixed; + top: var(--header-height); + left: var(--menu-sidebar-width); + width: 6px; + height: calc(100vh - var(--header-height)); + cursor: col-resize; + z-index: 101; + user-select: none; + transform: translateX(-50%); + + &::after { + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 2px; + height: 100%; + background: var(--color-primary-border); + opacity: 0.3; + transition: opacity 150ms ease; + } + + &:hover::after { + opacity: 1; + } + } } \ No newline at end of file diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.scss b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.scss index 0d601ca25c24..7bda0e44fadd 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.scss +++ b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.scss @@ -234,7 +234,7 @@ top: calc(var(--size) / 2); width: var(--size); height: calc(100% - var(--size)); - cursor: ew-resize; + cursor: col-resize; } &[data-resize="left"] { @@ -327,11 +327,14 @@ transform: translate(-50%, 0); height: calc(100% + var(--size)); top: calc(var(--size) / 2 * -1); + display: block; + opacity: 0.3; } &:hover::before, &_drag::before { display: block; + opacity: 1; } } diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx index 7a0642fb0b8a..bad80abc7d05 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx @@ -8,6 +8,7 @@ import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_MAX_HEIGHT, DEFAULT_PANEL_MAX_WIDTH, + DEFAULT_PANEL_MIN_WIDTH, DEFAULT_PANEL_WIDTH, PANEL_HEADER_HEIGHT, } from "../constants"; @@ -315,7 +316,7 @@ const SideTabsPanelsComponent: FC = ({ storedLeft: undefined, storedTop: undefined, maxHeight, - width: clamp(w, DEFAULT_PANEL_WIDTH, panelMaxWidth), + width: clamp(w, DEFAULT_PANEL_MIN_WIDTH, panelMaxWidth), height: panelData[panelKey].detached ? clamp(h, DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_MAX_HEIGHT) : panelData[panelKey].height, @@ -504,14 +505,14 @@ const SideTabsPanelsComponent: FC = ({ viewportSize.current.width = clientWidth ?? 0; viewportSize.current.height = clientHeight ?? 0; setViewportSizeMatch(checkContentFit()); - setPanelMaxWidth(rootRef.current.clientWidth * 0.4); + setPanelMaxWidth(rootRef.current.clientWidth * 0.6); }); }); if (root) { observer.observe(root); setViewportSizeMatch(checkContentFit()); - setPanelMaxWidth(root.clientWidth * 0.4); + setPanelMaxWidth(root.clientWidth * 0.6); setInitialized(true); } diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/__tests__/sidebar-resize.test.ts b/web/libs/editor/src/components/SidePanels/TabPanels/__tests__/sidebar-resize.test.ts new file mode 100644 index 000000000000..d640fbce3431 --- /dev/null +++ b/web/libs/editor/src/components/SidePanels/TabPanels/__tests__/sidebar-resize.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for the resizable sidebar panel feature. + * + * These tests guard against regressions that would break the ability to + * resize the side panels (left/right) in the labeling interface. + */ +import { + DEFAULT_PANEL_HEIGHT, + DEFAULT_PANEL_MAX_WIDTH, + DEFAULT_PANEL_MIN_HEIGHT, + DEFAULT_PANEL_MIN_WIDTH, + DEFAULT_PANEL_WIDTH, +} from "../../constants"; +import { resizePanelColumns, resizers } from "../utils"; +import { type PanelBBox, Side } from "../types"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makePanel = (overrides: Partial = {}): PanelBBox => ({ + top: 0, + left: 0, + order: 0, + relativeLeft: 0, + relativeTop: 0, + zIndex: 1, + width: DEFAULT_PANEL_WIDTH, + height: DEFAULT_PANEL_HEIGHT, + visible: true, + detached: false, + alignment: Side.right, + maxHeight: 1000, + panelViews: [], + ...overrides, +}); + +/** + * Pure reimplementation of the resizer-visibility rule from PanelTabsBase.tsx + * so we can unit-test it without rendering React. + * + * Source: PanelTabsBase.tsx — the `shouldRender` expression inside + * the {resizers.map(…)} block. + */ +const shouldRenderResizer = ( + res: string, + alignment: string, + collapsed: boolean, + detached: boolean, +): boolean => (collapsed ? false : ((res === "left" || res === "right") && alignment !== res) || detached); + +// --------------------------------------------------------------------------- +// 1. Constants +// --------------------------------------------------------------------------- + +describe("Sidebar resize constants", () => { + it("DEFAULT_PANEL_MIN_WIDTH is 180", () => { + expect(DEFAULT_PANEL_MIN_WIDTH).toBe(180); + }); + + it("DEFAULT_PANEL_WIDTH is 320", () => { + expect(DEFAULT_PANEL_WIDTH).toBe(320); + }); + + it("DEFAULT_PANEL_MAX_WIDTH is 500", () => { + expect(DEFAULT_PANEL_MAX_WIDTH).toBe(500); + }); + + it("DEFAULT_PANEL_MIN_WIDTH < DEFAULT_PANEL_WIDTH < DEFAULT_PANEL_MAX_WIDTH", () => { + expect(DEFAULT_PANEL_MIN_WIDTH).toBeLessThan(DEFAULT_PANEL_WIDTH); + expect(DEFAULT_PANEL_WIDTH).toBeLessThan(DEFAULT_PANEL_MAX_WIDTH); + }); + + it("DEFAULT_PANEL_MIN_HEIGHT is positive", () => { + expect(DEFAULT_PANEL_MIN_HEIGHT).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Resizer visibility logic +// --------------------------------------------------------------------------- + +describe("Sidebar resizer visibility", () => { + describe("left-aligned panel", () => { + it("shows the right-side resizer", () => { + expect(shouldRenderResizer("right", "left", false, false)).toBe(true); + }); + + it("does NOT show the left-side resizer (would overlap the panel edge)", () => { + expect(shouldRenderResizer("left", "left", false, false)).toBe(false); + }); + + it("does NOT show top/bottom resizers when attached", () => { + expect(shouldRenderResizer("top", "left", false, false)).toBe(false); + expect(shouldRenderResizer("bottom", "left", false, false)).toBe(false); + }); + }); + + describe("right-aligned panel", () => { + it("shows the left-side resizer", () => { + expect(shouldRenderResizer("left", "right", false, false)).toBe(true); + }); + + it("does NOT show the right-side resizer", () => { + expect(shouldRenderResizer("right", "right", false, false)).toBe(false); + }); + + it("does NOT show top/bottom resizers when attached", () => { + expect(shouldRenderResizer("top", "right", false, false)).toBe(false); + expect(shouldRenderResizer("bottom", "right", false, false)).toBe(false); + }); + }); + + describe("detached (floating) panel", () => { + it("shows ALL resizers", () => { + for (const res of resizers) { + expect(shouldRenderResizer(res, "left", false, true)).toBe(true); + } + }); + }); + + describe("collapsed panel", () => { + it("shows NO resizers regardless of alignment", () => { + for (const res of resizers) { + expect(shouldRenderResizer(res, "left", true, false)).toBe(false); + expect(shouldRenderResizer(res, "right", true, false)).toBe(false); + } + }); + }); +}); + +// --------------------------------------------------------------------------- +// 3. resizePanelColumns — group height resize +// --------------------------------------------------------------------------- + +describe("resizePanelColumns", () => { + it("returns the original state when the panel has no alignment", () => { + const state: Record = { + panel1: makePanel({ alignment: undefined as unknown as Side }), + }; + const result = resizePanelColumns(state, "panel1", 300, 0, 1000); + + expect(result).toBe(state); + }); + + it("clamps the resized panel height to DEFAULT_PANEL_MIN_HEIGHT", () => { + const state: Record = { + panel1: makePanel({ height: 500, order: 0 }), + panel2: makePanel({ height: 500, order: 1 }), + }; + const belowMin = DEFAULT_PANEL_MIN_HEIGHT - 10; + const result = resizePanelColumns(state, "panel2", belowMin, 0, 1000); + + expect(result["panel2"].height).toBeGreaterThanOrEqual(DEFAULT_PANEL_MIN_HEIGHT); + }); + + it("sets the resized panel to the requested height when within bounds", () => { + const state: Record = { + panel1: makePanel({ height: 500, order: 0 }), + panel2: makePanel({ height: 500, order: 1 }), + }; + const newHeight = 300; + const result = resizePanelColumns(state, "panel2", newHeight, 0, 1000); + + expect(result["panel2"].height).toBe(newHeight); + }); + + it("does not exceed availableHeight for any panel", () => { + const availableHeight = 800; + const state: Record = { + panel1: makePanel({ height: 400, order: 0 }), + panel2: makePanel({ height: 400, order: 1 }), + }; + const result = resizePanelColumns(state, "panel2", 700, 0, availableHeight); + + for (const panel of Object.values(result)) { + expect(panel.height).toBeLessThanOrEqual(availableHeight); + } + }); + + it("invisible panels are not resized", () => { + const state: Record = { + panel1: makePanel({ height: 500, order: 0, visible: false }), + panel2: makePanel({ height: 500, order: 1 }), + }; + const originalHeight = state["panel1"].height; + const result = resizePanelColumns(state, "panel2", 300, 0, 1000); + + expect(result["panel1"].height).toBe(originalHeight); + }); +}); diff --git a/web/libs/editor/src/components/SidePanels/constants.ts b/web/libs/editor/src/components/SidePanels/constants.ts index 4fd01bdc3e9c..9ef7b30e6e05 100644 --- a/web/libs/editor/src/components/SidePanels/constants.ts +++ b/web/libs/editor/src/components/SidePanels/constants.ts @@ -1,4 +1,5 @@ export const DEFAULT_PANEL_WIDTH = 320; +export const DEFAULT_PANEL_MIN_WIDTH = 180; export const DEFAULT_PANEL_HEIGHT = 300; export const DEFAULT_PANEL_MAX_WIDTH = 500; export const DEFAULT_PANEL_MAX_HEIGHT = 500;