diff --git a/.env.example b/.env.example index 097e6aa65f..649885ef9c 100644 --- a/.env.example +++ b/.env.example @@ -110,6 +110,24 @@ ANTHROPIC_API_KEY= # ----------------------------------------------------------------------------- BLOB_READ_WRITE_TOKEN= +# ----------------------------------------------------------------------------- +# Cloudflare storage + user content origin (pages, thumbnails, chat attachments) +# ----------------------------------------------------------------------------- +CLOUDFLARE_ACCOUNT_ID= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +# Pages and files; read through the usercontent Worker (apps/usercontent) with a ticket. +R2_PRIVATE_BUCKET= +# Where pages are served from, one subdomain per page under it (apps/usercontent): +# https://frame.supersetusercontent.com +USERCONTENT_URL= +# App-referenced files (apps/usercontent, same Worker): https://media.supersetusercontent.com +MEDIA_URL= +# Shared with the usercontent Worker (wrangler secret); 32+ characters. +USERCONTENT_TOKEN_SECRET= +# Optional: page thumbnails are skipped while unset. +CLOUDFLARE_BROWSER_RENDERING_TOKEN= + # ----------------------------------------------------------------------------- # PostHog Analytics # ----------------------------------------------------------------------------- @@ -130,6 +148,24 @@ NEXT_PUBLIC_SENTRY_DSN_DOCS= NEXT_PUBLIC_SENTRY_DSN_API= SENTRY_DSN_DESKTOP= +# Sentry public integration — powers Sentry automation triggers (issue created, +# resolved, assigned, ...). Optional: leave blank and the Sentry trigger simply +# reports "Requires connection" in the editor; nothing else breaks. +# +# These come from a Sentry *public integration* (Settings -> Developer Settings +# -> Public Integration), not from the DSN/auth-token above, which are for error +# reporting. A Sentry app allows exactly one Redirect URL, so a local checkout +# needs its own app rather than sharing the deployed one: +# Redirect URL: http://localhost:/api/integrations/sentry/callback +# Webhook URL: http://localhost:/api/integrations/sentry/webhook +# Permissions: Project, Issue & Event, Organization, Member = Read. +# The client secret is shown once, at creation — save it then. +# Sentry cannot reach localhost, so webhooks (and therefore live triggers) need +# a tunnel; the install flow and the project picker work without one. +SENTRY_APP_SLUG= +SENTRY_CLIENT_ID= +SENTRY_CLIENT_SECRET= + # ----------------------------------------------------------------------------- # Resend (Email) # ----------------------------------------------------------------------------- diff --git a/.env.local.example b/.env.local.example index 1a1838ac12..85f49d4aab 100644 --- a/.env.local.example +++ b/.env.local.example @@ -95,6 +95,24 @@ ANTHROPIC_API_KEY=sk-ant-fake-local-dev # ----------------------------------------------------------------------------- BLOB_READ_WRITE_TOKEN=vercel_blob_rw_fake_local_dev +# ----------------------------------------------------------------------------- +# Cloudflare storage + user content origin (fake for local dev; publishing a +# page needs real R2 credentials and `bun run --cwd apps/usercontent dev`) +# ----------------------------------------------------------------------------- +CLOUDFLARE_ACCOUNT_ID=fake-cloudflare-account-id +R2_ACCESS_KEY_ID=fake-r2-access-key-id +R2_SECRET_ACCESS_KEY=fake-r2-secret-access-key +R2_PRIVATE_BUCKET=superset-private-dev +# Setup's port allocator writes USERCONTENT_DEV_PORT (base+14) and this URL +# into the shared .env; 8787 is the bare fallback used without setup. +USERCONTENT_URL=http://frame.usercontent.localhost:8787 +MEDIA_URL=http://media.usercontent.localhost:8787 +USERCONTENT_TOKEN_SECRET=local-dev-usercontent-secret-change-me-32 +# Deliberately empty: unset means page thumbnails are skipped, which is the +# right local behavior. A fake value would make every publish enqueue a +# doomed capture job. +CLOUDFLARE_BROWSER_RENDERING_TOKEN= + # ----------------------------------------------------------------------------- # PostHog Analytics (disabled in local dev) # ----------------------------------------------------------------------------- @@ -115,6 +133,24 @@ NEXT_PUBLIC_SENTRY_DSN_DOCS= NEXT_PUBLIC_SENTRY_DSN_API= SENTRY_DSN_DESKTOP= +# Sentry public integration — powers Sentry automation triggers (issue created, +# resolved, assigned, ...). Optional: leave blank and the Sentry trigger simply +# reports "Requires connection" in the editor; nothing else breaks. +# +# These come from a Sentry *public integration* (Settings -> Developer Settings +# -> Public Integration), not from the DSN/auth-token above, which are for error +# reporting. A Sentry app allows exactly one Redirect URL, so a local checkout +# needs its own app rather than sharing the deployed one: +# Redirect URL: http://localhost:/api/integrations/sentry/callback +# Webhook URL: http://localhost:/api/integrations/sentry/webhook +# Permissions: Project, Issue & Event, Organization, Member = Read. +# The client secret is shown once, at creation — save it then. +# Sentry cannot reach localhost, so webhooks (and therefore live triggers) need +# a tunnel; the install flow and the project picker work without one. +SENTRY_APP_SLUG= +SENTRY_CLIENT_ID= +SENTRY_CLIENT_SECRET= + # ----------------------------------------------------------------------------- # Resend (Email — fake for local dev) # ----------------------------------------------------------------------------- diff --git a/.github/prompts/update-roadmap.md b/.github/prompts/update-roadmap.md index d47845a225..5d89fe2e20 100644 --- a/.github/prompts/update-roadmap.md +++ b/.github/prompts/update-roadmap.md @@ -33,9 +33,14 @@ The Notion database "Product Roadmap" (data source `collection://7cbe2be9-46f2-4 Regenerate the `ROADMAP_ITEMS` array in `apps/marketing/src/app/roadmap/data.ts`: - `id`: kebab-case slug of the public title (stable across runs — reuse existing ids where the item is unchanged). -- `title` = `Public title`, `description` = `Public description`, `category` = `Public category`, `status` = mapped lane. +- `title` and `description` are Lingui message descriptors, not plain strings. Write them as + `msg({ id: "marketing.roadmap.item..title", message: "Public title" })` and + `msg({ id: "marketing.roadmap.item..description", message: "Public description" })`, + where `` is the item's `id` in camelCase. Message IDs are permanent: when an item's + copy changes, edit the `message` and keep the existing ID. Only a genuinely new item gets a new ID. +- `category` = `Public category`, `status` = mapped lane. Both stay raw union values, not descriptors. - Order within each lane: keep the existing file's order for unchanged items; append new items at the end of their lane. -- Do not change the types, `CATEGORIES`, `STATUS_LABELS`, or `STATUS_DESCRIPTIONS` unless a `Public category` value doesn't exist in the union — in that case flag it in the PR description instead of editing types. +- Do not change the types, `CATEGORIES`, `CATEGORY_LABELS`, `STATUS_LABELS`, or `STATUS_DESCRIPTIONS` unless a `Public category` value doesn't exist in the union — in that case flag it in the PR description instead of editing types. ## Ship it diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cb36c087d..76d4f996ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,11 @@ jobs: steps: - name: Check out code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + # The i18n audit compares this branch against its merge base to catch + # translations left behind by an English edit; a shallow clone has no + # merge base to find. + fetch-depth: 0 - name: Setup Bun id: setup-bun @@ -115,6 +120,8 @@ jobs: # Message catalogs must match source (extract) and compile with no # missing translations (--strict); a dirty diff means someone forgot # to run `bun run --cwd packages/i18n check` after changing strings. + # The audit also fails on translations left stranded by an English edit, + # which --strict cannot see because nothing is missing. - name: Audit i18n catalogs run: bun run --cwd packages/i18n check diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 833c28eca6..9181000d31 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -128,6 +128,14 @@ jobs: DATABASE_URL: ${{ env.DATABASE_URL }} DATABASE_URL_UNPOOLED: ${{ env.DATABASE_URL_UNPOOLED }} BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_PRIVATE_BUCKET: superset-private-dev + USERCONTENT_URL: ${{ vars.USERCONTENT_URL_DEV }} + MEDIA_URL: ${{ vars.MEDIA_URL_DEV }} + USERCONTENT_TOKEN_SECRET: ${{ secrets.USERCONTENT_TOKEN_SECRET_DEV }} + CLOUDFLARE_BROWSER_RENDERING_TOKEN: ${{ secrets.CLOUDFLARE_BROWSER_RENDERING_TOKEN }} NEXT_PUBLIC_API_URL: https://${{ env.API_ALIAS }} NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} NEXT_PUBLIC_MARKETING_URL: https://${{ env.MARKETING_ALIAS }} @@ -185,6 +193,14 @@ jobs: --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env BLOB_READ_WRITE_TOKEN=$BLOB_READ_WRITE_TOKEN \ + --env CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID \ + --env R2_ACCESS_KEY_ID=$R2_ACCESS_KEY_ID \ + --env R2_SECRET_ACCESS_KEY=$R2_SECRET_ACCESS_KEY \ + --env R2_PRIVATE_BUCKET=$R2_PRIVATE_BUCKET \ + --env USERCONTENT_URL=$USERCONTENT_URL \ + --env MEDIA_URL=$MEDIA_URL \ + --env USERCONTENT_TOKEN_SECRET=$USERCONTENT_TOKEN_SECRET \ + --env CLOUDFLARE_BROWSER_RENDERING_TOKEN=$CLOUDFLARE_BROWSER_RENDERING_TOKEN \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ --env NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \ @@ -307,6 +323,8 @@ jobs: NEXT_PUBLIC_ADMIN_URL: https://${{ env.ADMIN_ALIAS }} NEXT_PUBLIC_DOCS_URL: https://${{ env.DOCS_ALIAS }} NEXT_PUBLIC_RELAY_URL: ${{ secrets.RELAY_URL }} + USERCONTENT_URL: ${{ vars.USERCONTENT_URL_DEV }} + MEDIA_URL: ${{ vars.MEDIA_URL_DEV }} BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} @@ -340,6 +358,8 @@ jobs: --env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \ --env NEXT_PUBLIC_DOCS_URL=$NEXT_PUBLIC_DOCS_URL \ --env NEXT_PUBLIC_RELAY_URL=$NEXT_PUBLIC_RELAY_URL \ + --env USERCONTENT_URL=$USERCONTENT_URL \ + --env MEDIA_URL=$MEDIA_URL \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ @@ -360,6 +380,10 @@ jobs: vercel alias $VERCEL_URL ${{ env.WEB_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT + - name: Smoke test web routes + run: | + bun scripts/smoke-routes.ts "https://${{ env.WEB_ALIAS }}" / + - name: Save web success status run: | cat > web-status.env << EOF @@ -475,6 +499,17 @@ jobs: vercel alias $VERCEL_URL ${{ env.MARKETING_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT + # Until now the pipeline deployed and never requested a page, so a build + # that returned 500 on every route still went green. MARKETING-67/68/69 + # reached production that way. + - name: Smoke test marketing routes + run: | + bun scripts/smoke-routes.ts "https://${{ env.MARKETING_ALIAS }}" --localized \ + / /pricing /enterprise /download /join-us /community /contact \ + /blog /changelog /compare /roadmap /leaderboard /marketplace \ + /agent-orchestration /parallel-coding-agents /factory-2026 \ + /mcp-install /stats /team /starchart /the-production-run + - name: Save marketing success status run: | cat > marketing-status.env << EOF @@ -594,6 +629,10 @@ jobs: vercel alias $VERCEL_URL ${{ env.ADMIN_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT + - name: Smoke test admin routes + run: | + bun scripts/smoke-routes.ts "https://${{ env.ADMIN_ALIAS }}" / + - name: Save admin success status run: | cat > admin-status.env << EOF @@ -678,6 +717,10 @@ jobs: vercel alias $VERCEL_URL ${{ env.DOCS_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT + - name: Smoke test docs routes + run: | + bun scripts/smoke-routes.ts "https://${{ env.DOCS_ALIAS }}" / + - name: Save docs success status run: | cat > docs-status.env << EOF diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index ce9b97e726..6ff5d90ef0 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -85,6 +85,14 @@ jobs: NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_PRIVATE_BUCKET: ${{ vars.R2_PRIVATE_BUCKET }} + USERCONTENT_URL: ${{ vars.USERCONTENT_URL }} + MEDIA_URL: ${{ vars.MEDIA_URL }} + USERCONTENT_TOKEN_SECRET: ${{ secrets.USERCONTENT_TOKEN_SECRET }} + CLOUDFLARE_BROWSER_RENDERING_TOKEN: ${{ secrets.CLOUDFLARE_BROWSER_RENDERING_TOKEN }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} GOOGLE_PUBSUB_TOPIC: ${{ secrets.GOOGLE_PUBSUB_TOPIC }} @@ -148,6 +156,14 @@ jobs: --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env BLOB_READ_WRITE_TOKEN=$BLOB_READ_WRITE_TOKEN \ + --env CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID \ + --env R2_ACCESS_KEY_ID=$R2_ACCESS_KEY_ID \ + --env R2_SECRET_ACCESS_KEY=$R2_SECRET_ACCESS_KEY \ + --env R2_PRIVATE_BUCKET=$R2_PRIVATE_BUCKET \ + --env USERCONTENT_URL=$USERCONTENT_URL \ + --env MEDIA_URL=$MEDIA_URL \ + --env USERCONTENT_TOKEN_SECRET=$USERCONTENT_TOKEN_SECRET \ + --env CLOUDFLARE_BROWSER_RENDERING_TOKEN=$CLOUDFLARE_BROWSER_RENDERING_TOKEN \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ --env NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \ @@ -252,6 +268,8 @@ jobs: NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }} NEXT_PUBLIC_DOCS_URL: ${{ secrets.NEXT_PUBLIC_DOCS_URL }} NEXT_PUBLIC_RELAY_URL: ${{ secrets.RELAY_URL }} + USERCONTENT_URL: ${{ vars.USERCONTENT_URL }} + MEDIA_URL: ${{ vars.MEDIA_URL }} BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} @@ -285,6 +303,8 @@ jobs: --env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \ --env NEXT_PUBLIC_DOCS_URL=$NEXT_PUBLIC_DOCS_URL \ --env NEXT_PUBLIC_RELAY_URL=$NEXT_PUBLIC_RELAY_URL \ + --env USERCONTENT_URL=$USERCONTENT_URL \ + --env MEDIA_URL=$MEDIA_URL \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ @@ -517,6 +537,37 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: bunx wrangler deploy + deploy-content: + name: Deploy Usercontent Worker to Cloudflare + runs-on: ubuntu-latest + environment: production + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + id: setup-bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Cache dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} + + - name: Install dependencies + run: bun install --frozen --ignore-scripts + + - name: Deploy Worker + working-directory: apps/usercontent + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: bunx wrangler deploy + deploy-docs: name: Deploy Docs to Vercel runs-on: ubuntu-latest diff --git a/.github/workflows/translate-catalogs.yml b/.github/workflows/translate-catalogs.yml new file mode 100644 index 0000000000..7d5ef857c1 --- /dev/null +++ b/.github/workflows/translate-catalogs.yml @@ -0,0 +1,127 @@ +name: Translate Catalogs + +# Every enabled locale is gated by `lingui compile --strict`, so a PR that +# adds or changes an English string owes a translation in all of them. This +# workflow closes that gap on the PR itself: when the extracted catalogs have +# untranslated entries, it fills them with Claude, verifies the strict gate, +# and pushes the fills back to the PR branch. +# +# The push uses a GitHub App token rather than GITHUB_TOKEN because pushes +# made with GITHUB_TOKEN do not trigger workflows — the PR would be stuck +# with no checks on its newest commit. + +on: + pull_request: + types: [opened, synchronize] + +concurrency: + group: translate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + translate: + name: Fill missing translations + # Fork PRs have no secrets; their catalog gap surfaces in the Lint job's + # catalog audit instead, for a maintainer to fill. Mirrors lack the GitHub + # App secrets the push step needs, so the job is gated on the repository name. + if: github.repository == 'superset-sh/superset' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + + steps: + - name: Mint app token + id: app-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + # Least privilege: pushing the fills needs contents only. + permission-contents: write + + - name: Checkout PR head + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + # The stale-translation check resolves a merge base. + fetch-depth: 0 + # Extract and the translator execute repo-controlled code (lingui + # config, scripts), so the token must not sit in git config where a + # malicious PR could read it. It is passed only to the push step. + persist-credentials: false + + - name: Setup Bun + id: setup-bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Cache dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} + + - name: Install dependencies + run: bun install --frozen --ignore-scripts + + - name: Extract catalogs and count missing translations + id: missing + working-directory: packages/i18n + run: | + bunx lingui extract --clean --overwrite > /dev/null + bun scripts/sort-po-references.ts > /dev/null + # Entry-aware count: the PO header is msgid "" / msgstr "" and must + # not be mistaken for an untranslated entry. + COUNT=$(bun -e ' + import { readFileSync, readdirSync } from "node:fs"; + let sum = 0; + for (const loc of readdirSync("locales", { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name !== "en") + .map((e) => e.name)) { + const src = readFileSync(`locales/${loc}/messages.po`, "utf8"); + for (const m of src.matchAll(/msgid "((?:[^"\\]|\\.)+)"\nmsgstr ""/g)) sum++; + } + console.log(sum);') + echo "count=$COUNT" >> "$GITHUB_OUTPUT" + echo "Missing translations: $COUNT" + + - name: Translate missing entries + if: steps.missing.outputs.count != '0' + working-directory: packages/i18n + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: bun scripts/translate-missing.ts + + - name: Verify the strict gate passes with the fills + if: steps.missing.outputs.count != '0' + working-directory: packages/i18n + run: | + bun scripts/sort-po-references.ts > /dev/null + bunx lingui compile --strict + bun scripts/check-stale-translations.ts + + # Commit carries no token: earlier PR-controlled steps can write + # .git/hooks, and a hook run by git commit inherits the step env. + - name: Commit fills + if: steps.missing.outputs.count != '0' + run: | + git config user.name "superset-i18n[bot]" + git config user.email "i18n-bot@superset.sh" + git add packages/i18n/locales + if git diff --cached --quiet; then + echo "Translator produced no accepted fills; leaving the PR red for a human." + exit 1 + fi + git -c core.hooksPath=/dev/null commit -m "chore(i18n): translate new strings across enabled locales" + + - name: Push fills + if: steps.missing.outputs.count != '0' + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + # The head ref is attacker-named text; it reaches the shell only as + # a quoted variable, never by template interpolation. + HEAD_REF: ${{ github.event.pull_request.head.ref }} + REPO: ${{ github.repository }} + run: | + git -c core.hooksPath=/dev/null push \ + "https://x-access-token:${APP_TOKEN}@github.com/${REPO}.git" \ + "HEAD:refs/heads/${HEAD_REF}" diff --git a/.superset/lib/setup/steps.sh b/.superset/lib/setup/steps.sh index 88b55ecf4b..5de2014c6b 100644 --- a/.superset/lib/setup/steps.sh +++ b/.superset/lib/setup/steps.sh @@ -306,7 +306,7 @@ step_write_env() { # Each workspace gets a range of 20 ports from its base. # Offsets: +0 web, +1 api, +2 marketing, +3 admin, +4 docs, # +5 desktop vite, +6 notifications, +7 streams, +8 streams internal, - # +11 code inspector, +13 relay + # +11 code inspector, +13 relay, +14 usercontent worker # (+9, +10, +12 were Electric/Caddy/wrangler; retired, kept unassigned so # the surviving offsets stay stable across existing allocations) local BASE=$SUPERSET_PORT_BASE @@ -323,6 +323,7 @@ step_write_env() { local STREAMS_INTERNAL_PORT=$((BASE + 8)) local CODE_INSPECTOR_PORT=$((BASE + 11)) local RELAY_PORT=$((BASE + 13)) + local USERCONTENT_DEV_PORT=$((BASE + 14)) echo "" echo "# Workspace Ports (allocated from SUPERSET_PORT_BASE=$BASE, range=20)" @@ -338,6 +339,7 @@ step_write_env() { write_env_var "STREAMS_INTERNAL_PORT" "$STREAMS_INTERNAL_PORT" write_env_var "CODE_INSPECTOR_PORT" "$CODE_INSPECTOR_PORT" write_env_var "RELAY_PORT" "$RELAY_PORT" + write_env_var "USERCONTENT_DEV_PORT" "$USERCONTENT_DEV_PORT" echo "" echo "# Cross-app URLs (overrides from root .env)" write_env_var "NEXT_PUBLIC_API_URL" "http://localhost:$API_PORT" @@ -352,6 +354,7 @@ step_write_env() { write_env_var "RELAY_URL" "http://localhost:$RELAY_PORT" write_env_var "NEXT_PUBLIC_RELAY_URL" "http://localhost:$RELAY_PORT" write_env_var "SUPERSET_WEB_URL" "http://localhost:$WEB_PORT" + write_env_var "USERCONTENT_URL" "http://frame.usercontent.localhost:$USERCONTENT_DEV_PORT" echo "" echo "# Streams URLs (overrides from root .env)" write_env_var "PORT" "$STREAMS_PORT" @@ -379,6 +382,7 @@ step_write_env() { { "port": $DESKTOP_NOTIFICATIONS_PORT, "label": "Notifications" }, { "port": $STREAMS_PORT, "label": "Streams" }, { "port": $STREAMS_INTERNAL_PORT, "label": "Streams Internal" }, + { "port": $USERCONTENT_DEV_PORT, "label": "Usercontent Worker" }, { "port": $CODE_INSPECTOR_PORT, "label": "Code Inspector" } ] } diff --git a/.superset/setup.local.sh b/.superset/setup.local.sh index 0a256214db..b580c4afa2 100755 --- a/.superset/setup.local.sh +++ b/.superset/setup.local.sh @@ -193,6 +193,7 @@ local_write_env() { local STREAMS_INTERNAL_PORT=$((BASE + 8)) local CODE_INSPECTOR_PORT=$((BASE + 11)) local RELAY_PORT=$((BASE + 13)) + local USERCONTENT_DEV_PORT=$((BASE + 14)) { echo "" @@ -226,6 +227,7 @@ local_write_env() { write_env_var "STREAMS_INTERNAL_PORT" "$STREAMS_INTERNAL_PORT" write_env_var "CODE_INSPECTOR_PORT" "$CODE_INSPECTOR_PORT" write_env_var "RELAY_PORT" "$RELAY_PORT" + write_env_var "USERCONTENT_DEV_PORT" "$USERCONTENT_DEV_PORT" echo "" echo "# Cross-app URLs (allocated ports)" write_env_var "NEXT_PUBLIC_API_URL" "http://localhost:$API_PORT" @@ -237,6 +239,7 @@ local_write_env() { write_env_var "RELAY_URL" "http://localhost:$RELAY_PORT" write_env_var "NEXT_PUBLIC_RELAY_URL" "http://localhost:$RELAY_PORT" write_env_var "SUPERSET_WEB_URL" "http://localhost:$WEB_PORT" + write_env_var "USERCONTENT_URL" "http://frame.usercontent.localhost:$USERCONTENT_DEV_PORT" echo "" echo "# Streams URLs" write_env_var "PORT" "$STREAMS_PORT" @@ -260,6 +263,7 @@ local_write_env() { { "port": $DESKTOP_VITE_PORT, "label": "Desktop Vite" }, { "port": $DESKTOP_NOTIFICATIONS_PORT, "label": "Notifications" }, { "port": $STREAMS_PORT, "label": "Streams" }, + { "port": $USERCONTENT_DEV_PORT, "label": "Usercontent Worker" }, { "port": $LOCAL_PG_PORT, "label": "Postgres" }, { "port": $LOCAL_NEON_PROXY_PORT, "label": "Neon Proxy" }, { "port": $LOCAL_REDIS_PORT, "label": "Redis" }, diff --git a/AGENTS.md b/AGENTS.md index 1732d00149..9badcff971 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,12 @@ superset ws delete WORKSPACE_ID In order: an isolated workspace with an agent already working in it, another agent in an existing workspace, what's running, what an agent is doing right now, and cleanup when you're done. +Spawning several related workspaces? Add `--tag SOME_TAG` (repeatable) to `ws create` — tagged +workspaces group into a sidebar folder of that name automatically, so a batch files itself instead +of scattering across the project. `ws list --tag SOME_TAG` filters to them, and +`ws update WORKSPACE_ID --tag ...` retags (`--clear-tags` ungroups). Automation-created workspaces +are tagged `automation` by default and collect in an "automation" folder. + `superset --help` covers the rest (tasks, automations, hosts, settings). Pass `--json` for parsable output; it's on by default under agent environments. @@ -126,6 +132,32 @@ JSX text — add a directory there once it is fully converted. `errorMessage()` translated and is display-only: logs, Sentry/PostHog, and error classification use `rawErrorMessage()` or the error object (enforced by `packages/i18n/test/display-only.test.ts`). +**Shipping locales.** `SUPPORTED_LOCALES` in `packages/i18n/src/locales.ts` is the single +source of truth — adding a locale there is what makes it appear in the Settings picker and +the optional onboarding step, and what `lingui.config.ts` must list. Every enabled locale +must be **fully translated**: `compile --strict` fails the build on a missing message, so +finish a translation before adding its locale. Native language names live in `LOCALE_LABELS` +and are never translated — someone stuck in the wrong language has to recognize their own. +Relative times use `formatRelativeTime`/`formatCompactRelativeTime`, not hand-rolled +"3d ago" helpers; `Intl` already knows every locale's wording. + +Three traps worth knowing before you touch catalogs: + +- **Editing English copy is not enough.** IDs are stable, so Lingui keeps the text loosely + coupled to them: `locales/en/messages.po` is what actually renders, and translations are + never invalidated when the English moves. `extract` runs `--overwrite` so the source locale + is always regenerated, and the `check` script fails on translations the edit stranded. + Details and the exemption file: `packages/i18n/README.md`. +- **Regenerate from a clean tree.** `messages.po` is environment-sensitive. Entry order and + `#:` reference order both used to vary between macOS and Linux; `orderBy: "messageId"` and + `scripts/sort-po-references.ts` pin them, but a catalog regenerated on top of local + experiments will still commit noise. +- **`bun test` runs uncompiled source.** The Lingui macro rewrites `` message: `${n} items` `` + into a placeholder message plus values at build time, so the catalog stores `{n} items`. + Tests see neither, which is why `apps/desktop/test-setup.ts` shims the macros and `i18n._`. + Mock that module with a Proxy, never a spread — `i18n` is a class instance and a spread + drops `load`/`activate`. + ## Further reading - `.agents/skills/`: CDP UI verification, DB migrations, ticket format, and more. Read the matching diff --git a/README.md b/README.md index 44bc02ee0c..bcd8e42b9a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,14 @@ ### Run 100+ Coding Agents in Parallel +
+🌐 Read this in other languages +
+ +[English](README.md) | [日本語](readme/README.ja.md) | [简体中文](readme/README.zh-CN.md) | [繁體中文](readme/README.zh-TW.md) | [한국어](readme/README.ko.md) | [Français](readme/README.fr.md) | [Español](readme/README.es.md) | [Deutsch](readme/README.de.md) | [Português](readme/README.pt-BR.md) | [Italiano](readme/README.it.md) | [Русский](readme/README.ru.md) | [Türkçe](readme/README.tr.md) | [Polski](readme/README.pl.md) | [Nederlands](readme/README.nl.md) | [Bahasa Indonesia](readme/README.id.md) | [Čeština](readme/README.cs.md) | [Tiếng Việt](readme/README.vi.md) + +
+ [![GitHub stars](https://img.shields.io/github/stars/superset-sh/superset?style=flat&logo=github)](https://github.com/superset-sh/superset/stargazers) [![GitHub release](https://img.shields.io/github/v/release/superset-sh/superset?style=flat&logo=github)](https://github.com/superset-sh/superset/releases) [![License](https://img.shields.io/badge/license-Elastic%20License%202.0-blue?style=flat)](LICENSE.md) diff --git a/apps/admin/next.config.ts b/apps/admin/next.config.ts index a671746736..8436ce5261 100644 --- a/apps/admin/next.config.ts +++ b/apps/admin/next.config.ts @@ -16,6 +16,12 @@ const config: NextConfig = { reactCompiler: true, typescript: { ignoreBuildErrors: true }, + // Compiles @lingui/react/macro at build time. Version must stay in + // lockstep with Next's swc_core ABI — see plans/20260826-i18n-strategy.md. + experimental: { + swcPlugins: [["@lingui/swc-plugin", {}]], + }, + images: { remotePatterns: [ { diff --git a/apps/admin/package.json b/apps/admin/package.json index f270eb629d..2ebc8247b8 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -11,9 +11,12 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@lingui/core": "6.6.0", + "@lingui/react": "6.6.0", "@sentry/nextjs": "10.68.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", + "@superset/i18n": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", @@ -40,6 +43,7 @@ "zod": "4.4.3" }, "devDependencies": { + "@lingui/swc-plugin": "6.6.0", "@superset/typescript": "workspace:*", "@tailwindcss/postcss": "4.2.2", "@types/node": "24.12.0", diff --git a/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx b/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx index 6248c463e1..51d217e1aa 100644 --- a/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx +++ b/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx @@ -1,5 +1,8 @@ "use client"; +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; +import { i18n } from "@superset/i18n"; import { Collapsible, CollapsibleContent, @@ -27,15 +30,15 @@ import { SearchForm } from "./components/SearchForm"; const topLevelNav = [ { - title: "Home", + title: msg({ id: "admin.nav.home", message: "Home" }), url: "/", icon: LuHouse, }, ]; const sections: { - title: string; - items: { title: string; url: string; icon: typeof LuHouse }[]; + title: MessageDescriptor; + items: { title: MessageDescriptor; url: string; icon: typeof LuHouse }[]; }[] = []; export interface AppSidebarProps extends React.ComponentProps { @@ -61,11 +64,11 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) { {topLevelNav.map((item) => ( - + - {item.title} + {i18n._(item.title)} @@ -76,8 +79,8 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) { {sections.map((section) => ( @@ -87,7 +90,7 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) { className="group/label text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground text-sm" > - {section.title} + {i18n._(section.title)} @@ -95,14 +98,14 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) { {section.items.map((item) => ( - + {item.icon && } - {item.title} + {i18n._(item.title)} diff --git a/apps/admin/src/app/(dashboard)/components/AppSidebar/components/NavUser/NavUser.tsx b/apps/admin/src/app/(dashboard)/components/AppSidebar/components/NavUser/NavUser.tsx index 360304c06e..561de282d8 100644 --- a/apps/admin/src/app/(dashboard)/components/AppSidebar/components/NavUser/NavUser.tsx +++ b/apps/admin/src/app/(dashboard)/components/AppSidebar/components/NavUser/NavUser.tsx @@ -1,5 +1,6 @@ "use client"; +import { Trans, useLingui } from "@lingui/react/macro"; import { authClient } from "@superset/auth/client"; import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; import { Button } from "@superset/ui/button"; @@ -57,6 +58,7 @@ export interface NavUserProps { export function NavUser({ user }: NavUserProps) { const { isMobile } = useSidebar(); + const { t } = useLingui(); const trpc = useTRPC(); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); @@ -65,12 +67,19 @@ export function NavUser({ user }: NavUserProps) { const setPasswordMutation = useMutation( trpc.admin.setMyPassword.mutationOptions({ onSuccess: () => { - toast.success("Password set"); + toast.success( + t({ id: "admin.navUser.passwordSet", message: "Password set" }), + ); setPasswordDialogOpen(false); setNewPassword(""); }, onError: (error) => { - toast.error(`Failed to set password: ${error.message}`); + toast.error( + t({ + id: "admin.navUser.passwordSetFailed", + message: `Failed to set password: ${error.message}`, + }), + ); }, }), ); @@ -141,25 +150,25 @@ export function NavUser({ user }: NavUserProps) { - Account + Account setPasswordDialogOpen(true)}> - Set password + Set password - Settings + Settings - Notifications + Notifications - Log out + Log out @@ -173,17 +182,24 @@ export function NavUser({ user }: NavUserProps) { > - Set password + + Set password + - Sets an email+password credential for{" "} - {user.email} via Better Auth (hashed with - scrypt). Existing sign-in methods keep working. + + Sets an email+password credential for{" "} + {user.email} via Better Auth (hashed with + scrypt). Existing sign-in methods keep working. + setNewPassword(event.target.value)} onKeyDown={(event) => { @@ -200,7 +216,9 @@ export function NavUser({ user }: NavUserProps) { {setPasswordMutation.isPending ? ( ) : null} - Set Password + + Set Password + diff --git a/apps/admin/src/app/(dashboard)/components/AppSidebar/components/SearchForm/SearchForm.tsx b/apps/admin/src/app/(dashboard)/components/AppSidebar/components/SearchForm/SearchForm.tsx index 1607fec1aa..38dcb6ca93 100644 --- a/apps/admin/src/app/(dashboard)/components/AppSidebar/components/SearchForm/SearchForm.tsx +++ b/apps/admin/src/app/(dashboard)/components/AppSidebar/components/SearchForm/SearchForm.tsx @@ -1,5 +1,6 @@ "use client"; +import { Trans, useLingui } from "@lingui/react/macro"; import { Label } from "@superset/ui/label"; import { SidebarGroup, @@ -9,14 +10,23 @@ import { import { LuSearch } from "react-icons/lu"; export function SearchForm({ ...props }: React.ComponentProps<"form">) { + const { t } = useLingui(); + return (
- + diff --git a/apps/admin/src/app/(dashboard)/components/BurnByVendorTile/BurnByVendorTile.tsx b/apps/admin/src/app/(dashboard)/components/BurnByVendorTile/BurnByVendorTile.tsx index 34cba43ceb..9d26340e5c 100644 --- a/apps/admin/src/app/(dashboard)/components/BurnByVendorTile/BurnByVendorTile.tsx +++ b/apps/admin/src/app/(dashboard)/components/BurnByVendorTile/BurnByVendorTile.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLingui } from "@lingui/react/macro"; import { useQuery } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/react"; @@ -9,6 +10,7 @@ import { InsightTileFrame } from "../InsightTileFrame"; // Where the money goes: average monthly outflow per counterparty over the // last 3 complete months — the actionable half of a burn chart. export function BurnByVendorTile() { + const { t } = useLingui(); const trpc = useTRPC(); const query = useQuery(trpc.business.getCashFlow.queryOptions()); @@ -19,14 +21,25 @@ export function BurnByVendorTile() { return (
diff --git a/apps/admin/src/app/(dashboard)/components/CashBalanceTile/CashBalanceTile.tsx b/apps/admin/src/app/(dashboard)/components/CashBalanceTile/CashBalanceTile.tsx index f6eeeee88b..da47f60951 100644 --- a/apps/admin/src/app/(dashboard)/components/CashBalanceTile/CashBalanceTile.tsx +++ b/apps/admin/src/app/(dashboard)/components/CashBalanceTile/CashBalanceTile.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLingui } from "@lingui/react/macro"; import { type ChartConfig, ChartContainer, @@ -14,14 +15,20 @@ import { useTRPC } from "@/trpc/react"; import { makeDateAxis } from "../../utils/chartAxis"; import { InsightTileFrame } from "../InsightTileFrame"; -const chartConfig = { - cashUsd: { label: "total cash", color: "var(--chart-2)" }, -} satisfies ChartConfig; - // Total cash across all Mercury accounts + Treasury, reconstructed from the // transaction history. Tranche wires show as steps up; burn as gentle slope. export function CashBalanceTile() { + const { t } = useLingui(); const trpc = useTRPC(); + const chartConfig = { + cashUsd: { + label: t({ + id: "admin.cashBalance.seriesTotalCash", + message: "total cash", + }), + color: "var(--chart-2)", + }, + } satisfies ChartConfig; const query = useQuery(trpc.business.getCashFlow.queryOptions()); const unavailableReason = @@ -31,14 +38,26 @@ export function CashBalanceTile() { return ( diff --git a/apps/admin/src/app/(dashboard)/components/ChurnHeatmapTile/ChurnHeatmapTile.tsx b/apps/admin/src/app/(dashboard)/components/ChurnHeatmapTile/ChurnHeatmapTile.tsx index 7f69d291cd..838b6814e4 100644 --- a/apps/admin/src/app/(dashboard)/components/ChurnHeatmapTile/ChurnHeatmapTile.tsx +++ b/apps/admin/src/app/(dashboard)/components/ChurnHeatmapTile/ChurnHeatmapTile.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLingui } from "@lingui/react/macro"; import { useQuery } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/react"; @@ -12,6 +13,7 @@ import { InsightTileFrame } from "../InsightTileFrame"; // are months since subscribing, cells are % still subscribed. Shares the // cohort-triangle rendering with weekly retention. export function ChurnHeatmapTile() { + const { t } = useLingui(); const trpc = useTRPC(); const query = useQuery( trpc.business.getChurnCohorts.queryOptions({ months: 7 }), @@ -40,17 +42,32 @@ export function ChurnHeatmapTile() { return ( `Month ${offset}`)} + columnLabels={offsets.map((offset) => + t({ + id: "admin.churnHeatmap.monthColumn", + message: `Month ${offset}`, + }), + )} rows={rows} - labelHeader="Cohort" - sizeHeader="Subs" + labelHeader={t({ + id: "admin.churnHeatmap.cohortHeader", + message: "Cohort", + })} + sizeHeader={t({ id: "admin.churnHeatmap.subsHeader", message: "Subs" })} /> ); diff --git a/apps/admin/src/app/(dashboard)/components/CohortGrid/CohortGrid.tsx b/apps/admin/src/app/(dashboard)/components/CohortGrid/CohortGrid.tsx index d30367727e..93c66e4bb1 100644 --- a/apps/admin/src/app/(dashboard)/components/CohortGrid/CohortGrid.tsx +++ b/apps/admin/src/app/(dashboard)/components/CohortGrid/CohortGrid.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLingui } from "@lingui/react/macro"; import { cn } from "@superset/ui/utils"; // Shared cohort-triangle rendering for retention and survival grids: solid @@ -58,9 +59,11 @@ function Cell({ cell, isBaseline }: { cell: CohortCell; isBaseline: boolean }) { export function CohortGrid({ columnLabels, rows, - labelHeader = "Cohort", - sizeHeader = "Size", + labelHeader, + sizeHeader, }: CohortGridProps) { + const { t } = useLingui(); + return (
- {labelHeader} + + {labelHeader ?? + t({ id: "admin.cohortGrid.cohortHeader", message: "Cohort" })} + - {sizeHeader} + {sizeHeader ?? + t({ id: "admin.cohortGrid.sizeHeader", message: "Size" })} {columnLabels.map((label) => ( diff --git a/apps/admin/src/app/(dashboard)/components/EnterpriseArrTile/EnterpriseArrTile.tsx b/apps/admin/src/app/(dashboard)/components/EnterpriseArrTile/EnterpriseArrTile.tsx index 21384a2837..3924bf7107 100644 --- a/apps/admin/src/app/(dashboard)/components/EnterpriseArrTile/EnterpriseArrTile.tsx +++ b/apps/admin/src/app/(dashboard)/components/EnterpriseArrTile/EnterpriseArrTile.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLingui } from "@lingui/react/macro"; import { useQuery } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/react"; @@ -18,6 +19,7 @@ function initials(name: string): string { // money from Stripe subscriptions. Unbilled deals stay visible so revenue // that exists only on paper can't hide. export function EnterpriseArrTile() { + const { t } = useLingui(); const trpc = useTRPC(); const query = useQuery(trpc.business.getEnterpriseArr.queryOptions()); const data = query.data; @@ -27,20 +29,35 @@ export function EnterpriseArrTile() { return ( 0 - ? `. ${data.unbilledLogos} not yet modeled as Stripe subscriptions` - : "" - }` - : (data?.reason ?? "Annualized enterprise Stripe subscriptions") + ? t({ + id: "admin.enterpriseArr.summary", + message: `$${data.arrUsd.toLocaleString()}/yr across ${accounts.length} logos — already inside MRR, not additive`, + }) + + (data.unbilledLogos > 0 + ? t({ + id: "admin.enterpriseArr.unbilledSuffix", + message: `. ${data.unbilledLogos} not yet modeled as Stripe subscriptions`, + }) + : "") + : (data?.reason ?? + t({ + id: "admin.enterpriseArr.descriptionFallback", + message: "Annualized enterprise Stripe subscriptions", + })) } isLoading={query.isLoading} error={query.error} empty={accounts.length === 0} - emptyLabel="No active enterprise accounts" + emptyLabel={t({ + id: "admin.enterpriseArr.empty", + message: "No active enterprise accounts", + })} >
{accounts.map((account) => ( @@ -65,7 +82,10 @@ export function EnterpriseArrTile() { {account.billed ? `$${account.arrUsd.toLocaleString()}/yr` - : "unbilled"} + : t({ + id: "admin.enterpriseArr.unbilled", + message: "unbilled", + })}
diff --git a/apps/admin/src/app/(dashboard)/components/FunnelChart/FunnelChart.tsx b/apps/admin/src/app/(dashboard)/components/FunnelChart/FunnelChart.tsx index d5b2fd09ef..215e674d71 100644 --- a/apps/admin/src/app/(dashboard)/components/FunnelChart/FunnelChart.tsx +++ b/apps/admin/src/app/(dashboard)/components/FunnelChart/FunnelChart.tsx @@ -1,5 +1,6 @@ "use client"; +import { Trans, useLingui } from "@lingui/react/macro"; import { Card, CardContent, @@ -57,7 +58,9 @@ function TooltipHeader({ step, name }: { step: number; name: string }) { className="size-2 shrink-0 rounded-full" style={{ background: "var(--chart-1)" }} /> - Step {step}: {name} + + Step {step}: {name} +
); } @@ -73,6 +76,7 @@ export function FunnelChart({ error, headerAction, }: FunnelChartProps) { + const { t } = useLingui(); const firstCount = steps?.[0]?.count ?? 0; return ( @@ -95,13 +99,17 @@ export function FunnelChart({ ) : error ? (

- Failed to load funnel data + + Failed to load funnel data +

) : !steps || steps.length === 0 ? (

- No funnel data available for this period + + No funnel data available for this period +

) : ( @@ -149,19 +157,28 @@ export function FunnelChart({ {dropped !== null ? ( ) : null} {pctOfPrevious !== null ? ( ) : null} {droppedPctOfStart !== null ? ( ) : null} @@ -184,28 +201,43 @@ export function FunnelChart({ > {pctOfPrevious !== null ? ( ) : null} {step.medianSeconds !== null && index > 0 ? ( ) : null} {step.averageSeconds !== null && index > 0 ? ( ) : null} @@ -224,7 +256,10 @@ export function FunnelChart({
- {step.count.toLocaleString()} persons + {t({ + id: "admin.funnel.persons", + message: `${step.count.toLocaleString()} persons`, + })} {pctOfPrevious !== null ? ` (${pctOfPrevious.toFixed(1)}%)` : ""} @@ -234,8 +269,11 @@ export function FunnelChart({
- {dropped.toLocaleString()} persons ( - {(100 - pctOfPrevious).toFixed(1)}%) + {t({ + id: "admin.funnel.persons", + message: `${dropped.toLocaleString()} persons`, + })}{" "} + ({(100 - pctOfPrevious).toFixed(1)}%)
) : null} diff --git a/apps/admin/src/app/(dashboard)/components/InsightTileFrame/InsightTileFrame.tsx b/apps/admin/src/app/(dashboard)/components/InsightTileFrame/InsightTileFrame.tsx index 1f2894c8ae..34060a27d8 100644 --- a/apps/admin/src/app/(dashboard)/components/InsightTileFrame/InsightTileFrame.tsx +++ b/apps/admin/src/app/(dashboard)/components/InsightTileFrame/InsightTileFrame.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLingui } from "@lingui/react/macro"; import { Button } from "@superset/ui/button"; import { Card, @@ -35,13 +36,15 @@ export function InsightTileFrame({ isLoading, error, empty, - emptyLabel = "No data", + emptyLabel, headerAction, onRefresh, isRefreshing, href, children, }: InsightTileFrameProps) { + const { t } = useLingui(); + return ( @@ -68,7 +71,7 @@ export function InsightTileFrame({ className="size-6 p-0" onClick={onRefresh} disabled={isRefreshing} - aria-label="Refresh" + aria-label={t({ id: "admin.tile.refresh", message: "Refresh" })} > ) : empty ? (
-

{emptyLabel}

+

+ {emptyLabel ?? t({ id: "admin.tile.noData", message: "No data" })} +

) : ( children diff --git a/apps/admin/src/app/(dashboard)/components/LogoRetentionTile/LogoRetentionTile.tsx b/apps/admin/src/app/(dashboard)/components/LogoRetentionTile/LogoRetentionTile.tsx index 9ab55b3a0c..882fb3340c 100644 --- a/apps/admin/src/app/(dashboard)/components/LogoRetentionTile/LogoRetentionTile.tsx +++ b/apps/admin/src/app/(dashboard)/components/LogoRetentionTile/LogoRetentionTile.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLingui } from "@lingui/react/macro"; import { type ChartConfig, ChartContainer, @@ -14,12 +15,18 @@ import { useTRPC } from "@/trpc/react"; import { formatMonth } from "../../utils/chartAxis"; import { InsightTileFrame } from "../InsightTileFrame"; -const chartConfig = { - retention_pct: { label: "logo retention", color: "var(--chart-1)" }, -} satisfies ChartConfig; - export function LogoRetentionTile() { + const { t } = useLingui(); const trpc = useTRPC(); + const chartConfig = { + retention_pct: { + label: t({ + id: "admin.logoRetention.series", + message: "logo retention", + }), + color: "var(--chart-1)", + }, + } satisfies ChartConfig; const query = useQuery( trpc.business.getLogoRetention.queryOptions({ months: 8 }), ); @@ -27,8 +34,15 @@ export function LogoRetentionTile() { return ( ) : error ? ( -

Failed to load

+

+ Failed to load +

) : value !== null && value !== undefined ? (

{formatter(value)}

) : ( -

No data

+

+ No data +

)}
diff --git a/apps/admin/src/app/(dashboard)/components/MrrTile/MrrTile.tsx b/apps/admin/src/app/(dashboard)/components/MrrTile/MrrTile.tsx index b139613e52..7faaacb530 100644 --- a/apps/admin/src/app/(dashboard)/components/MrrTile/MrrTile.tsx +++ b/apps/admin/src/app/(dashboard)/components/MrrTile/MrrTile.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLingui } from "@lingui/react/macro"; import { type ChartConfig, ChartContainer, @@ -23,10 +24,6 @@ import { makeDateAxis } from "../../utils/chartAxis"; import { InsightTileFrame } from "../InsightTileFrame"; import { type MrrDatum, MrrTooltip } from "./MrrTooltip"; -const chartConfig = { - mrrUsd: { label: "MRR", color: "var(--chart-1)" }, -} satisfies ChartConfig; - const RANGE_DAYS = { "7d": 7, "35d": 35, "180d": 180 } as const; type RangeKey = keyof typeof RANGE_DAYS; @@ -51,7 +48,14 @@ function bucketPoints(all: MrrDatum[], range: RangeKey): MrrDatum[] { } export function MrrTile() { + const { t } = useLingui(); const trpc = useTRPC(); + const chartConfig = { + mrrUsd: { + label: t({ id: "admin.mrr.label", message: "MRR" }), + color: "var(--chart-1)", + }, + } satisfies ChartConfig; const [range, setRange] = useState("7d"); const query = useQuery( trpc.business.getMrr.queryOptions(undefined, { @@ -85,18 +89,28 @@ export function MrrTile() { return ( setRange(v as RangeKey)}> @@ -134,8 +148,10 @@ export function MrrTile() {
{latest?.prevUsd !== null && latest?.prevUsd !== undefined ? (

- ${latest.prevUsd.toLocaleString()} previous period ( - {latest.prevDate}) + {t({ + id: "admin.mrr.previousPeriod", + message: `$${latest.prevUsd.toLocaleString()} previous period (${latest.prevDate})`, + })}

) : null}
diff --git a/apps/admin/src/app/(dashboard)/components/MrrTile/MrrTooltip.tsx b/apps/admin/src/app/(dashboard)/components/MrrTile/MrrTooltip.tsx index 699dda1e5d..c011e47ced 100644 --- a/apps/admin/src/app/(dashboard)/components/MrrTile/MrrTooltip.tsx +++ b/apps/admin/src/app/(dashboard)/components/MrrTile/MrrTooltip.tsx @@ -1,5 +1,6 @@ "use client"; +import { Trans } from "@lingui/react/macro"; import { cn } from "@superset/ui/utils"; export interface MrrDatum { @@ -22,7 +23,9 @@ export function MrrTooltip({ active, payload }: MrrTooltipProps) { return (
- MRR + + MRR + {datum.changePct !== null ? ( diff --git a/apps/admin/src/app/(dashboard)/components/PostHogFunnelTile/PostHogFunnelTile.tsx b/apps/admin/src/app/(dashboard)/components/PostHogFunnelTile/PostHogFunnelTile.tsx index d8a8e869cc..c812106bdc 100644 --- a/apps/admin/src/app/(dashboard)/components/PostHogFunnelTile/PostHogFunnelTile.tsx +++ b/apps/admin/src/app/(dashboard)/components/PostHogFunnelTile/PostHogFunnelTile.tsx @@ -1,5 +1,9 @@ "use client"; +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react/macro"; +import { i18n } from "@superset/i18n"; import { ADMIN_INSIGHTS, POSTHOG_PROJECT_URL, @@ -22,27 +26,44 @@ interface PostHogFunnelStep { // The OR-group step's API name is the mush of its member events // ("$pageview, $pageview, …"); the saved definition's group name isn't // echoed in results, so label it here by position. -const STEP_NAME_OVERRIDES: Record = { - 4: "Reached a dashboard page", +const STEP_NAME_OVERRIDES: Record = { + 4: msg({ + id: "admin.activationFunnel.stepDashboardPage", + message: "Reached a dashboard page", + }), }; export function PostHogFunnelTile() { + const { t } = useLingui(); const insight = useInsightResults("activationFunnel"); const steps = Array.isArray(insight.data?.result) ? (insight.data.result as PostHogFunnelStep[]) : []; - const data = steps.map((step, index) => ({ - name: step.custom_name ?? STEP_NAME_OVERRIDES[index] ?? step.name, - count: step.count, - medianSeconds: step.median_conversion_time ?? null, - averageSeconds: step.average_conversion_time ?? null, - })); + const data = steps.map((step, index) => { + const override = STEP_NAME_OVERRIDES[index]; + return { + name: step.custom_name ?? (override ? i18n._(override) : step.name), + count: step.count, + medianSeconds: step.median_conversion_time ?? null, + averageSeconds: step.average_conversion_time ?? null, + }; + }); return ( @@ -64,7 +88,7 @@ export function PostHogFunnelTile() { className="size-6 p-0" onClick={() => insight.refetch()} disabled={insight.isFetching} - aria-label="Refresh" + aria-label={t({ id: "admin.tile.refresh", message: "Refresh" })} > `Week ${week}`, + columnLabels={Array.from({ length: intervalCount }, (_, week) => + t({ id: "admin.retentionGrid.weekColumn", message: `Week ${week}` }), )} rows={rows} /> diff --git a/apps/admin/src/app/(dashboard)/components/RunwayTile/RunwayTile.tsx b/apps/admin/src/app/(dashboard)/components/RunwayTile/RunwayTile.tsx index 5e44bec627..4e06474be9 100644 --- a/apps/admin/src/app/(dashboard)/components/RunwayTile/RunwayTile.tsx +++ b/apps/admin/src/app/(dashboard)/components/RunwayTile/RunwayTile.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLingui } from "@lingui/react/macro"; import { useQuery } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/react"; @@ -9,25 +10,34 @@ import { MetricCard } from "../MetricCard"; // Runway headline. Cash and burn have their own charts, so this card // carries just the derived number plus the inputs it came from. export function RunwayTile() { + const { t } = useLingui(); const trpc = useTRPC(); const query = useQuery(trpc.business.getCashFlow.queryOptions()); const data = query.data; const available = data?.available === true; const description = available - ? `$${(data.totalCashUsd / 1_000_000).toFixed(2)}M cash ÷ $${data.avgMonthlyNetBurnUsd?.toLocaleString() ?? "—"}/mo net burn, last 3 complete months. Cash includes raised capital; no growth assumed.` + ? t({ + id: "admin.runway.description", + message: `$${(data.totalCashUsd / 1_000_000).toFixed(2)}M cash ÷ $${data.avgMonthlyNetBurnUsd?.toLocaleString() ?? "—"}/mo net burn, last 3 complete months. Cash includes raised capital; no growth assumed.`, + }) : ((data && !data.available ? data.reason : null) ?? - "Cash ÷ net burn at the current run rate"); + t({ + id: "admin.runway.descriptionFallback", + message: "Cash ÷ net burn at the current run rate", + })); return ( `${v.toLocaleString()} mo`} + formatter={(v) => + t({ id: "admin.runway.months", message: `${v.toLocaleString()} mo` }) + } /> ); } diff --git a/apps/admin/src/app/(dashboard)/components/SignupToPaidTile/SignupToPaidTile.tsx b/apps/admin/src/app/(dashboard)/components/SignupToPaidTile/SignupToPaidTile.tsx index a3b5eb5082..ac357d9945 100644 --- a/apps/admin/src/app/(dashboard)/components/SignupToPaidTile/SignupToPaidTile.tsx +++ b/apps/admin/src/app/(dashboard)/components/SignupToPaidTile/SignupToPaidTile.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLingui } from "@lingui/react/macro"; import { type ChartConfig, ChartContainer, @@ -14,13 +15,22 @@ import { useTRPC } from "@/trpc/react"; import { makeDateAxis } from "../../utils/chartAxis"; import { InsightTileFrame } from "../InsightTileFrame"; -const chartConfig = { - conversion_pct: { label: "signup → paid ≤30d", color: "var(--chart-1)" }, - signups: { label: "signups", color: "var(--chart-2)" }, -} satisfies ChartConfig; - export function SignupToPaidTile() { + const { t } = useLingui(); const trpc = useTRPC(); + const chartConfig = { + conversion_pct: { + label: t({ + id: "admin.signupToPaid.seriesConversion", + message: "signup → paid ≤30d", + }), + color: "var(--chart-1)", + }, + signups: { + label: t({ id: "admin.signupToPaid.seriesSignups", message: "signups" }), + color: "var(--chart-2)", + }, + } satisfies ChartConfig; const query = useQuery( trpc.business.getSignupToPaid.queryOptions({ weeks: 12 }), ); @@ -29,8 +39,15 @@ export function SignupToPaidTile() { return ( { - const label = s.custom_name ?? s.label ?? `series ${i + 1}`; + const label = + s.custom_name ?? + s.label ?? + t({ + id: "admin.trendSeries.seriesFallback", + message: `series ${i + 1}`, + }); const color = SERIES_COLORS[i % SERIES_COLORS.length]; return [ [`s${i}`, { label, color }], ...(splitIncomplete - ? [[`s${i}partial`, { label: `${label} (partial week)`, color }]] + ? [ + [ + `s${i}partial`, + { + label: t({ + id: "admin.trendSeries.partialWeek", + message: `${label} (partial week)`, + }), + color, + }, + ], + ] : []), ]; }), diff --git a/apps/admin/src/app/(dashboard)/layout.tsx b/apps/admin/src/app/(dashboard)/layout.tsx index 37d43460f6..b3c26c94c3 100644 --- a/apps/admin/src/app/(dashboard)/layout.tsx +++ b/apps/admin/src/app/(dashboard)/layout.tsx @@ -1,4 +1,5 @@ import { auth } from "@superset/auth/server"; +import { i18n } from "@superset/i18n"; import { COMPANY } from "@superset/shared/constants"; import { Breadcrumb, @@ -58,7 +59,9 @@ export default async function DashboardLayout({ - Home + + {i18n._({ id: "admin.breadcrumb.home", message: "Home" })} + diff --git a/apps/admin/src/app/(dashboard)/page.tsx b/apps/admin/src/app/(dashboard)/page.tsx index 5293484680..5467e37684 100644 --- a/apps/admin/src/app/(dashboard)/page.tsx +++ b/apps/admin/src/app/(dashboard)/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { Trans, useLingui } from "@lingui/react/macro"; import { POSTHOG_PROJECT_URL } from "@superset/trpc/insight-registry"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@superset/ui/tabs"; @@ -22,85 +23,121 @@ import { TrendSeriesTile } from "./components/TrendSeriesTile"; // by id; business tiles compute live from Stripe/Neon. Each tile renders at // its canonical saved range (D-14). -const DAU_PROPS = { - insight: "dau", - description: "Unique users creating a real workspace, daily", -} as const; +export default function DashboardPage() { + const { t } = useLingui(); -const WAU_PROPS = { - insight: "wau", - description: - "Unique users creating a real workspace per calendar week; current week dashed", - dashIncompleteLast: true, -} as const; + const DAU_PROPS = { + insight: "dau", + description: t({ + id: "admin.insight.dau", + message: "Unique users creating a real workspace, daily", + }), + } as const; -const ACTIVATED_RATE_PROPS = { - insight: "activatedRate", - description: - "Real workspaces on 2+ distinct days within week 1 of first workspace (retention-validated definition)", - xColumn: 0, - series: [ - { - column: 3, - key: "activation_pct", - label: "activation rate", - kind: "line", - suffix: "%", - }, - { - column: 1, - key: "new_creators", - label: "new workspace creators", - kind: "bar", - rightAxis: true, - }, - ], -} as const; + const WAU_PROPS = { + insight: "wau", + description: t({ + id: "admin.insight.wau", + message: + "Unique users creating a real workspace per calendar week; current week dashed", + }), + dashIncompleteLast: true, + } as const; -const ACTIVE_ORGS_PROPS = { - insight: "activeOrgs", - description: "Weekly orgs with 2+/5+ members creating real workspaces", - xColumn: 0, - series: [ - { - column: 1, - key: "orgs_2plus", - label: "orgs with 2+ active members", - kind: "line", - }, - { - column: 2, - key: "orgs_5plus", - label: "orgs with 5+ active members", - kind: "line", - }, - ], -} as const; + const ACTIVATED_RATE_PROPS = { + insight: "activatedRate", + description: t({ + id: "admin.insight.activatedRate", + message: + "Real workspaces on 2+ distinct days within week 1 of first workspace (retention-validated definition)", + }), + xColumn: 0, + series: [ + { + column: 3, + key: "activation_pct", + label: t({ + id: "admin.series.activationRate", + message: "activation rate", + }), + kind: "line", + suffix: "%", + }, + { + column: 1, + key: "new_creators", + label: t({ + id: "admin.series.newWorkspaceCreators", + message: "new workspace creators", + }), + kind: "bar", + rightAxis: true, + }, + ], + } as const; + + const ACTIVE_ORGS_PROPS = { + insight: "activeOrgs", + description: t({ + id: "admin.insight.activeOrgs", + message: "Weekly orgs with 2+/5+ members creating real workspaces", + }), + xColumn: 0, + series: [ + { + column: 1, + key: "orgs_2plus", + label: t({ + id: "admin.series.orgs2Plus", + message: "orgs with 2+ active members", + }), + kind: "line", + }, + { + column: 2, + key: "orgs_5plus", + label: t({ + id: "admin.series.orgs5Plus", + message: "orgs with 5+ active members", + }), + kind: "line", + }, + ], + } as const; -export default function DashboardPage() { return (
-

Company Metrics

+

+ Company Metrics +

- Mirror of the{" "} - - PostHog Success Metrics dashboard - {" "} - — product via saved insights, business live from Stripe/Neon + + Mirror of the{" "} + + PostHog Success Metrics dashboard + {" "} + — product via saved insights, business live from Stripe/Neon +

- Company - Product - Growth + + Company + + + Product + + + Growth + @@ -129,20 +166,31 @@ export default function DashboardPage() {
@@ -158,11 +206,18 @@ export default function DashboardPage() {
diff --git a/apps/admin/src/app/(dashboard)/utils/chartAxis/chartAxis.ts b/apps/admin/src/app/(dashboard)/utils/chartAxis/chartAxis.ts index 8c9fb9f509..9c8630cd5a 100644 --- a/apps/admin/src/app/(dashboard)/utils/chartAxis/chartAxis.ts +++ b/apps/admin/src/app/(dashboard)/utils/chartAxis/chartAxis.ts @@ -1,3 +1,5 @@ +import { formatDate } from "@superset/i18n/format"; + const ISO_DATE_PREFIX = /^\d{4}-\d{2}-\d{2}/; const MULTI_MONTH_SPAN_DAYS = 60; const DAY_MS = 24 * 60 * 60 * 1000; @@ -12,15 +14,16 @@ interface DateAxisConfig { // midnight and formats in local time — which renders it as January in the // Americas. Every formatter here pins to UTC so labels match the data. export function formatDay(value: string): string { - return new Date(`${value.slice(0, 10)}T00:00:00Z`).toLocaleDateString( - "en-US", - { month: "short", day: "numeric", timeZone: "UTC" }, - ); + return formatDate(new Date(`${value.slice(0, 10)}T00:00:00Z`), { + month: "short", + day: "numeric", + timeZone: "UTC", + }); } export function formatMonth(value: string): string { const month = value.length === 7 ? `${value}-01` : value.slice(0, 10); - return new Date(`${month}T00:00:00Z`).toLocaleDateString("en-US", { + return formatDate(new Date(`${month}T00:00:00Z`), { month: "long", timeZone: "UTC", }); diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 54b14752b9..41c9905d96 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -1,3 +1,4 @@ +import { initI18n } from "@superset/i18n"; import { Toaster } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; import type { Metadata, Viewport } from "next"; @@ -19,6 +20,11 @@ const inter = Inter({ variable: "--font-inter", }); +// Server components render outside I18nProvider (which is client-only), and +// `i18n._` throws on an unactivated instance. Activating at module scope means +// the server module graph is ready before any RSC in this tree renders. +initI18n(); + export const metadata: Metadata = { title: "Superset | Company Dashboard", description: "Analytics, Ops, and more", diff --git a/apps/admin/src/app/providers.tsx b/apps/admin/src/app/providers.tsx index 5b0348e138..d1e4e4d786 100644 --- a/apps/admin/src/app/providers.tsx +++ b/apps/admin/src/app/providers.tsx @@ -1,5 +1,6 @@ "use client"; +import { I18nProvider } from "@superset/i18n/react"; import { THEME_STORAGE_KEY } from "@superset/shared/constants"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ThemeProvider } from "next-themes"; @@ -13,19 +14,21 @@ import { TRPCReactProvider } from "../trpc/react"; export function Providers({ children }: { children: React.ReactNode }) { return ( - - - - {children} - - - + + + + + {children} + + + + ); } diff --git a/apps/api/src/app/api/automations/webhook/[automationId]/route.ts b/apps/api/src/app/api/automations/webhook/[automationId]/route.ts index dfa45b8144..9adb561465 100644 --- a/apps/api/src/app/api/automations/webhook/[automationId]/route.ts +++ b/apps/api/src/app/api/automations/webhook/[automationId]/route.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { db } from "@superset/db/client"; import { automations, automationTriggers } from "@superset/db/schema"; import { - bearerToken, + presentedWebhookToken, WEBHOOK_TOKEN_PREFIX, webhookTokenMatches, } from "@superset/trpc/automation-webhook-secret"; @@ -37,8 +37,9 @@ function parseBody(body: string): Record | unknown[] { } /** - * Inbound raw webhook: `POST /api/automations/webhook/{automationId}` with - * `Authorization: Bearer `. Every authenticated delivery is one event + * Inbound raw webhook: `POST /api/automations/webhook/{automationId}`, the + * token in `Authorization: Bearer ` or — for producers whose settings + * accept only a URL — in `?token=`. Every authenticated delivery is one event * and fires every enabled webhook trigger on the automation; there are no * filters and no dedupe. */ @@ -51,12 +52,15 @@ export async function POST( return Response.json({ error: "Not found" }, { status: 404 }); } - const token = bearerToken(request.headers.get("authorization")); + const token = presentedWebhookToken( + request.headers.get("authorization"), + request.url, + ); if (!token) { - return Response.json({ error: "Missing bearer token" }, { status: 401 }); + return Response.json({ error: "Missing token" }, { status: 401 }); } if (!token.startsWith(WEBHOOK_TOKEN_PREFIX)) { - return Response.json({ error: "Invalid bearer token" }, { status: 401 }); + return Response.json({ error: "Invalid token" }, { status: 401 }); } // Keyed on the presented token, not the public automation id, so someone @@ -87,7 +91,7 @@ export async function POST( webhookTokenMatches(token, t.secretHash), ); if (!authenticating) { - return Response.json({ error: "Invalid bearer token" }, { status: 401 }); + return Response.json({ error: "Invalid token" }, { status: 401 }); } const { organizationId } = authenticating; if (!authenticating.automationEnabled) { diff --git a/apps/api/src/app/api/chat-attachments/[id]/route.ts b/apps/api/src/app/api/chat-attachments/[id]/route.ts deleted file mode 100644 index 67b70a015f..0000000000 --- a/apps/api/src/app/api/chat-attachments/[id]/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { auth } from "@superset/auth/server"; -import { db } from "@superset/db/client"; -import { chatAttachments } from "@superset/db/schema"; -import { head } from "@vercel/blob"; -import { eq } from "drizzle-orm"; - -const UUID_REGEX = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -function buildContentDisposition(filename: string, mediaType: string): string { - // Only image bytes can be safely rendered inline on the API origin. - // HTML/XML/JSON would otherwise execute or be sniffed in the auth'd origin. - const disposition = mediaType.startsWith("image/") ? "inline" : "attachment"; - // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control chars from a stored filename is the whole point. - const ascii = filename.replace(/[\x00-\x1f\x7f"\\]/g, "").trim() || "file"; - const encoded = encodeURIComponent(ascii); - return `${disposition}; filename="${ascii}"; filename*=UTF-8''${encoded}`; -} - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -): Promise { - const sessionData = await auth.api.getSession({ headers: request.headers }); - if (!sessionData?.user) { - return new Response("Unauthorized", { status: 401 }); - } - - const { id } = await params; - if (!UUID_REGEX.test(id)) { - return new Response("Not found", { status: 404 }); - } - - const [attachment] = await db - .select({ - blobPathname: chatAttachments.blobPathname, - mediaType: chatAttachments.mediaType, - filename: chatAttachments.filename, - ownerId: chatAttachments.createdBy, - }) - .from(chatAttachments) - .where(eq(chatAttachments.id, id)) - .limit(1); - - if (!attachment || attachment.ownerId !== sessionData.user.id) { - return new Response("Not found", { status: 404 }); - } - - let downloadUrl: string; - try { - const meta = await head(attachment.blobPathname); - downloadUrl = meta.url; - } catch (error) { - console.error("[chat-attachments] head failed", { id, error }); - return new Response("Attachment not available", { status: 404 }); - } - - let blobResp: Response; - try { - blobResp = await fetch(downloadUrl); - } catch (error) { - console.error("[chat-attachments] blob fetch threw", { id, error }); - return new Response("Failed to fetch attachment", { status: 502 }); - } - if (!blobResp.ok || !blobResp.body) { - console.error("[chat-attachments] blob fetch failed", { - id, - status: blobResp.status, - }); - return new Response("Failed to fetch attachment", { status: 502 }); - } - - return new Response(blobResp.body, { - status: 200, - headers: { - "Content-Type": attachment.mediaType, - "X-Content-Type-Options": "nosniff", - "Content-Disposition": buildContentDisposition( - attachment.filename, - attachment.mediaType, - ), - "Cache-Control": "private, max-age=3600", - }, - }); -} diff --git a/apps/api/src/app/api/files/jobs/sweep/route.ts b/apps/api/src/app/api/files/jobs/sweep/route.ts new file mode 100644 index 0000000000..0ab1d0603a --- /dev/null +++ b/apps/api/src/app/api/files/jobs/sweep/route.ts @@ -0,0 +1,26 @@ +import { + FILE_SWEEP_JOB_PATH, + sweepPendingFiles, +} from "@superset/trpc/file-sweep"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; + +export const maxDuration = 300; +export const dynamic = "force-dynamic"; + +/** + * Daily: a `pending` row is an upload whose `complete` never came — the + * client died mid-PUT, or verification failed after the object landed. + * After a day it is never completing; drop the row and the object. + */ +export async function POST(request: Request) { + const body = await request.text(); + const rejected = await verifyQstashRequest( + request, + body, + FILE_SWEEP_JOB_PATH, + ); + if (rejected) return rejected; + + const result = await sweepPendingFiles(); + return Response.json(result); +} diff --git a/apps/api/src/app/api/github/webhook/normalizeGithubDelivery.ts b/apps/api/src/app/api/github/webhook/normalizeGithubDelivery.ts index 2a7f9969b6..9f8042982f 100644 --- a/apps/api/src/app/api/github/webhook/normalizeGithubDelivery.ts +++ b/apps/api/src/app/api/github/webhook/normalizeGithubDelivery.ts @@ -172,6 +172,8 @@ export function matchableFrom( payload.pull_request?.user?.id ?? payload.issue?.user?.id ?? undefined; return id !== undefined ? String(id) : null; })(), + subjectAuthorLogin: + payload.pull_request?.user?.login ?? payload.issue?.user?.login ?? null, }; } diff --git a/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/normalizeCirclebackDelivery.ts b/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/normalizeCirclebackDelivery.ts deleted file mode 100644 index acd082cbfd..0000000000 --- a/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/normalizeCirclebackDelivery.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { z } from "zod"; - -import type { NormalizedDelivery } from "@/lib/automations/ingestAutomationEvent"; - -export const EVENT_TYPE = "meeting.completed"; - -/** - * The fields matching and the row need. Everything else — notes, action items, - * transcript, insights — rides along in `payload` for the prompt. - */ -export const meetingSchema = z - .object({ - id: z.union([z.string().min(1), z.number()]).transform(String), - name: z.string().default(""), - tags: z.array(z.string()).default([]), - attendees: z - .array(z.object({ email: z.string().nullable().optional() })) - .default([]), - }) - .passthrough(); - -export type CirclebackMeeting = z.infer; - -/** - * A Circleback delivery is addressed: the user configured this trigger's URL - * in Circleback, so only this trigger is a candidate — every other Circleback - * trigger in the organization has its own URL and gets its own delivery of - * the same meeting. That is why the dedupe key carries the trigger id and the - * dispatch is narrowed to the one trigger. - */ -export function normalizeCirclebackDelivery(params: { - organizationId: string; - automationId: string; - triggerId: string; - meeting: CirclebackMeeting; - payload: unknown; -}): NormalizedDelivery { - const { meeting, triggerId } = params; - return { - event: { - organizationId: params.organizationId, - integrationConnectionId: null, - provider: "circleback", - eventType: EVENT_TYPE, - externalEventId: `${triggerId}:${meeting.id}`, - resourceKey: `circleback:${meeting.id}`, - title: meeting.name || meeting.id, - url: `https://circleback.ai/meetings/${meeting.id}`, - payload: params.payload, - }, - dispatch: { - automationId: params.automationId, - triggerId, - event: { - provider: "circleback", - eventType: EVENT_TYPE, - actorId: null, - actorLogin: null, - body: null, - name: meeting.name || null, - tags: meeting.tags, - attendeeEmails: meeting.attendees.flatMap((a) => - a.email ? [a.email] : [], - ), - }, - }, - }; -} diff --git a/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/route.ts b/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/route.ts deleted file mode 100644 index aff969194a..0000000000 --- a/apps/api/src/app/api/integrations/circleback/webhook/[triggerId]/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { dbWs } from "@superset/db/client"; -import { automations, automationTriggers } from "@superset/db/schema"; -import { Ratelimit } from "@upstash/ratelimit"; -import { Redis } from "@upstash/redis"; -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; - -import { env } from "@/env"; -import { ingestAutomationEvent } from "@/lib/automations/ingestAutomationEvent"; -import { cappedBody, parseJson } from "@/lib/webhooks/body"; -import { hmacHex, timingSafeHex } from "@/lib/webhooks/verify"; -import { - meetingSchema, - normalizeCirclebackDelivery, -} from "./normalizeCirclebackDelivery"; - -export const dynamic = "force-dynamic"; - -const rateLimit = new Ratelimit({ - redis: new Redis({ - url: env.KV_REST_API_URL, - token: env.KV_REST_API_TOKEN, - }), - limiter: Ratelimit.slidingWindow(300, "1 m"), - prefix: "ratelimit:integrations:circleback:webhook", -}); - -/** - * One meeting, delivered by Circleback to the trigger named in the URL. - * - * Unlike GitHub, where one delivery is matched against every trigger in the - * organization, a Circleback delivery is addressed: the user configured this - * URL in Circleback, so only this trigger is evaluated. - */ -export async function POST( - request: Request, - { params }: { params: Promise<{ triggerId: string }> }, -): Promise { - const { triggerId } = await params; - if (!z.string().uuid().safeParse(triggerId).success) { - return Response.json({ error: "Unknown trigger" }, { status: 404 }); - } - - const { success: withinLimit } = await rateLimit.limit(triggerId); - if (!withinLimit) { - return Response.json({ error: "Rate limit exceeded" }, { status: 429 }); - } - - const [trigger] = await dbWs - .select({ - organizationId: automationTriggers.organizationId, - automationId: automationTriggers.automationId, - // For an HMAC provider the column holds the signing key itself — a - // hash could not verify a signature. - secret: automationTriggers.secretHash, - automationEnabled: automations.enabled, - }) - .from(automationTriggers) - .innerJoin(automations, eq(automations.id, automationTriggers.automationId)) - .where( - and( - eq(automationTriggers.id, triggerId), - eq(automationTriggers.kind, "circleback"), - ), - ) - .limit(1); - - if (!trigger) { - return Response.json({ error: "Unknown trigger" }, { status: 404 }); - } - - const body = await cappedBody(request); - if (body instanceof Response) return body; - - // A trigger with no secret yet cannot tell Circleback from anyone who has - // seen the URL, so it accepts nothing until one is pasted in. - const secret = trigger.secret; - if (!secret) { - console.warn( - "[circleback/webhook] No signing secret configured for trigger:", - triggerId, - ); - return Response.json( - { error: "Signing secret not configured" }, - { status: 401 }, - ); - } - // Circleback signs the raw body with the secret it issued for the automation - // and puts the hex digest in `x-signature`. - const signature = request.headers.get("x-signature"); - if (!signature || !timingSafeHex(signature, hmacHex(body, secret))) { - console.warn( - "[circleback/webhook] Invalid signature for trigger:", - triggerId, - ); - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } - - // Refused before the event row exists: the dedupe key is permanent, so a - // delivery recorded during a pause would swallow the redelivery too. - if (!trigger.automationEnabled) { - return Response.json({ error: "Automation is disabled" }, { status: 400 }); - } - - const json = parseJson(body); - if (json instanceof Response) return json; - const parsed = meetingSchema.safeParse(json); - if (!parsed.success) { - console.error( - "[circleback/webhook] Unexpected payload shape", - parsed.error, - ); - return Response.json({ error: "Invalid payload" }, { status: 400 }); - } - - const outcome = await ingestAutomationEvent( - dbWs, - normalizeCirclebackDelivery({ - organizationId: trigger.organizationId, - automationId: trigger.automationId, - triggerId, - meeting: parsed.data, - payload: json, - }), - ); - return Response.json({ ok: true, outcome }); -} diff --git a/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts b/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts index 98600911dc..d36d7c3676 100644 --- a/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts +++ b/apps/api/src/app/api/integrations/google/lib/syncMailbox.ts @@ -1,5 +1,8 @@ import { db } from "@superset/db/client"; -import type { SelectIntegrationConnection } from "@superset/db/schema"; +import { + automationEvents, + type SelectIntegrationConnection, +} from "@superset/db/schema"; import type { GmailMatchableEvent } from "@superset/shared/automation-matching"; import { type GmailMessage, @@ -12,6 +15,7 @@ import { parseAddresses, patchGmailState, } from "@superset/trpc/integrations/google"; +import { and, eq, inArray } from "drizzle-orm"; import { ingestAutomationEvent, type NormalizedDelivery, @@ -53,9 +57,33 @@ export async function syncMailbox( return { baseline: true, added: 0, recorded: 0, matched: 0 }; } + // Which of these were already recorded, so the fetch below is skipped for + // them. Without this, a history walk that cannot finish inside the + // function's time budget re-fetches every message on every push and the + // checkpoint write at the bottom never runs — the cursor wedges at its old + // value, each new email costs a full re-scan, and the re-scan only grows. + // One indexed query makes a re-walk nearly free, so runs complete and the + // cursor advances again. + const known = new Set(); + for (let i = 0; i < result.messages.length; i += 500) { + const chunk = result.messages.slice(i, i + 500).map((m) => m.id); + const rows = await db + .select({ id: automationEvents.externalEventId }) + .from(automationEvents) + .where( + and( + eq(automationEvents.integrationConnectionId, connection.id), + eq(automationEvents.provider, "gmail"), + inArray(automationEvents.externalEventId, chunk), + ), + ); + for (const row of rows) known.add(row.id); + } + let recorded = 0; let matched = 0; for (const added of result.messages) { + if (known.has(added.id)) continue; if (added.labelIds.some((label) => OUTGOING_LABELS.has(label))) continue; const message = await getMessage(connection.id, added.id); if (!message) continue; diff --git a/apps/api/src/app/api/integrations/sentry/callback/route.ts b/apps/api/src/app/api/integrations/sentry/callback/route.ts index 6ff92b08d4..e94b2e1137 100644 --- a/apps/api/src/app/api/integrations/sentry/callback/route.ts +++ b/apps/api/src/app/api/integrations/sentry/callback/route.ts @@ -34,7 +34,7 @@ const web = (params = "") => */ export async function GET(request: Request) { const callback = await resolveCallback(request, { - params: ["code", "installationId"], + params: ["code", "installationId", "orgSlug"], redirect: (error) => web(`?error=${error}`), stateFrom: (req) => readCookie(req.headers.get("cookie"), SENTRY_STATE_COOKIE), @@ -54,7 +54,10 @@ export async function GET(request: Request) { return web("?error=token_exchange_failed"); } - const organization = await fetchSentryOrganization(token.token); + const organization = await fetchSentryOrganization( + token.token, + params.orgSlug, + ); if (!organization) return web("?error=organization_lookup_failed"); const config: SentryConfig = { @@ -74,7 +77,14 @@ export async function GET(request: Request) { externalOrgName: organization.name, config, }); - if (result.conflict) return web("?error=organization_already_linked"); + if (result.conflict) { + // Who holds it, so the message can name someone to ask — the blocked org + // cannot see the other organization's connection, let alone disconnect it. + const owner = result.conflict.ownerEmail + ? `&owner=${encodeURIComponent(result.conflict.ownerEmail)}` + : ""; + return web(`?error=organization_already_linked${owner}`); + } // Verify Install, if the app has it on; best-effort, the token already works. await verifySentryInstall(installationId, token.token); diff --git a/apps/api/src/app/api/integrations/sentry/connect/route.ts b/apps/api/src/app/api/integrations/sentry/connect/route.ts index 6b19749aa9..f7bc586a01 100644 --- a/apps/api/src/app/api/integrations/sentry/connect/route.ts +++ b/apps/api/src/app/api/integrations/sentry/connect/route.ts @@ -8,11 +8,11 @@ export const SENTRY_STATE_COOKIE = "sentry_oauth_state"; * Starts a Sentry install for the org's Sentry admin. * * A public Sentry integration is installed from Sentry's side, and Sentry - * redirects back to the app's one fixed Redirect URL with only a grant code and - * an install id — no state of ours. Sentry's install payload never names the - * Superset org either. So the one place the Superset org is known is right here, - * and it is carried to the callback in a signed, first-party cookie rather than - * through Sentry. + * redirects back to the app's one fixed Redirect URL with a grant code, an + * install id and the Sentry org's slug — but no state of ours, and nothing + * naming the Superset org. So the one place the Superset org is known is right + * here, and it is carried to the callback in a signed, first-party cookie + * rather than through Sentry. */ export async function GET(request: Request) { const member = await requireOrgMember(request); diff --git a/apps/api/src/app/api/integrations/slack/manifest.json b/apps/api/src/app/api/integrations/slack/manifest.json index 8f6a7c4adf..c06dbd24ed 100644 --- a/apps/api/src/app/api/integrations/slack/manifest.json +++ b/apps/api/src/app/api/integrations/slack/manifest.json @@ -39,6 +39,7 @@ "app_mentions:read", "assistant:write", "channels:history", + "channels:join", "channels:read", "chat:write", "files:read", diff --git a/apps/api/src/app/api/pages/jobs/thumbnail/route.ts b/apps/api/src/app/api/pages/jobs/thumbnail/route.ts new file mode 100644 index 0000000000..aa60d13887 --- /dev/null +++ b/apps/api/src/app/api/pages/jobs/thumbnail/route.ts @@ -0,0 +1,30 @@ +import { + generatePageThumbnail, + PAGE_THUMBNAIL_JOB_PATH, + pageThumbnailJobSchema, +} from "@superset/trpc/page-thumbnail"; +import { verifyQstashRequest } from "@/lib/verifyQstash"; + +export async function POST(request: Request) { + const body = await request.text(); + const rejected = await verifyQstashRequest( + request, + body, + PAGE_THUMBNAIL_JOB_PATH, + ); + if (rejected) return rejected; + + let payload: unknown; + try { + payload = JSON.parse(body); + } catch { + return Response.json({ error: "Invalid payload" }, { status: 400 }); + } + const parsed = pageThumbnailJobSchema.safeParse(payload); + if (!parsed.success) { + return Response.json({ error: "Invalid payload" }, { status: 400 }); + } + + const result = await generatePageThumbnail(parsed.data); + return Response.json({ result }); +} diff --git a/apps/api/src/lib/automations/dispatchMatchingTriggers.ts b/apps/api/src/lib/automations/dispatchMatchingTriggers.ts index 0d406f2f89..5ed26cfde2 100644 --- a/apps/api/src/lib/automations/dispatchMatchingTriggers.ts +++ b/apps/api/src/lib/automations/dispatchMatchingTriggers.ts @@ -3,13 +3,23 @@ import { automationEvents, automations, automationTriggers, + subscriptions, } from "@superset/db/schema"; +import { findProviderIdentity } from "@superset/db/utils"; import { + configHasMeScope, type MatchableEvent, + resolveMeScopes, triggerMatches, } from "@superset/shared/automation-matching"; +import { + ACTIVE_SUBSCRIPTION_STATUSES, + type PlanTier, + planAllowsTriggerKind, + requiredPlanForTriggerKind, +} from "@superset/shared/billing"; import { Client } from "@upstash/qstash"; -import { and, eq } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import { env } from "@/env"; const qstash = new Client({ @@ -55,11 +65,23 @@ export async function dispatchMatchingTriggers(params: { }): Promise<{ matched: number; considered: number }> { const { event } = params; + // Tier gate, same map the editor badges from: a downgraded org's triggers + // stay configured and editable, they just never fire. The event is still + // marked dispatched — it was handled, by being declined. + if (requiredPlanForTriggerKind(event.provider) !== undefined) { + const plan = await organizationPlan(params.organizationId); + if (!planAllowsTriggerKind(plan, event.provider)) { + await markDispatched(params.eventId); + return { matched: 0, considered: 0 }; + } + } + const candidates = await dbWs .select({ triggerId: automationTriggers.id, config: automationTriggers.config, automationId: automations.id, + ownerUserId: automations.ownerUserId, }) .from(automationTriggers) .innerJoin(automations, eq(automations.id, automationTriggers.automationId)) @@ -89,7 +111,34 @@ export async function dispatchMatchingTriggers(params: { return { matched: 0, considered: 0 }; } - const matched = candidates.filter( + // "Me" scopes resolve to the automation owner's identity at the event's + // provider NOW, not the identity they had when the trigger was written — + // reconnecting a different account moves every "Me" trigger with it. One + // lookup per owner per event, and only for configs that carry the mode. + const meIds = new Map>(); + const meIdFor = (ownerUserId: string) => { + // The promise is what's cached — the candidates resolve concurrently, + // and a value cache would miss for every one of them. + let pending = meIds.get(ownerUserId); + if (!pending) { + pending = findProviderIdentity({ + organizationId: params.organizationId, + userId: ownerUserId, + provider: event.provider, + }).then((identity) => identity?.externalId ?? null); + meIds.set(ownerUserId, pending); + } + return pending; + }; + const resolved = await Promise.all( + candidates.map(async (candidate) => { + if (!configHasMeScope(candidate.config)) return candidate; + const meId = await meIdFor(candidate.ownerUserId); + return { ...candidate, config: resolveMeScopes(candidate.config, meId) }; + }), + ); + + const matched = resolved.filter( (candidate) => triggerMatches(candidate.config, event).matches, ); @@ -118,6 +167,27 @@ export async function dispatchMatchingTriggers(params: { return { matched: matched.length, considered: candidates.length }; } +/** + * The org's plan as billing.activePlan resolves it: the newest subscription + * in a paying status, else free. Unrecognized plan names read as free — the + * gate must fail closed on a plan string this build doesn't know. + */ +async function organizationPlan(organizationId: string): Promise { + const [subscription] = await dbWs + .select({ plan: subscriptions.plan }) + .from(subscriptions) + .where( + and( + eq(subscriptions.referenceId, organizationId), + inArray(subscriptions.status, [...ACTIVE_SUBSCRIPTION_STATUSES]), + ), + ) + .orderBy(desc(subscriptions.createdAt)) + .limit(1); + const plan = subscription?.plan; + return plan === "pro" || plan === "enterprise" ? plan : "free"; +} + /** * The handoff to QStash is the one step that cannot be retried by the sender * or by QStash itself, so the row records that it happened. Rows left diff --git a/apps/api/src/lib/webhooks/verify.ts b/apps/api/src/lib/webhooks/verify.ts index 396e982df5..d3beede9f8 100644 --- a/apps/api/src/lib/webhooks/verify.ts +++ b/apps/api/src/lib/webhooks/verify.ts @@ -47,6 +47,58 @@ export function freshTimestamp( return Math.abs(Date.now() - ms) <= toleranceMs; } +/** + * Verify a delivery signed per the Standard Webhooks spec, which Granola and + * every other Svix-backed sender uses. + * + * Three differences from the hex-digest-over-the-body shape above, and all + * three matter: the signed string is `{id}.{timestamp}.{body}` rather than the + * body alone, the digest is base64 rather than hex, and the key is the base64 + * payload *after* the `whsec_` prefix rather than the literal string. Signing + * the id and timestamp is what makes the timestamp check meaningful — an + * attacker cannot replay a body under a fresh timestamp without the key. + * + * The header may carry several space-separated `v1,` entries during a + * secret rotation, so any match is a pass. + */ +export function verifyStandardWebhook(params: { + id: string | null; + timestamp: string | null; + signatureHeader: string | null; + body: string; + secret: string; + toleranceMs: number; +}): boolean { + const { id, timestamp, signatureHeader, body, secret } = params; + if (!id || !timestamp || !signatureHeader) return false; + if (!freshTimestamp(timestamp, params.toleranceMs)) return false; + + const key = secret.startsWith("whsec_") + ? Buffer.from(secret.slice("whsec_".length), "base64") + : Buffer.from(secret, "utf8"); + if (key.length === 0) return false; + + const expected = createHmac("sha256", key) + .update(`${id}.${timestamp}.${body}`, "utf8") + .digest(); + + // Every candidate is compared even after a match, so the work does not + // depend on which entry matched. + let matched = false; + for (const entry of signatureHeader.split(/\s+/)) { + const [version, value] = entry.split(",", 2); + if (version !== "v1" || !value) continue; + const received = Buffer.from(value, "base64"); + if ( + received.length === expected.length && + timingSafeEqual(received, expected) + ) { + matched = true; + } + } + return matched; +} + export function unauthorized(error: string): Response { return Response.json({ error }, { status: 401 }); } diff --git a/apps/desktop/docs/EXTERNAL_FILES.md b/apps/desktop/docs/EXTERNAL_FILES.md index aad501f8ba..d1d2e68c9f 100644 --- a/apps/desktop/docs/EXTERNAL_FILES.md +++ b/apps/desktop/docs/EXTERNAL_FILES.md @@ -42,7 +42,7 @@ its hook entries into these files while preserving user-defined entries: | File | Purpose | |------|---------| | `~/.claude/settings.json` | Claude Code hook registration merge | -| `~/.codex/hooks.json` | Codex hook registration merge (`SessionStart`, `UserPromptSubmit`, `Stop`) | +| `~/.codex/hooks.json` | Codex hook registration merge (`SessionStart`, `SessionEnd`, `UserPromptSubmit`, `Stop`, `Interrupt`) | | `~/.factory/settings.json` | Factory Droid hook registration (`UserPromptSubmit`, `Notification`, `PostToolUse`, `Stop`) | | `~/.omp/agent/extensions/superset-hooks.ts` (or `$OMP_CODING_AGENT_DIR/extensions/superset-hooks.ts`) | Oh My Pi lifecycle extension (`session_start`, `agent_start`, `before_agent_start`, `tool_execution_end`, `agent_end`, `session_end`, `session_shutdown`) | | `~/.pi/agent/extensions/superset-hooks.ts` | Pi lifecycle extension (`session_start`, `before_agent_start`, `agent_end`, `session_end`) | @@ -52,13 +52,15 @@ registration for durable prompt/tool lifecycle events. The wrapper in `~/.superset[-{workspace}]/bin/codex` enables those hooks — appending `--dangerously-bypass-hook-trust` when the launch command doesn't already pass it, because Codex silently skips untrusted `hooks.json` entries and Superset -would otherwise lose the Stop signal — and keeps the session-log watcher as a -best-effort compatibility bridge for Start and permission events on older -Codex releases. It does not override the legacy `notify` callback because that -callback cannot distinguish main-agent and subagent completions. On startup, -Superset rewrites only its own managed entries in `~/.codex/hooks.json` to -point at the current environment's `notify.sh`, while preserving any -user-defined Codex hooks. +would otherwise lose lifecycle signals — and keeps the session-log watcher as +a best-effort compatibility bridge for Start and permission events on older +Codex releases. Native `Stop` reports normal turn completion, `Interrupt` +reports an aborted turn, and `SessionEnd` reports session teardown. The wrapper +also keeps its clean-exit SessionEnd report for older Codex releases. It does +not override the legacy `notify` callback because that callback cannot +distinguish main-agent and subagent completions. On startup, Superset rewrites +only its own managed entries in `~/.codex/hooks.json` to point at the current +environment's `notify.sh`, while preserving any user-defined Codex hooks. ### `zsh/` and `bash/` - Shell Integration diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 572a197aa2..956ce04c8f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "@superset/desktop", "productName": "Superset", "description": "The last developer tool you'll ever need", - "version": "1.25.0", + "version": "1.25.1", "main": "./dist/main/index.js", "resources": "src/resources", "repository": { @@ -158,8 +158,8 @@ "@xterm/addon-serialize": "0.15.0-beta.289", "@xterm/addon-unicode11": "0.10.0-beta.289", "@xterm/addon-webgl": "0.20.0-beta.297", - "@xterm/headless": "6.1.0-beta.289", - "@xterm/xterm": "6.1.0-beta.289", + "@xterm/headless": "6.1.0-beta.302", + "@xterm/xterm": "6.1.0-beta.302", "ai": "6.0.235", "better-auth": "1.6.22", "better-sqlite3": "12.11.1", diff --git a/apps/desktop/scripts/cdp-smoke-popups.ts b/apps/desktop/scripts/cdp-smoke-popups.ts new file mode 100644 index 0000000000..ace3557fa0 --- /dev/null +++ b/apps/desktop/scripts/cdp-smoke-popups.ts @@ -0,0 +1,706 @@ +/** + * Smoke test for browser-pane popups against a running dev build. + * + * RENDERER_REMOTE_DEBUG_PORT=9222 bun run dev:desktop # then, with a + * # browser pane open in a workspace (Cmd+Shift+B): + * bun run apps/desktop/scripts/cdp-smoke-popups.ts + * + * Covers the contract in `main/lib/browser/popup-window.ts`: a `window.open` + * that a sign-in flow depends on becomes a real popup window that keeps + * `window.opener`, the window name, the pane's cookie jar and `window.close()`, + * while an ordinary `target="_blank"` link still opens as a split pane. + * + * Regressions here are close to invisible in unit tests: the pieces that break + * (opener identity, session inheritance, whether Electron hands the URL to the + * system browser) only exist in a real Electron window. See SUPER-1272. + * + * Serves its own fixture, so it needs no network. Exits 0 on PASS, 1 on FAIL. + * Dependency-free (Bun WebSocket + fetch). + */ + +const PORT = process.env.RENDERER_REMOTE_DEBUG_PORT; +const VITE_PORT = process.env.DESKTOP_VITE_PORT; +const FIXTURE_PORT = Number(process.env.POPUP_FIXTURE_PORT ?? "8797"); +const ONLY_CASE = process.env.POPUP_CASE; +const OBSERVE_MS = process.env.POPUP_OBSERVE_MS + ? Number(process.env.POPUP_OBSERVE_MS) + : null; +const ORIGIN = `http://localhost:${FIXTURE_PORT}`; + +const OAUTH = `${ORIGIN}/popup?client_id=a&redirect_uri=${encodeURIComponent( + `${ORIGIN}/cb`, +)}&response_type=code&scope=email`; + +/** Each case runs in the pane; `rec` stores what `window.open` handed back. */ +const CASES: Record = { + noArgs: `rec(window.open())`, + blankFragment: `rec(window.open("about:blank#state=1"))`, + oauthBare: `rec(window.open(${JSON.stringify(OAUTH)}))`, + oauthBlankTarget: `rec(window.open(${JSON.stringify(OAUTH)}, "_blank"))`, + oauthNamed: `rec(window.open(${JSON.stringify(OAUTH)}, "authwin"))`, + withFeatures: `rec(window.open(${JSON.stringify( + `${ORIGIN}/popup`, + )}, "authpopup", "width=500,height=600"))`, + oidcHybrid: `rec(window.open(${JSON.stringify( + `${ORIGIN}/popup?client_id=a&redirect_uri=b&response_type=${encodeURIComponent("code id_token")}`, + )}))`, + noopener: `rec(window.open(${JSON.stringify(OAUTH)}, "n", "noopener"))`, + nested: `rec(window.open(${JSON.stringify( + `${ORIGIN}/popup?nest=1`, + )}, "outer", "width=500,height=500"))`, + selfClosing: `rec(window.open(${JSON.stringify( + `${ORIGIN}/popup?close=1`, + )}, "c", "width=400,height=400"))`, + blankLink: `document.getElementById("lnk").click()`, + plainScriptedOpen: `rec(window.open(${JSON.stringify(`${ORIGIN}/popup`)}))`, + disallowedScheme: `rec(window.open("file:///etc/passwd"))`, +}; + +interface Expectation { + /** Popup windows expected to exist while the case is live. */ + popups: number; + /** Extra split panes the case should create. */ + panes: number; + /** Whether the popup must still see its opener. */ + opener?: boolean; + /** Whether every live popup must share the opener's cookie jar. */ + jar?: boolean; + /** Exact live window names, sorted because CDP target order is unspecified. */ + names?: string[]; + /** Exact postMessage callbacks this case must deliver to the opener. */ + messages?: string[]; + /** Whether `window.open` must hand the page a usable handle. */ + handle?: boolean; + note: string; +} + +const EXPECT: Record = { + noArgs: { + popups: 1, + panes: 0, + opener: true, + jar: true, + names: [""], + handle: true, + note: "window.open() with no arguments", + }, + blankFragment: { + popups: 1, + panes: 0, + opener: true, + jar: true, + names: [""], + handle: true, + note: "about:blank with a fragment", + }, + oauthBare: { + popups: 1, + panes: 0, + opener: true, + jar: true, + names: [""], + messages: ["ok:(anon)"], + handle: true, + note: "bare window.open of a sign-in URL (Deel's shape)", + }, + oauthBlankTarget: { + popups: 1, + panes: 0, + opener: true, + jar: true, + names: [""], + messages: ["ok:(anon)"], + handle: true, + note: "sign-in URL with a _blank name", + }, + oauthNamed: { + popups: 1, + panes: 0, + opener: true, + jar: true, + names: ["authwin"], + messages: ["ok:authwin"], + handle: true, + note: "sign-in URL, named, no features", + }, + withFeatures: { + popups: 1, + panes: 0, + opener: true, + jar: true, + names: ["authpopup"], + messages: ["ok:authpopup"], + handle: true, + note: "window.open with features (Firebase's shape)", + }, + oidcHybrid: { + popups: 1, + panes: 0, + opener: true, + jar: true, + names: [""], + messages: ["ok:(anon)"], + handle: true, + note: "OIDC hybrid response_type", + }, + noopener: { + popups: 1, + panes: 0, + opener: false, + jar: true, + names: ["n"], + handle: false, + note: "noopener severs the opener and nulls the handle, per spec", + }, + nested: { + popups: 2, + panes: 0, + opener: true, + jar: true, + names: ["inner", "outer"], + messages: ["ok:outer"], + handle: true, + note: "a popup opening its own popup (Google's consent step)", + }, + selfClosing: { + popups: 0, + panes: 0, + messages: ["ok:c"], + handle: true, + note: "window.close() from inside the popup", + }, + blankLink: { + popups: 0, + panes: 1, + note: "a plain target=_blank link still opens a split pane", + }, + plainScriptedOpen: { + popups: 0, + panes: 1, + handle: false, + note: "KNOWN GAP: indistinguishable from a _blank link, so null + a pane", + }, + disallowedScheme: { + popups: 0, + panes: 0, + handle: false, + note: "file: is refused outright", + }, +}; + +const FIXTURE = `popup fixture + +blank link +`; + +const POPUP = (q: URLSearchParams) => ` + +`; + +interface CdpTarget { + id: string; + type: string; + url: string; + webSocketDebuggerUrl?: string; +} + +class Cdp { + private id = 0; + private pending = new Map< + number, + { resolve: (v: unknown) => void; reject: (e: Error) => void } + >(); + private constructor(private ws: WebSocket) { + ws.addEventListener("message", (ev) => { + const m = JSON.parse(String(ev.data)) as { + id?: number; + result?: unknown; + error?: { message: string }; + }; + if (m.id == null) return; + const p = this.pending.get(m.id); + if (!p) return; + this.pending.delete(m.id); + m.error ? p.reject(new Error(m.error.message)) : p.resolve(m.result); + }); + } + static async connect(url: string): Promise { + const ws = new WebSocket(url); + await new Promise((res, rej) => { + ws.addEventListener("open", () => res(), { once: true }); + ws.addEventListener("error", () => rej(new Error("ws error")), { + once: true, + }); + }); + return new Cdp(ws); + } + send(method: string, params: Record = {}): Promise { + const id = ++this.id; + return new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: resolve as (v: unknown) => void, + reject, + }); + this.ws.send(JSON.stringify({ id, method, params })); + setTimeout(() => { + if (this.pending.delete(id)) reject(new Error(`timeout: ${method}`)); + }, 20_000); + }); + } + async eval(expression: string): Promise { + const r = await this.send<{ + result?: { value?: T }; + exceptionDetails?: { text: string }; + }>("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + userGesture: true, + }); + if (r.exceptionDetails) throw new Error(r.exceptionDetails.text); + return r.result?.value as T; + } + close() { + this.ws.close(); + } +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const targets = async (): Promise => + (await ( + await fetch(`http://127.0.0.1:${PORT}/json/list`) + ).json()) as CdpTarget[]; +function formatObservations(values: Array): string { + return values.length ? values.map((value) => value ?? "?").join(",") : "-"; +} + +async function closePaneTarget( + renderer: Cdp, + target: CdpTarget, +): Promise { + const point = await renderer.eval<{ x: number; y: number } | null>(`(() => { + const targetUrl = ${JSON.stringify(target.url)}; + const element = [...document.querySelectorAll("webview")].find((candidate) => + candidate.getURL?.() === targetUrl || candidate.getAttribute("src") === targetUrl + ); + if (!element) return null; + const rect = element.getBoundingClientRect(); + // The host-rendered pane header sits just above the fixed webview. + return { x: rect.left + rect.width / 2, y: Math.max(0, rect.top - 10) }; + })()`); + if (!point) throw new Error(`could not locate pane target ${target.id}`); + // PaneHeader scopes a middle click to this pane's context.actions.close. + // Unlike the global close hotkey, a miss cannot close some other active pane. + await renderer.send("Input.dispatchMouseEvent", { + type: "mousePressed", + x: point.x, + y: point.y, + button: "middle", + clickCount: 1, + }); + await renderer.send("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: point.x, + y: point.y, + button: "middle", + clickCount: 1, + }); + await sleep(200); +} + +async function main() { + if (!PORT || !VITE_PORT) { + console.error( + "FAIL: set RENDERER_REMOTE_DEBUG_PORT explicitly and load this workspace's DESKTOP_VITE_PORT from .env.", + ); + return 1; + } + if (ONLY_CASE && !(ONLY_CASE in CASES)) { + console.error(`FAIL: unknown POPUP_CASE ${ONLY_CASE}`); + return 1; + } + if (OBSERVE_MS !== null && (!Number.isFinite(OBSERVE_MS) || OBSERVE_MS < 0)) { + console.error("FAIL: POPUP_OBSERVE_MS must be a non-negative number."); + return 1; + } + + const server = Bun.serve({ + port: FIXTURE_PORT, + fetch(req) { + const u = new URL(req.url); + const body = u.pathname.startsWith("/popup") + ? POPUP(u.searchParams) + : FIXTURE; + return new Response(body, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }, + }); + + try { + const allTargets = await targets(); + const rendererOrigin = new URL(`http://localhost:${VITE_PORT}`).origin; + const renderer = allTargets.find( + (target) => + target.type === "page" && + target.webSocketDebuggerUrl && + target.url.startsWith(`${rendererOrigin}/`), + ); + if (!renderer) { + console.error( + `FAIL: no renderer for ${rendererOrigin} on CDP port ${PORT}.`, + ); + return 1; + } + + const pane = allTargets.find( + (target) => target.type === "webview" && target.webSocketDebuggerUrl, + ); + if (!pane?.webSocketDebuggerUrl) { + console.error( + `FAIL: no browser pane found on port ${PORT}.\n` + + "Open a workspace and press Cmd+Shift+B, then re-run.", + ); + return 1; + } + const originalPaneUrl = pane.url; + console.log( + `Renderer ${renderer.url}; pane ${pane.id} initially ${originalPaneUrl}`, + ); + + const g = await Cdp.connect(pane.webSocketDebuggerUrl); + const host = await Cdp.connect(renderer.webSocketDebuggerUrl as string); + const failures: string[] = []; + try { + await g.send("Page.enable"); + await g.send("Runtime.enable"); + await g.send("Page.navigate", { url: `${ORIGIN}/` }); + await sleep(2500); + + console.log( + `${"case".padEnd(19)}${"popups".padEnd(8)}${"opener".padEnd(12)}${"jar".padEnd(10)}${"name".padEnd(20)}${"handle".padEnd(8)}panes verdict`, + ); + let callbacksDelivered = 0; + + for (const [name, script] of Object.entries(CASES)) { + if (ONLY_CASE && name !== ONLY_CASE) continue; + const want = EXPECT[name]; + if (!want) continue; + const before = await targets(); + const pageIdsBefore = new Set( + before + .filter((target) => target.type === "page") + .map((target) => target.id), + ); + const paneIdsBefore = new Set( + before + .filter((target) => target.type === "webview") + .map((target) => target.id), + ); + const createdPopups = new Map(); + try { + let actionError: string | null = null; + await g + .eval(`window.__handle = null; window.__msgs = []; ${script}; 1`) + .catch((error) => { + actionError = + error instanceof Error ? error.message : String(error); + }); + await sleep( + OBSERVE_MS ?? + (name === "nested" || name === "selfClosing" ? 3000 : 2000), + ); + + const after = await targets(); + const live = after.filter( + (target) => target.type === "page" && !pageIdsBefore.has(target.id), + ); + const livePanes = after.filter( + (target) => + target.type === "webview" && !paneIdsBefore.has(target.id), + ); + for (const target of live) createdPopups.set(target.id, target); + const opener: Array = []; + const jar: Array = []; + const windowNames: Array = []; + const observationErrors: string[] = []; + for (const p of live) { + if (!p.webSocketDebuggerUrl) { + observationErrors.push(`popup ${p.id} has no debugger URL`); + continue; + } + const c = await Cdp.connect(p.webSocketDebuggerUrl).catch( + (error) => { + observationErrors.push(`popup ${p.id}: ${String(error)}`); + return null; + }, + ); + if (!c) continue; + try { + await c.send("Runtime.enable"); + // Probe the popup directly. This also covers about:blank, which never + // loads the fixture's window.__info snapshot. + opener.push( + await c.eval("!!window.opener").catch(() => null), + ); + jar.push( + await c + .eval('document.cookie.includes("paneprobe=1")') + .catch(() => null), + ); + windowNames.push( + await c.eval("window.name").catch(() => null), + ); + } finally { + c.close(); + } + } + const handle = await g + .eval("String(window.__handle)") + .catch(() => "?"); + const caseMessages = await g + .eval("window.__msgs ?? []") + .catch((error) => { + observationErrors.push( + `could not read opener callbacks: ${String(error)}`, + ); + return []; + }); + callbacksDelivered += caseMessages.length; + + const bad: string[] = []; + if (actionError) bad.push(`action failed: ${actionError}`); + bad.push(...observationErrors); + if (live.length !== want.popups) + bad.push(`popups ${live.length}!=${want.popups}`); + if (livePanes.length !== want.panes) + bad.push(`panes +${livePanes.length}!=+${want.panes}`); + if (want.opener !== undefined) { + if ( + opener.length !== live.length || + opener.some((value) => value !== want.opener) + ) { + bad.push(`opener ${formatObservations(opener)}!=${want.opener}`); + } + } + if (want.jar !== undefined) { + if ( + jar.length !== live.length || + jar.some((value) => value !== want.jar) + ) { + bad.push(`jar ${formatObservations(jar)}!=${want.jar}`); + } + } + if (want.names) { + const gotNames = windowNames + .filter((value): value is string => value != null) + .sort(); + const wantedNames = [...want.names].sort(); + if ( + gotNames.length !== live.length || + JSON.stringify(gotNames) !== JSON.stringify(wantedNames) + ) { + bad.push( + `names ${JSON.stringify(windowNames)}!=${JSON.stringify(want.names)}`, + ); + } + } + if (want.messages) { + const gotMessages = [...caseMessages].sort(); + const wantedMessages = [...want.messages].sort(); + if ( + JSON.stringify(gotMessages) !== JSON.stringify(wantedMessages) + ) { + bad.push( + `messages ${JSON.stringify(caseMessages)}!=${JSON.stringify(want.messages)}`, + ); + } + } + if (want.handle !== undefined) { + const got = handle === "HANDLE"; + if (got !== want.handle) bad.push(`handle ${handle}`); + } + if (bad.length) + failures.push(`${name}: ${bad.join(", ")} (${want.note})`); + + console.log( + `${name.padEnd(19)}${String(live.length).padEnd(8)}${formatObservations(opener).padEnd(12)}${formatObservations(jar).padEnd(10)}${formatObservations(windowNames).padEnd(20)}${handle.padEnd(8)}+${livePanes.length} ${bad.length ? `FAIL ${bad.join(", ")}` : "ok"}`, + ); + } catch (error) { + failures.push(`${name}: case failed unexpectedly: ${String(error)}`); + } finally { + await targets() + .then((current) => { + // Replace the popup snapshot only after enumeration succeeds. If it + // fails, retain recorded popup targets as a cleanup fallback. + createdPopups.clear(); + for (const target of current) { + if (target.type === "page" && !pageIdsBefore.has(target.id)) { + createdPopups.set(target.id, target); + } + } + }) + .catch((error) => { + failures.push( + `${name}: could not enumerate targets for cleanup: ${String(error)}`, + ); + }); + + const popupCleanupErrors = new Map(); + for (const p of createdPopups.values()) { + if (!p.webSocketDebuggerUrl) { + popupCleanupErrors.set(p.id, "no debugger URL"); + continue; + } + const c = await Cdp.connect(p.webSocketDebuggerUrl).catch( + (error) => { + popupCleanupErrors.set(p.id, String(error)); + return null; + }, + ); + await c?.send("Page.close").catch((error) => { + popupCleanupErrors.set(p.id, String(error)); + }); + c?.close(); + } + const listLiveCreatedPanes = () => + targets() + .then((current) => + current.filter( + (target) => + target.type === "webview" && !paneIdsBefore.has(target.id), + ), + ) + .catch((error) => { + failures.push( + `${name}: could not recount panes for cleanup: ${String(error)}`, + ); + return null; + }); + let liveCreatedPanes = await listLiveCreatedPanes(); + while (liveCreatedPanes?.length) { + const closed = await closePaneTarget(host, liveCreatedPanes[0]) + .then(() => true) + .catch((error) => { + failures.push( + `${name}: could not close created pane: ${String(error)}`, + ); + return false; + }); + if (!closed) break; + + const countBeforeClose = liveCreatedPanes.length; + let panesAfterClose: CdpTarget[] | null = liveCreatedPanes; + for ( + let attempt = 0; + attempt < 10 && + panesAfterClose !== null && + panesAfterClose.length >= countBeforeClose; + attempt += 1 + ) { + await sleep(500); + panesAfterClose = await listLiveCreatedPanes(); + } + if (panesAfterClose === null) break; + if (panesAfterClose.length >= countBeforeClose) { + failures.push( + `${name}: created pane count did not decrease during cleanup`, + ); + break; + } + if (panesAfterClose.length === 0) { + // A closed webview can briefly disappear and be recreated under a + // new target id while the renderer commits its pane state. + await sleep(1500); + panesAfterClose = await listLiveCreatedPanes(); + if (panesAfterClose === null) break; + } + liveCreatedPanes = panesAfterClose; + } + await sleep(800); + await targets() + .then((current) => { + const remainingIds = new Set(current.map((target) => target.id)); + for (const target of current) { + if ( + target.type === "webview" && + !paneIdsBefore.has(target.id) + ) { + failures.push( + `${name}: created pane ${target.id} remained after cleanup`, + ); + } + } + for (const popup of createdPopups.values()) { + if (remainingIds.has(popup.id)) { + const cleanupError = popupCleanupErrors.get(popup.id); + failures.push( + `${name}: popup ${popup.id} remained after cleanup${cleanupError ? ` (${cleanupError})` : ""}`, + ); + } + } + }) + .catch((error) => { + failures.push( + `${name}: could not verify target cleanup: ${String(error)}`, + ); + }); + } + } + + console.log( + `\npostMessage callbacks delivered to opener: ${callbacksDelivered}`, + ); + } finally { + try { + await g + .send("Page.navigate", { url: originalPaneUrl }) + .catch((error) => { + failures.push(`could not restore pane URL: ${String(error)}`); + }); + await sleep(1500); + const restored = (await targets()).find( + (target) => target.id === pane.id, + ); + if (restored?.url !== originalPaneUrl) { + failures.push( + `pane URL not restored: ${restored?.url ?? "missing"} != ${originalPaneUrl}`, + ); + } + } catch (error) { + failures.push(`could not verify pane restoration: ${String(error)}`); + } finally { + g.close(); + host.close(); + } + } + + if (failures.length) { + console.error(`\nFAIL (${failures.length}):`); + for (const f of failures) console.error(` - ${f}`); + return 1; + } + console.log("\nPASS: every popup shape behaved as specified."); + return 0; + } finally { + server.stop(true); + } +} + +process.exit(await main()); diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index e8cc89a83d..49944f5b79 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, shell } from "electron"; import { env } from "main/env.main"; +import { isBrowserPanePopup } from "main/lib/browser/popup-window"; import { loadReactDevToolsExtension } from "main/lib/extensions"; import { PLATFORM } from "shared/constants"; import { makeAppId } from "shared/utils"; @@ -44,6 +45,13 @@ export async function makeAppSetup( app.on("web-contents-created", (_, contents) => { if (contents.getType() === "webview") return; contents.on("will-navigate", (event, url) => { + // A popup a browser pane opened navigates in place: it is a real + // `window.open` window (an OAuth sign-in, typically) that has to stay + // on the pane's session. Handing it to the system browser strands the + // sign-in in a different cookie jar than the pane that started it + // (SUPER-1272). Checked here rather than above because the popup is + // marked on `did-create-window`, after this listener is attached. + if (isBrowserPanePopup(contents)) return; // Always prevent in-app navigation for external URLs if (url.startsWith("http://") || url.startsWith("https://")) { event.preventDefault(); diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index ee3ac6858d..4de7eddd36 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -18,9 +18,9 @@ import { createKeyboardLayoutRouter } from "./keyboardLayout"; import { createMenuRouter } from "./menu"; import { createMigrationRouter } from "./migration"; import { createNotificationsRouter } from "./notifications"; -import { createPageContentRouter } from "./page-content"; import { createPermissionsRouter } from "./permissions"; import { createPluginsRouter } from "./plugins"; +import { createPortForwardsRouter } from "./port-forwards"; import { createPortsRouter } from "./ports"; import { createProjectsRouter } from "./projects"; import { createResourceMetricsRouter } from "./resource-metrics"; @@ -50,10 +50,10 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { changes: createChangesRouter(), filesystem: createFilesystemRouter(), notifications: createNotificationsRouter(getWindow), - pageContent: createPageContentRouter(), permissions: createPermissionsRouter(), plugins: createPluginsRouter(), ports: createPortsRouter(), + portForwards: createPortForwardsRouter(), resourceMetrics: createResourceMetricsRouter(), menu: createMenuRouter(), external: createExternalRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/page-content/index.ts b/apps/desktop/src/lib/trpc/routers/page-content/index.ts deleted file mode 100644 index 7e09cbc802..0000000000 --- a/apps/desktop/src/lib/trpc/routers/page-content/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createPageContentRouter } from "./page-content"; diff --git a/apps/desktop/src/lib/trpc/routers/page-content/page-content.ts b/apps/desktop/src/lib/trpc/routers/page-content/page-content.ts deleted file mode 100644 index d972d39e74..0000000000 --- a/apps/desktop/src/lib/trpc/routers/page-content/page-content.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { registerPageContent, releasePageContent } from "main/lib/pageContent"; -import { z } from "zod"; -import { publicProcedure, router } from "../.."; - -export const createPageContentRouter = () => { - return router({ - register: publicProcedure - .input(z.object({ html: z.string() })) - .mutation(({ input }) => registerPageContent(input.html)), - - release: publicProcedure - .input(z.object({ token: z.string() })) - .mutation(({ input }) => { - releasePageContent(input.token); - }), - }); -}; diff --git a/apps/desktop/src/lib/trpc/routers/port-forwards/index.ts b/apps/desktop/src/lib/trpc/routers/port-forwards/index.ts new file mode 100644 index 0000000000..953aa53557 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/port-forwards/index.ts @@ -0,0 +1 @@ +export { createPortForwardsRouter } from "./port-forwards"; diff --git a/apps/desktop/src/lib/trpc/routers/port-forwards/port-forwards.ts b/apps/desktop/src/lib/trpc/routers/port-forwards/port-forwards.ts new file mode 100644 index 0000000000..231a0cd83d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/port-forwards/port-forwards.ts @@ -0,0 +1,54 @@ +import { observable } from "@trpc/server/observable"; +import { portForwardManager, setRelayToken } from "main/lib/port-forward"; +import type { PortForward } from "shared/types"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +export const createPortForwardsRouter = () => { + return router({ + setRelayToken: publicProcedure + .input(z.object({ token: z.string().nullable() })) + .mutation(({ input }) => { + setRelayToken(input.token); + }), + + list: publicProcedure.query((): PortForward[] => portForwardManager.list()), + + subscribe: publicProcedure + .input(z.object({ clientId: z.string() })) + .subscription(({ input }) => { + return observable((emit) => { + const onChange = (forwards: PortForward[]) => emit.next(forwards); + portForwardManager.on("change", onChange); + emit.next(portForwardManager.list()); + return () => { + portForwardManager.off("change", onChange); + // The subscription is per window; its teardown is how the + // manager learns a window (and its wanted set) is gone. + void portForwardManager.releaseClient(input.clientId); + }; + }); + }), + + sync: publicProcedure + .input( + z.object({ + clientId: z.string(), + hostUrl: z.string(), + workspaceId: z.string(), + ports: z.array(z.number().int().positive()), + }), + ) + .mutation( + ({ input }): Promise => portForwardManager.sync(input), + ), + + retryEphemeral: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(({ input }) => portForwardManager.retryEphemeral(input.id)), + + killLocalOwner: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(({ input }) => portForwardManager.killLocalOwner(input.id)), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 97ea23a9b8..82560b746d 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -1,6 +1,7 @@ import { existsSync, statSync } from "node:fs"; import { access, mkdir, rm } from "node:fs/promises"; import { basename, join } from "node:path"; +import { i18n } from "@superset/i18n"; import { BRANCH_PREFIX_MODES, EXTERNAL_APPS, @@ -564,7 +565,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { } const result = await dialog.showOpenDialog(window, { properties: ["openDirectory", "createDirectory"], - title: "Select Directory", + title: i18n._({ + id: "desktop.lib.dialog.selectDirectory.title", + message: "Select Directory", + }), defaultPath: input.defaultPath, }); if (result.canceled || result.filePaths.length === 0) { @@ -1085,7 +1089,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { } const result = await dialog.showOpenDialog(window, { properties: ["openDirectory", "multiSelections"], - title: "Open Project", + title: i18n._({ + id: "desktop.lib.dialog.openProject.title", + message: "Open Project", + }), }); if (result.canceled || result.filePaths.length === 0) { @@ -1247,7 +1254,10 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { } const result = await dialog.showOpenDialog(window, { properties: ["openDirectory", "createDirectory"], - title: "Select Clone Destination", + title: i18n._({ + id: "desktop.lib.dialog.selectCloneDestination.title", + message: "Select Clone Destination", + }), }); // User canceled - return canceled state (not an error) diff --git a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts index c760f3ad56..0a4ad977df 100644 --- a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts @@ -1,4 +1,5 @@ import type { ChildProcess } from "node:child_process"; +import { i18n } from "@superset/i18n"; import { TRPCError } from "@trpc/server"; import type { BrowserWindow, OpenDialogOptions } from "electron"; import { dialog } from "electron"; @@ -170,10 +171,16 @@ export const createRingtoneRouter = (getWindow: () => BrowserWindow | null) => { const window = getWindow(); const openDialogOptions: OpenDialogOptions = { properties: ["openFile"], - title: "Select Notification Sound", + title: i18n._({ + id: "desktop.lib.dialog.selectNotificationSound.title", + message: "Select Notification Sound", + }), filters: [ { - name: "Audio", + name: i18n._({ + id: "desktop.lib.dialog.filter.audio", + message: "Audio", + }), extensions: ["mp3", "wav", "ogg"], }, ], diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 47e9993664..78d13c4da9 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -45,6 +45,7 @@ import { env } from "main/env.main"; import { exitImmediately } from "main/index"; import { hasCustomRingtone } from "main/lib/custom-ringtones"; import { getHostServiceCoordinator } from "main/lib/host-service-coordinator"; +import { applyAppLanguage } from "main/lib/language"; import { localDb } from "main/lib/local-db"; import { DEFAULT_AUTO_APPLY_DEFAULT_PRESET, @@ -54,6 +55,7 @@ import { DEFAULT_OPEN_LINKS_IN_APP, DEFAULT_SHOW_PRESETS_BAR, DEFAULT_SHOW_RESOURCE_MONITOR, + DEFAULT_TERMINAL_COPY_ON_SELECT, DEFAULT_TERMINAL_LINK_BEHAVIOR, DEFAULT_TERMINAL_PARKED_RUNTIME_CAP, DEFAULT_USE_COMPACT_TERMINAL_ADD_BUTTON, @@ -623,7 +625,7 @@ export const createSettingsRouter = () => { setLanguage: publicProcedure .input(z.object({ language: z.string().nullable() })) - .mutation(({ input }) => { + .mutation(async ({ input }) => { const value = input.language === null || input.language === "auto" ? null @@ -642,6 +644,10 @@ export const createSettingsRouter = () => { set: { language: value }, }) .run(); + // The application and tray menus resolve their labels when they are + // built, so they need an explicit rebuild on a language change. + // Awaited: the catalog for the new locale loads on demand. + await applyAppLanguage(value); }), getSelectedRingtoneId: publicProcedure.query(() => { @@ -1061,6 +1067,26 @@ export const createSettingsRouter = () => { return { success: true }; }), + getTerminalCopyOnSelect: publicProcedure.query(() => { + const row = getSettings(); + return row.terminalCopyOnSelect ?? DEFAULT_TERMINAL_COPY_ON_SELECT; + }), + + setTerminalCopyOnSelect: publicProcedure + .input(z.object({ enabled: z.boolean() })) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, terminalCopyOnSelect: input.enabled }) + .onConflictDoUpdate({ + target: settings.id, + set: { terminalCopyOnSelect: input.enabled }, + }) + .run(); + + return { success: true }; + }), + getShowResourceMonitor: publicProcedure.query(() => { const row = getSettings(); return row.showResourceMonitor ?? DEFAULT_SHOW_RESOURCE_MONITOR; diff --git a/apps/desktop/src/lib/trpc/routers/window.ts b/apps/desktop/src/lib/trpc/routers/window.ts index ecc027bd73..dbe4aaa295 100644 --- a/apps/desktop/src/lib/trpc/routers/window.ts +++ b/apps/desktop/src/lib/trpc/routers/window.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import { homedir } from "node:os"; +import { i18n } from "@superset/i18n"; import { dialog } from "electron"; import { menuEmitter } from "main/lib/menu-events"; import { getOrg, setOrg } from "main/lib/window-registry/window-registry"; @@ -121,7 +122,12 @@ export const createWindowRouter = () => { const result = await dialog.showOpenDialog(window, { properties: ["openDirectory", "createDirectory"], - title: input?.title ?? "Select Directory", + title: + input?.title ?? + i18n._({ + id: "desktop.lib.dialog.selectDirectory.title", + message: "Select Directory", + }), defaultPath: input?.defaultPath ?? undefined, }); @@ -140,10 +146,16 @@ export const createWindowRouter = () => { const result = await dialog.showOpenDialog(window, { properties: ["openFile"], - title: "Select Organization Logo", + title: i18n._({ + id: "desktop.lib.dialog.selectOrganizationLogo.title", + message: "Select Organization Logo", + }), filters: [ { - name: "Images", + name: i18n._({ + id: "desktop.lib.dialog.filter.images", + message: "Images", + }), extensions: ["png", "jpg", "jpeg", "webp"], }, ], diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 1e5854625b..f68517af07 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -6,7 +6,7 @@ import { writeSharedDisabledAgentIds, writeSharedDisabledSkillIds, } from "@superset/agent-setup"; -import { initI18n, resolveLocale } from "@superset/i18n"; +import { i18n, initI18nAsync } from "@superset/i18n"; import { settings } from "@superset/local-db"; import { app, dialog, Notification, net, protocol, session } from "electron"; import { makeAppSetup } from "lib/electron-app/factories/app/setup"; @@ -33,15 +33,16 @@ import { resolveDevWorkspaceName } from "./lib/dev-workspace-name"; import { setWorkspaceDockIcon } from "./lib/dock-icon"; import { loadWebviewBrowserExtension } from "./lib/extensions"; import { getHostServiceCoordinator } from "./lib/host-service-coordinator"; +import { resolveAppLocale } from "./lib/language"; import { localDb } from "./lib/local-db"; import { requestLocalNetworkAccess } from "./lib/local-network-permission"; import { menuEmitter } from "./lib/menu-events"; -import { PAGE_SCHEME, pageProtocolHandler } from "./lib/pageContent"; import { initTanstackDbPersistence, shutdownTanstackDbPersistence, } from "./lib/persistence/persistence"; import { syncInstalledPluginMcpServers } from "./lib/plugin-installs"; +import { portForwardManager } from "./lib/port-forward"; import { ensureProjectIconsDir, getProjectIconPath } from "./lib/project-icons"; import { runQuitCleanup } from "./lib/quit-sequence"; import { initSentry } from "./lib/sentry"; @@ -95,12 +96,14 @@ async function processDeepLink(url: string): Promise { if (authLink.type !== "not-auth") { // Never log the auth URL: it contains the desktop session token. console.log("[main] Processing auth deep link"); + // `error` stays English: it is the log line. What the user reads is + // resolved separately below so it can be translated. const result = authLink.type === "valid" ? await handleAuthCallback(authLink.params) : { success: false as const, - error: "The sign-in link was incomplete. Please try again.", + error: "sign-in link was missing required parameters", }; if (result.success) { focusMainWindow(); @@ -108,9 +111,18 @@ async function processDeepLink(url: string): Promise { console.error("[main] Auth deep link failed:", result.error); focusMainWindow(); dialog.showErrorBox( - "Sign-in failed", - result.error ?? - "Superset could not complete sign-in. Please try again.", + i18n._({ id: "main.auth.failed.title", message: "Sign-in failed" }), + authLink.type === "valid" + ? (result.error ?? + i18n._({ + id: "main.auth.failed.detail", + message: + "Superset could not complete sign-in. Please try again.", + })) + : i18n._({ + id: "main.auth.failed.incompleteLink", + message: "The sign-in link was incomplete. Please try again.", + }), ); } return; @@ -239,11 +251,17 @@ app.on("before-quit", async (event) => { try { const { response } = await dialog.showMessageBox({ type: "question", - buttons: ["Quit", "Cancel"], + buttons: [ + i18n._({ id: "main.quit.confirm", message: "Quit" }), + i18n._({ id: "main.dialog.cancel", message: "Cancel" }), + ], defaultId: 0, cancelId: 1, - title: "Quit Superset", - message: "Are you sure you want to quit?", + title: i18n._({ id: "main.quit.title", message: "Quit Superset" }), + message: i18n._({ + id: "main.quit.message", + message: "Are you sure you want to quit?", + }), }); if (response === 1) { @@ -255,6 +273,9 @@ app.on("before-quit", async (event) => { } isQuitting = true; + // Local port-forward listeners hold no state worth draining; drop them so + // nothing keeps 127.0.0.1: bound after the app is gone. + portForwardManager.stopAll(); // Snapshot all open windows (bounds + org) before they close, so relaunch // restores them. markAppQuitting() stops per-window close handlers from // shrinking the set as windows close one-by-one. @@ -334,6 +355,12 @@ if (process.env.NODE_ENV === "development") { parentCheckInterval.unref(); } +// Chromium refuses to cache any single entry larger than about an eighth +// of the disk cache, and the default cache is a few hundred MB — too +// small for a video inside a page. 1 GiB lifts the per-entry cap to +// roughly 128 MB. +app.commandLine.appendSwitch("disk-cache-size", String(1024 * 1024 * 1024)); + protocol.registerSchemesAsPrivileged([ { scheme: "superset-icon", @@ -353,13 +380,6 @@ protocol.registerSchemesAsPrivileged([ supportFetchAPI: true, }, }, - { - scheme: PAGE_SCHEME, - privileges: { - standard: true, - secure: true, - }, - }, ]); const gotTheLock = app.requestSingleInstanceLock(); @@ -400,13 +420,9 @@ if (!gotTheLock) { (async () => { await app.whenReady(); // Persisted language setting wins; otherwise infer from OS preferences - // (plans/20260826-i18n-strategy.md). - initI18n( - resolveLocale([ - ...(getLanguageSetting() ? [getLanguageSetting() as string] : []), - ...app.getPreferredSystemLanguages(), - ]), - ); + // (plans/20260826-i18n-strategy.md). Menus are built later in + // initAppServices/initTray, so a plain activate is enough here. + await initI18nAsync(resolveAppLocale(getLanguageSetting())); registerWithMacOSNotificationCenter(); requestAppleEventsAccess(); requestLocalNetworkAccess(); @@ -426,11 +442,6 @@ if (!gotTheLock) { .fromPartition("persist:superset") .protocol.handle("superset-icon", iconProtocolHandler); - protocol.handle(PAGE_SCHEME, pageProtocolHandler); - session - .fromPartition("persist:superset") - .protocol.handle(PAGE_SCHEME, pageProtocolHandler); - // Serve system fonts (e.g. SF Mono on macOS) via custom protocol // so the renderer can use @font-face with font-src 'self' CSP if (process.platform === "darwin") { diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 9653533ec5..15beebdc48 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -1,6 +1,7 @@ import { EventEmitter } from "node:events"; import { statfsSync } from "node:fs"; import * as Sentry from "@sentry/electron/main"; +import { i18n } from "@superset/i18n"; import { app, dialog } from "electron"; import log from "electron-log/main"; import { autoUpdater, type UpdateCheckResult } from "electron-updater"; @@ -211,16 +212,22 @@ export function checkForUpdatesInteractive(): void { if (env.NODE_ENV === "development") { dialog.showMessageBox({ type: "info", - title: "Updates", - message: "Auto-updates are disabled in development mode.", + title: i18n._({ id: "main.update.dialog.title", message: "Updates" }), + message: i18n._({ + id: "main.update.disabledInDev", + message: "Auto-updates are disabled in development mode.", + }), }); return; } if (!IS_AUTO_UPDATE_PLATFORM) { dialog.showMessageBox({ type: "info", - title: "Updates", - message: "Auto-updates are only available on macOS and Linux.", + title: i18n._({ id: "main.update.dialog.title", message: "Updates" }), + message: i18n._({ + id: "main.update.unsupportedPlatform", + message: "Auto-updates are only available on macOS and Linux.", + }), }); return; } @@ -239,9 +246,19 @@ export function checkForUpdatesInteractive(): void { emitStatus(AUTO_UPDATE_STATUS.IDLE); dialog.showMessageBox({ type: "info", - title: "No Updates", - message: "You're up to date!", - detail: `Version ${app.getVersion()} is the latest version.`, + title: i18n._({ + id: "main.update.upToDate.title", + message: "No Updates", + }), + message: i18n._({ + id: "main.update.upToDate.message", + message: "You're up to date!", + }), + detail: i18n._({ + id: "main.update.upToDate.detail", + message: "Version {version} is the latest version.", + values: { version: app.getVersion() }, + }), }); } }) @@ -251,9 +268,15 @@ export function checkForUpdatesInteractive(): void { emitStatus(AUTO_UPDATE_STATUS.IDLE); dialog.showMessageBox({ type: "info", - title: "No Internet Connection", - message: - "Unable to check for updates. Please check your internet connection.", + title: i18n._({ + id: "main.update.offline.title", + message: "No Internet Connection", + }), + message: i18n._({ + id: "main.update.offline.message", + message: + "Unable to check for updates. Please check your internet connection.", + }), }); return; } @@ -261,8 +284,14 @@ export function checkForUpdatesInteractive(): void { emitStatus(AUTO_UPDATE_STATUS.ERROR, undefined, error.message); dialog.showMessageBox({ type: "error", - title: "Update Error", - message: "Failed to check for updates. Please try again later.", + title: i18n._({ + id: "main.update.error.title", + message: "Update Error", + }), + message: i18n._({ + id: "main.update.error.message", + message: "Failed to check for updates. Please try again later.", + }), }); }); } diff --git a/apps/desktop/src/main/lib/browser/browser-manager.test.ts b/apps/desktop/src/main/lib/browser/browser-manager.test.ts index b5c695e454..6dab446cd6 100644 --- a/apps/desktop/src/main/lib/browser/browser-manager.test.ts +++ b/apps/desktop/src/main/lib/browser/browser-manager.test.ts @@ -28,11 +28,16 @@ const PNG_IMAGE: FakeImage = { toJPEG: () => Buffer.from("jpeg"), }; +type WindowOpenHandler = ( + details: Electron.HandlerDetails, +) => Electron.WindowOpenHandlerResponse; + interface FakeWebContents { throttlingCalls: boolean[]; isDestroyed: () => boolean; setBackgroundThrottling: (allowed: boolean) => void; - setWindowOpenHandler: () => void; + setWindowOpenHandler: (handler: WindowOpenHandler) => void; + windowOpen: WindowOpenHandler | null; on: () => void; off: () => void; getURL: () => string; @@ -58,7 +63,10 @@ function makeWc(): { wc: FakeWebContents; id: number } { setBackgroundThrottling: (allowed: boolean) => { throttlingCalls.push(allowed); }, - setWindowOpenHandler: () => {}, + setWindowOpenHandler: (handler: WindowOpenHandler) => { + wc.windowOpen = handler; + }, + windowOpen: null, on: () => {}, off: () => {}, getURL: () => "https://example.com", @@ -365,3 +373,116 @@ describe("forced CDP detach", () => { expect(states).toEqual([["pane-state"], []]); }); }); + +describe("window.open handling", () => { + function openDetails( + overrides: Partial, + ): Electron.HandlerDetails { + return { + url: "https://accounts.google.com/o/oauth2/auth", + frameName: "", + features: "", + disposition: "foreground-tab", + referrer: { url: "", policy: "default" }, + ...overrides, + } as Electron.HandlerDetails; + } + + test("a real popup opens natively, so it keeps its opener and cookie jar", () => { + const wc = register("pane-popup"); + const result = wc.windowOpen?.( + openDetails({ + disposition: "new-window", + features: "width=500,height=600", + }), + ); + expect(result?.action).toBe("allow"); + // A sign-in popup must not outlive the page that opened it. + expect(result?.outlivesOpener).toBe(false); + }); + + test("geometry is left to Electron's own parse of the features string", () => { + const wc = register("pane-geometry"); + const opts = wc.windowOpen?.( + openDetails({ + disposition: "new-window", + features: "width=500,height=600", + }), + )?.overrideBrowserWindowOptions; + // These options outrank Electron's features parse, so re-deriving the + // geometry here would override Chromium rather than defer to it. + expect(opts).not.toHaveProperty("width"); + expect(opts).not.toHaveProperty("height"); + }); + + test("a popup pins no webPreferences, so it inherits the opener's", () => { + const wc = register("pane-partition"); + const result = wc.windowOpen?.(openDetails({ disposition: "new-window" })); + // Inheritance already guarantees no-Node and context isolation. Pinning + // a value that later diverges from the guest (sandbox especially) makes + // Electron isolate the child in its own process, which nulls + // window.opener and silently breaks the sign-in handshake. Not setting + // a partition is part of the same rule: the popup shares the pane's jar. + expect( + result?.overrideBrowserWindowOptions?.webPreferences, + ).toBeUndefined(); + }); + + test("an about:blank popup is allowed — auth flows open one then navigate it", () => { + const wc = register("pane-blank-popup"); + const emitted: string[] = []; + browserManager.on("new-window:pane-blank-popup", (url: string) => { + emitted.push(url); + }); + const result = wc.windowOpen?.( + openDetails({ url: "about:blank", disposition: "new-window" }), + ); + expect(result?.action).toBe("allow"); + expect(emitted).toEqual([]); + }); + + test("a target=_blank link still opens as a split pane", () => { + const wc = register("pane-link"); + const emitted: string[] = []; + browserManager.on("new-window:pane-link", (url: string) => { + emitted.push(url); + }); + const result = wc.windowOpen?.( + openDetails({ url: "https://example.com/docs", frameName: "_blank" }), + ); + expect(result?.action).toBe("deny"); + expect(emitted).toEqual(["https://example.com/docs"]); + }); + + test("a bare about:blank open is allowed, not denied as an empty tab", () => { + // window.open("about:blank") passes no features, so Chromium reports a + // tab. Denying it returns null to the caller, which auth libraries that + // open-then-navigate read as a blocked popup. + const wc = register("pane-blank-tab"); + const emitted: string[] = []; + browserManager.on("new-window:pane-blank-tab", (url: string) => { + emitted.push(url); + }); + expect(wc.windowOpen?.(openDetails({ url: "about:blank" }))?.action).toBe( + "allow", + ); + expect(emitted).toEqual([]); + }); + + test("a disallowed scheme is denied outright, popup or not", () => { + const wc = register("pane-scheme"); + const emitted: string[] = []; + browserManager.on("new-window:pane-scheme", (url: string) => { + emitted.push(url); + }); + expect( + wc.windowOpen?.(openDetails({ url: "file:///etc/passwd" }))?.action, + ).toBe("deny"); + expect( + wc.windowOpen?.( + openDetails({ url: "file:///etc/passwd", disposition: "new-window" }), + )?.action, + ).toBe("deny"); + expect(emitted).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/lib/browser/browser-manager.ts b/apps/desktop/src/main/lib/browser/browser-manager.ts index 62f535c102..a9c5dc2aac 100644 --- a/apps/desktop/src/main/lib/browser/browser-manager.ts +++ b/apps/desktop/src/main/lib/browser/browser-manager.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import { i18n } from "@superset/i18n"; import { clipboard, Menu, webContents } from "electron"; import { safeOpenExternal } from "main/lib/safe-url"; import type { @@ -16,6 +17,7 @@ import { import { DesignModeController } from "./design-mode-controller"; import { captureDesignModeScreenshot } from "./design-mode-screenshot"; import { buildDesignModeScript } from "./design-mode-script"; +import { markBrowserPanePopup, shouldOpenAsPopup } from "./popup-window"; interface ConsoleEntry { level: "log" | "warn" | "error" | "info" | "debug"; @@ -98,6 +100,47 @@ function isAllowedGuestUrl(url: string): boolean { } } +/** Shared by panes and by the popups they open. Returns a detach function. */ +function attachNavigationGuard(wc: Electron.WebContents): () => void { + const handler = (event: Electron.Event, url: string) => { + if (!isAllowedGuestUrl(url)) event.preventDefault(); + }; + wc.on("will-navigate", handler); + wc.on("will-redirect", handler); + return () => { + try { + wc.off("will-navigate", handler); + wc.off("will-redirect", handler); + } catch { + // webContents may be destroyed + } + }; +} + +/** + * Window options for a popup opened from a guest pane. + * + * Geometry is deliberately left out: Electron already parses `width`/`height`/ + * `x`/`y` from the `features` string, and options returned here outrank that + * parse — setting them would only re-derive what Chromium worked out, and drift + * from it. `partition` is left out for a different reason: the popup inherits + * the opener's session, and that shared cookie jar is the point of allowing it. + */ +function popupWindowOptions(): Electron.BrowserWindowConstructorOptions { + return { + autoHideMenuBar: true, + // A sign-in window has no business going fullscreen. + fullscreenable: false, + // `webPreferences` is deliberately not set. Electron inherits the + // opener's security preferences and refuses to relax them, so the popup + // is already no-Node and context-isolated. Restating them here would be + // worse than redundant: if a value we pin ever diverges from the guest's + // (`sandbox` especially), Electron isolates the child in its own process + // and `window.opener` comes back null, silently breaking the one thing + // this popup exists to preserve. + }; +} + /** * Resolve address-bar input to a URL the guest may load, or throw if it names * an explicit disallowed scheme (`file:`, `chrome:`, `data:`, `javascript:`, @@ -155,6 +198,7 @@ class BrowserManager extends EventEmitter { private contextMenuListeners = new Map void>(); private beforeInputListeners = new Map void>(); private navigationListeners = new Map void>(); + private popupListeners = new Map void>(); private cdpDetachers = new Map void>(); // Ref-count of in-flight agent work per pane (a live CDP session, a // screenshot capture). While present the guest renderer stays @@ -180,6 +224,7 @@ class BrowserManager extends EventEmitter { this.contextMenuListeners, this.beforeInputListeners, this.navigationListeners, + this.popupListeners, ]) { const cleanup = map.get(paneId); if (cleanup) { @@ -200,12 +245,7 @@ class BrowserManager extends EventEmitter { // throttled+hidden guest stops presenting frames and CDP input and // screenshots silently break. this.applyThrottling(paneId, wc); - wc.setWindowOpenHandler(({ url }) => { - if (url && url !== "about:blank") { - this.emit(`new-window:${paneId}`, url); - } - return { action: "deny" as const }; - }); + this.setupWindowOpen(paneId, wc); this.setupConsoleCapture(paneId, wc); this.setupContextMenu(paneId, wc); this.setupBeforeInput(paneId, wc); @@ -223,6 +263,7 @@ class BrowserManager extends EventEmitter { this.contextMenuListeners, this.beforeInputListeners, this.navigationListeners, + this.popupListeners, ]) { const cleanup = map.get(paneId); if (cleanup) { @@ -810,21 +851,83 @@ class BrowserManager extends EventEmitter { // the guest itself, so the policy holds whether the load came from the // toolbar, a link, or a raw CDP `Page.navigate` (which skips sanitizeUrl). private setupNavigationGuard(paneId: string, wc: Electron.WebContents): void { - const handler = (event: Electron.Event, url: string) => { - if (!isAllowedGuestUrl(url)) event.preventDefault(); + this.navigationListeners.set(paneId, attachNavigationGuard(wc)); + } + + private setupWindowOpen(paneId: string, wc: Electron.WebContents): void { + wc.setWindowOpenHandler((details) => + this.resolveWindowOpen(paneId, details), + ); + const onCreated = (window: Electron.BrowserWindow) => { + this.configurePopupWindow(paneId, window); }; - wc.on("will-navigate", handler); - wc.on("will-redirect", handler); - this.navigationListeners.set(paneId, () => { + wc.on("did-create-window", onCreated); + this.popupListeners.set(paneId, () => { try { - wc.off("will-navigate", handler); - wc.off("will-redirect", handler); + wc.off("did-create-window", onCreated); } catch { // webContents may be destroyed } }); } + /** + * Decide what a guest's `window.open` should do. + * + * A `target="_blank"` link (a tab disposition) keeps the pane behaviour: deny + * the native window and let the renderer open the URL as a split. The one + * exception is an OAuth authorization URL, which arrives with the same + * disposition when a site opens sign-in via a bare `window.open(url)` but + * cannot survive losing its opener — see `shouldOpenAsPopup`. + * + * A real popup has to stay a real popup. `window.open(url, name, "width=…")` + * is how "Sign in with Google" flows work (Firebase `signInWithPopup`, Google + * Identity Services, Auth0): the popup hands its result back through + * `window.opener` and then closes itself. Re-opening that URL as a detached + * pane drops both the opener and the window name, so the flow can never + * complete — the reported symptom is Google bouncing the callback to + * `accounts.google.com/CookieMismatch` (SUPER-1272). Allowing the window also + * keeps it on the opener's session, so it shares the pane's cookie jar. + */ + private resolveWindowOpen( + paneId: string, + details: Electron.HandlerDetails, + ): Electron.WindowOpenHandlerResponse { + if (!isAllowedGuestUrl(details.url)) return { action: "deny" }; + if (shouldOpenAsPopup(details)) { + return { + action: "allow", + // The default, but worth stating: a sign-in popup must not + // outlive the page that opened it. + outlivesOpener: false, + overrideBrowserWindowOptions: popupWindowOptions(), + }; + } + this.emit(`new-window:${paneId}`, details.url); + return { action: "deny" }; + } + + /** + * A popup loads arbitrary web content in the pane's session, so it gets the + * pane's scheme guard, and the same window-open policy so the nested consent + * window Google opens mid-flow stays a popup too. + */ + private configurePopupWindow( + paneId: string, + window: Electron.BrowserWindow, + ): void { + const wc = window.webContents; + markBrowserPanePopup(wc); + const detachGuard = attachNavigationGuard(wc); + wc.setWindowOpenHandler((details) => + this.resolveWindowOpen(paneId, details), + ); + wc.on("did-create-window", (child) => { + this.configurePopupWindow(paneId, child); + }); + window.on("closed", detachGuard); + } + private setupContextMenu(paneId: string, wc: Electron.WebContents): void { const handler = ( _event: Electron.Event, @@ -837,13 +940,19 @@ class BrowserManager extends EventEmitter { if (linkURL) { menuItems.push( { - label: "Open Link in Default Browser", + label: i18n._({ + id: "main.browserContextMenu.openLinkExternally", + message: "Open Link in Default Browser", + }), click: () => { void safeOpenExternal(linkURL); }, }, { - label: "Open Link as New Split", + label: i18n._({ + id: "main.browserContextMenu.openLinkAsSplit", + message: "Open Link as New Split", + }), click: () => this.emit(`context-menu-action:${paneId}`, { action: "open-in-split" as const, @@ -851,7 +960,10 @@ class BrowserManager extends EventEmitter { }), }, { - label: "Copy Link Address", + label: i18n._({ + id: "main.browserContextMenu.copyLinkAddress", + message: "Copy Link Address", + }), click: () => clipboard.writeText(linkURL), }, { type: "separator" }, @@ -860,7 +972,10 @@ class BrowserManager extends EventEmitter { if (selectionText) { menuItems.push({ - label: "Copy", + label: i18n._({ + id: "main.browserContextMenu.copy", + message: "Copy", + }), enabled: editFlags.canCopy, click: () => wc.copy(), }); @@ -868,14 +983,20 @@ class BrowserManager extends EventEmitter { if (editFlags.canPaste) { menuItems.push({ - label: "Paste", + label: i18n._({ + id: "main.browserContextMenu.paste", + message: "Paste", + }), click: () => wc.paste(), }); } if (editFlags.canSelectAll) { menuItems.push({ - label: "Select All", + label: i18n._({ + id: "main.browserContextMenu.selectAll", + message: "Select All", + }), click: () => wc.selectAll(), }); } @@ -886,17 +1007,26 @@ class BrowserManager extends EventEmitter { menuItems.push( { - label: "Back", + label: i18n._({ + id: "main.browserContextMenu.back", + message: "Back", + }), enabled: wc.canGoBack(), click: () => wc.goBack(), }, { - label: "Forward", + label: i18n._({ + id: "main.browserContextMenu.forward", + message: "Forward", + }), enabled: wc.canGoForward(), click: () => wc.goForward(), }, { - label: "Reload", + label: i18n._({ + id: "main.browserContextMenu.reload", + message: "Reload", + }), click: () => wc.reload(), }, ); @@ -905,7 +1035,10 @@ class BrowserManager extends EventEmitter { menuItems.push( { type: "separator" }, { - label: "Open Page in Default Browser", + label: i18n._({ + id: "main.browserContextMenu.openPageExternally", + message: "Open Page in Default Browser", + }), click: () => { if (pageURL && pageURL !== "about:blank") { void safeOpenExternal(pageURL); @@ -914,7 +1047,10 @@ class BrowserManager extends EventEmitter { enabled: !!pageURL && pageURL !== "about:blank", }, { - label: "Copy Page URL", + label: i18n._({ + id: "main.browserContextMenu.copyPageUrl", + message: "Copy Page URL", + }), click: () => { if (pageURL) clipboard.writeText(pageURL); }, diff --git a/apps/desktop/src/main/lib/browser/popup-window.test.ts b/apps/desktop/src/main/lib/browser/popup-window.test.ts new file mode 100644 index 0000000000..e842553b1d --- /dev/null +++ b/apps/desktop/src/main/lib/browser/popup-window.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from "bun:test"; +import { + isBrowserPanePopup, + isOAuthAuthorizationUrl, + isPopupDisposition, + markBrowserPanePopup, + shouldOpenAsPopup, +} from "./popup-window"; + +describe("isPopupDisposition", () => { + test("treats Chromium's popup disposition as a popup", () => { + expect(isPopupDisposition("new-window")).toBe(true); + }); + + test("leaves link targets as tabs, so they still open as split panes", () => { + expect(isPopupDisposition("foreground-tab")).toBe(false); + expect(isPopupDisposition("background-tab")).toBe(false); + expect(isPopupDisposition("default")).toBe(false); + expect(isPopupDisposition("other")).toBe(false); + }); +}); + +describe("browser pane popup registry", () => { + // The app-wide `web-contents-created` guard in electron-app/factories/app + // sends http(s) `will-navigate` to the system browser. It consults this + // registry so a pane's sign-in popup navigates in place instead of being + // kicked out to Chrome, which would split the session across two browsers. + const contents = () => ({}) as unknown as Electron.WebContents; + + test("an unmarked webContents is not a pane popup", () => { + expect(isBrowserPanePopup(contents())).toBe(false); + }); + + test("a marked webContents is recognised", () => { + const wc = contents(); + markBrowserPanePopup(wc); + expect(isBrowserPanePopup(wc)).toBe(true); + }); + + test("marking one popup does not mark another", () => { + const a = contents(); + const b = contents(); + markBrowserPanePopup(a); + expect(isBrowserPanePopup(a)).toBe(true); + expect(isBrowserPanePopup(b)).toBe(false); + }); +}); + +describe("isOAuthAuthorizationUrl", () => { + // The exact parameter set Deel's "Sign in with Google" opens with, captured + // from the running app. It arrives as `foreground-tab` with an empty + // frameName and empty features, indistinguishable from a `_blank` link, so + // the URL is the only signal left. + const DEEL_GOOGLE = + "https://accounts.google.com/o/oauth2/v2/auth?scope=email+profile&response_type=code&redirect_uri=https%3A%2F%2Fapp.deel.com%2Flogin%2Fgoogle&response_mode=query&prompt=select_account&client_id=x&state=y"; + + test("recognises a real provider authorization request", () => { + expect(isOAuthAuthorizationUrl(DEEL_GOOGLE)).toBe(true); + }); + + test("is provider-agnostic rather than a hostname allowlist", () => { + expect( + isOAuthAuthorizationUrl( + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=a&redirect_uri=b&response_type=code", + ), + ).toBe(true); + expect( + isOAuthAuthorizationUrl( + "https://example.okta.com/oauth2/v1/authorize?client_id=a&redirect_uri=b&response_type=token", + ), + ).toBe(true); + }); + + test("an ordinary link is not an authorization request", () => { + expect(isOAuthAuthorizationUrl("https://example.com/docs")).toBe(false); + expect(isOAuthAuthorizationUrl("https://example.com/?client_id=a")).toBe( + false, + ); + expect(isOAuthAuthorizationUrl("not a url")).toBe(false); + expect(isOAuthAuthorizationUrl("about:blank")).toBe(false); + }); +}); + +describe("shouldOpenAsPopup", () => { + test("a Chromium popup disposition is enough on its own", () => { + expect( + shouldOpenAsPopup({ + disposition: "new-window", + url: "https://example.com/anything", + }), + ).toBe(true); + }); + + test("a bare window.open of a sign-in URL still becomes a popup", () => { + // Deel's shape: no name, no features, so Chromium reports a tab. + expect( + shouldOpenAsPopup({ + disposition: "foreground-tab", + url: "https://accounts.google.com/o/oauth2/v2/auth?client_id=a&redirect_uri=b&response_type=code", + }), + ).toBe(true); + }); + + test("an ordinary target=_blank link still opens as a split pane", () => { + expect( + shouldOpenAsPopup({ + disposition: "foreground-tab", + url: "https://example.com/docs", + }), + ).toBe(false); + }); +}); + +describe("blank popups (open-then-navigate auth libraries)", () => { + test('a bare window.open("about:blank") is a popup, not a denied tab', () => { + // No features, so Chromium reports a tab. Denying it would hand the + // caller null, which reads as a blocked popup. + expect( + shouldOpenAsPopup({ disposition: "foreground-tab", url: "about:blank" }), + ).toBe(true); + }); + + test("still a popup when Chromium already calls it one", () => { + expect( + shouldOpenAsPopup({ disposition: "new-window", url: "about:blank" }), + ).toBe(true); + }); + + test("a fragment or query on about:blank does not lose the popup", () => { + // Measured: matching the bare string exactly returned null to the caller + // AND left an empty split pane behind. + for (const url of [ + "about:blank#state=abc", + "about:blank?x=1", + "about:srcdoc", + ]) { + expect(shouldOpenAsPopup({ disposition: "foreground-tab", url })).toBe( + true, + ); + } + }); +}); + +describe("isOAuthAuthorizationUrl: response_type value space", () => { + const url = (rt: string) => + `https://idp.example.com/authorize?client_id=a&redirect_uri=b&response_type=${rt}`; + + test("accepts the defined single and hybrid response types", () => { + for (const rt of [ + "code", + "token", + "id_token", + "none", + "code%20id_token", + "token%20id_token", + "token%20code%20id_token", + ]) { + expect(isOAuthAuthorizationUrl(url(rt))).toBe(true); + } + }); + + test("rejects invalid combinations and empty required values", () => { + for (const rt of ["none%20code", "code%20code", "code%20unknown"]) { + expect(isOAuthAuthorizationUrl(url(rt))).toBe(false); + } + expect( + isOAuthAuthorizationUrl( + "https://idp.example.com/authorize?client_id=&redirect_uri=b&response_type=code", + ), + ).toBe(false); + expect( + isOAuthAuthorizationUrl( + "https://idp.example.com/authorize?client_id=a&redirect_uri=&response_type=code", + ), + ).toBe(false); + }); + + test("an unrelated URL carrying the same parameter names is not a sign-in", () => { + // The false positive a presence-only check would have accepted. + expect(isOAuthAuthorizationUrl(url("json"))).toBe(false); + expect( + isOAuthAuthorizationUrl( + "https://example.com/report?client_id=a&redirect_uri=b&response_type=csv", + ), + ).toBe(false); + }); + + test("only http(s) URLs qualify", () => { + expect( + isOAuthAuthorizationUrl( + "file:///tmp/x?client_id=a&redirect_uri=b&response_type=code", + ), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/lib/browser/popup-window.ts b/apps/desktop/src/main/lib/browser/popup-window.ts new file mode 100644 index 0000000000..de75cae915 --- /dev/null +++ b/apps/desktop/src/main/lib/browser/popup-window.ts @@ -0,0 +1,132 @@ +/** + * Classification for a guest pane's `window.open`. + * + * Chromium decides the disposition itself: a scripted `window.open(url, name, + * "width=…,height=…")` is a popup (`new-window`), while a `target="_blank"` + * link is a tab (`foreground-tab`/`background-tab`). We keep tabs as split + * panes and let popups stay real popups — see `resolveWindowOpen` in + * browser-manager. + */ +export function isPopupDisposition( + disposition: Electron.HandlerDetails["disposition"], +): boolean { + return disposition === "new-window"; +} + +/** + * An OAuth 2.0 / OpenID Connect authorization request (RFC 6749 section 4.1.1). + * + * Needed because Chromium cannot tell us the thing we actually want to know. + * Measured in Electron 41: a scripted `window.open(url)` with no name and no + * features, and a plain `` click, arrive *identically* — + * disposition `foreground-tab`, empty `frameName`, empty `features`. Sites that + * open sign-in that way (Deel does) would otherwise land in a split pane, lose + * `window.opener`, and never complete the handshake. + * + * Keyed on the parameters every authorization endpoint carries rather than a + * list of provider hostnames, so it covers any identity provider without a + * vendor allowlist to maintain. A `target="_blank"` link to an ordinary page + * has none of these, so the split-pane path keeps that traffic. + */ +export function isOAuthAuthorizationUrl(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false; + const params = parsed.searchParams; + return ( + Boolean(params.get("client_id")?.trim()) && + Boolean(params.get("redirect_uri")?.trim()) && + hasAuthorizationResponseType(params) + ); +} + +/** + * `response_type` restricted to the values the specs actually define (RFC 6749 + * section 3.1.1, plus the OIDC hybrid combinations, which are space-delimited + * and order-independent). Checking the value space rather than merely the + * parameter's presence keeps an unrelated URL that happens to carry these three + * parameter names on the split-pane path, without resorting to a list of + * provider hostnames that would go stale and would miss self-hosted identity + * providers. + */ +const AUTHORIZATION_RESPONSE_TYPES = new Set([ + "code", + "id_token", + "none", + "token", + "code id_token", + "code token", + "id_token token", + "code id_token token", +]); + +function hasAuthorizationResponseType(params: URLSearchParams): boolean { + const raw = params.get("response_type")?.trim(); + if (!raw) return false; + const values = raw.split(/\s+/); + if (new Set(values).size !== values.length) return false; + return AUTHORIZATION_RESPONSE_TYPES.has(values.sort().join(" ")); +} + +/** + * `window.open("about:blank")`, then assigning `location` once the request is + * ready, is how several auth libraries dodge popup blockers. Chromium reports + * it as a tab when no features are passed, so without this it would be denied + * and `window.open` would hand the page `null` — indistinguishable, to the + * caller, from a blocked popup. + */ +function isBlankPopupUrl(url: string): boolean { + try { + // Any `about:` URL, not just the bare string: `about:blank#state` is a + // real pattern (libraries stash handshake state in the fragment), and + // matching exactly missed it, which both returned null to the caller and + // left an empty split pane behind. Nothing under `about:` is worth a + // pane, so routing the whole scheme here fixes both halves. + return new URL(url).protocol === "about:"; + } catch { + return false; + } +} + +/** + * Whether a guest's `window.open` should become a real popup window rather than + * a split pane: either Chromium already called it a popup, or it is a sign-in + * handshake that cannot survive losing its opener. + */ +export function shouldOpenAsPopup( + details: Pick, +): boolean { + return ( + isPopupDisposition(details.disposition) || + isBlankPopupUrl(details.url) || + isOAuthAuthorizationUrl(details.url) + ); +} + +/** + * Popups opened from a browser pane. + * + * The app-wide `web-contents-created` guard sends any http(s) `will-navigate` + * in a non-webview `webContents` to the system browser. A pane's popup is a + * `BrowserWindow`, so it matches that rule — which would kick a "Sign in with + * Google" window out to Chrome, stranding the session in a different browser's + * cookie jar from the pane that started it (SUPER-1272). These popups must + * navigate in place instead; that is the entire point of allowing them. + * + * A `WeakSet` so an entry dies with its `webContents`. Registration happens on + * `did-create-window`, which Electron fires before the popup's first + * `will-navigate`, so the guard always sees the mark in time. + */ +const panePopupContents = new WeakSet(); + +export function markBrowserPanePopup(contents: Electron.WebContents): void { + panePopupContents.add(contents); +} + +export function isBrowserPanePopup(contents: Electron.WebContents): boolean { + return panePopupContents.has(contents); +} diff --git a/apps/desktop/src/main/lib/edit-context-menu.ts b/apps/desktop/src/main/lib/edit-context-menu.ts index 171b6f64b7..a9706ccebf 100644 --- a/apps/desktop/src/main/lib/edit-context-menu.ts +++ b/apps/desktop/src/main/lib/edit-context-menu.ts @@ -1,3 +1,4 @@ +import { i18n } from "@superset/i18n"; import { Menu } from "electron"; /** @@ -25,7 +26,10 @@ export function attachEditContextMenu(wc: Electron.WebContents): void { if (params.misspelledWord) { menuItems.push( { - label: "Add to Dictionary", + label: i18n._({ + id: "main.contextMenu.addToDictionary", + message: "Add to Dictionary", + }), click: () => wc.session.addWordToSpellCheckerDictionary(params.misspelledWord), }, diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index 112cb4b7b8..2d4bf9875b 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto"; import { EventEmitter } from "node:events"; import * as fs from "node:fs"; import path from "node:path"; +import { i18n } from "@superset/i18n"; import { organizations, settings } from "@superset/local-db"; import { getHostId, getHostName } from "@superset/shared/host-info"; import { eq } from "drizzle-orm"; @@ -1173,10 +1174,28 @@ export class HostServiceCoordinator extends EventEmitter { const orgName = this.getOrganizationName(organizationId); void dialog.showMessageBox({ type: "error", - title: "Host service crashed", - message: `The Superset host service${orgName ? ` for ${orgName}` : ""} stopped unexpectedly (${cause}) and could not be restarted automatically.`, - detail: - "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart.", + title: i18n._({ + id: "main.hostService.crashed.title", + message: "Host service crashed", + }), + message: orgName + ? i18n._({ + id: "main.hostService.crashed.messageForOrganization", + message: + "The Superset host service for {organization} stopped unexpectedly ({cause}) and could not be restarted automatically.", + values: { organization: orgName, cause }, + }) + : i18n._({ + id: "main.hostService.crashed.message", + message: + "The Superset host service stopped unexpectedly ({cause}) and could not be restarted automatically.", + values: { cause }, + }), + detail: i18n._({ + id: "main.hostService.crashed.detail", + message: + "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart.", + }), }); } diff --git a/apps/desktop/src/main/lib/host-service-utils.ts b/apps/desktop/src/main/lib/host-service-utils.ts index d8d73d8a5c..b315073668 100644 --- a/apps/desktop/src/main/lib/host-service-utils.ts +++ b/apps/desktop/src/main/lib/host-service-utils.ts @@ -47,7 +47,7 @@ function normalizePort(port: number): number | null { return port; } -function canBindPort(port: number): Promise { +export function canBindPort(port: number): Promise { return new Promise((resolve) => { const server = createServer(); const finish = (available: boolean) => { diff --git a/apps/desktop/src/main/lib/language.ts b/apps/desktop/src/main/lib/language.ts new file mode 100644 index 0000000000..4ac616e05b --- /dev/null +++ b/apps/desktop/src/main/lib/language.ts @@ -0,0 +1,30 @@ +import { + initI18nAsync, + resolveLocale, + type SupportedLocale, +} from "@superset/i18n"; +import { app } from "electron"; +import { createApplicationMenu } from "main/lib/menu"; +import { refreshTrayMenu } from "main/lib/tray"; + +/** Persisted setting wins; otherwise infer from the OS preference list. */ +export function resolveAppLocale(stored: string | null): SupportedLocale { + return resolveLocale([ + ...(stored ? [stored] : []), + ...app.getPreferredSystemLanguages(), + ]); +} + +/** + * Activate a locale in the main process and rebuild the native surfaces whose + * labels are resolved once at build time — the application menu and the tray + * menu. Renderer windows re-render on their own via I18nProvider. + */ +export async function applyAppLanguage(stored: string | null): Promise { + // Await the catalog: non-English catalogs load on demand, and the menus + // below resolve their labels once, at build time. Rebuilding before the + // load resolves would render them in English. + await initI18nAsync(resolveAppLocale(stored)); + createApplicationMenu(); + refreshTrayMenu(); +} diff --git a/apps/desktop/src/main/lib/menu.ts b/apps/desktop/src/main/lib/menu.ts index b01d111936..5c0054e164 100644 --- a/apps/desktop/src/main/lib/menu.ts +++ b/apps/desktop/src/main/lib/menu.ts @@ -1,3 +1,4 @@ +import { i18n } from "@superset/i18n"; import { COMPANY } from "@superset/shared/constants"; import { app, BrowserWindow, Menu, shell } from "electron"; import { env } from "main/env.main"; @@ -23,10 +24,13 @@ export function createApplicationMenu() { const template: Electron.MenuItemConstructorOptions[] = [ { - label: "File", + label: i18n._({ id: "main.menu.file", message: "File" }), submenu: [ { - label: "New Window", + label: i18n._({ + id: "main.menu.file.newWindow", + message: "New Window", + }), accelerator: newWindowAccelerator, click: () => { menuEmitter.emit("new-window"); @@ -34,7 +38,10 @@ export function createApplicationMenu() { }, { type: "separator" }, { - label: "Open Repo...", + label: i18n._({ + id: "main.menu.file.openRepo", + message: "Open Repo...", + }), accelerator: "CmdOrCtrl+O", click: () => { menuEmitter.emit("open-project"); @@ -46,7 +53,10 @@ export function createApplicationMenu() { // `before-input-event` interception and closes the window instead of // the focused pane. { - label: "Close Window", + label: i18n._({ + id: "main.menu.file.closeWindow", + message: "Close Window", + }), click: () => { BrowserWindow.getFocusedWindow()?.close(); }, @@ -54,7 +64,7 @@ export function createApplicationMenu() { ], }, { - label: "Edit", + label: i18n._({ id: "main.menu.edit", message: "Edit" }), submenu: [ { role: "undo" }, { role: "redo" }, @@ -66,10 +76,10 @@ export function createApplicationMenu() { ], }, { - label: "View", + label: i18n._({ id: "main.menu.view", message: "View" }), submenu: [ { - label: "Reload", + label: i18n._({ id: "main.menu.view.reload", message: "Reload" }), accelerator: reloadAccelerator, click: () => { BrowserWindow.getFocusedWindow()?.reload(); @@ -79,7 +89,10 @@ export function createApplicationMenu() { // an implicit CmdOrCtrl+Shift+R accelerator that prevents the renderer's // Reopen Closed Tab shortcut from receiving the event. { - label: "Force Reload", + label: i18n._({ + id: "main.menu.view.forceReload", + message: "Force Reload", + }), click: () => { BrowserWindow.getFocusedWindow()?.webContents.reloadIgnoringCache(); }, @@ -91,7 +104,10 @@ export function createApplicationMenu() { { role: "zoomOut" }, { type: "separator" }, { - label: "Toggle Scripts Bar", + label: i18n._({ + id: "main.menu.view.toggleScriptsBar", + message: "Toggle Scripts Bar", + }), click: () => { menuEmitter.emit("toggle-presets-bar"); }, @@ -101,7 +117,7 @@ export function createApplicationMenu() { ], }, { - label: "Window", + label: i18n._({ id: "main.menu.window", message: "Window" }), // macOS appends the list of open windows to a windowMenu-role menu, // which is how you switch between platform windows. Without the role // the list never appears, so multi-window has no switcher. @@ -114,7 +130,7 @@ export function createApplicationMenu() { ], }, { - label: "Resources", + label: i18n._({ id: "main.menu.resources", message: "Resources" }), submenu: [ // No accelerator here: on macOS, a menu accelerator is always live // and would bypass the renderer's user-customizable CHECK_RESOURCES @@ -122,7 +138,10 @@ export function createApplicationMenu() { // discoverable via the command palette and keyboard settings, both // of which reflect the user's actual current/overridden binding. { - label: "Check Resources", + label: i18n._({ + id: "main.menu.resources.check", + message: "Check Resources", + }), click: () => { menuEmitter.emit("check-resources"); }, @@ -130,36 +149,51 @@ export function createApplicationMenu() { ], }, { - label: "Help", + label: i18n._({ id: "main.menu.help", message: "Help" }), submenu: [ { - label: "Documentation", + label: i18n._({ + id: "main.menu.help.documentation", + message: "Documentation", + }), click: () => { shell.openExternal(COMPANY.DOCS_URL); }, }, { type: "separator" }, { - label: "Contact Us", + label: i18n._({ + id: "main.menu.help.contactUs", + message: "Contact Us", + }), click: () => { shell.openExternal(COMPANY.MAIL_TO); }, }, { - label: "Report Issue", + label: i18n._({ + id: "main.menu.help.reportIssue", + message: "Report Issue", + }), click: () => { shell.openExternal(COMPANY.REPORT_ISSUE_URL); }, }, { - label: "Join Discord", + label: i18n._({ + id: "main.menu.help.joinDiscord", + message: "Join Discord", + }), click: () => { shell.openExternal(COMPANY.DISCORD_URL); }, }, { type: "separator" }, { - label: "Keyboard Shortcuts", + label: i18n._({ + id: "main.menu.help.keyboardShortcuts", + message: "Keyboard Shortcuts", + }), accelerator: showHotkeysAccelerator, click: () => { menuEmitter.emit("open-settings", "keyboard"); @@ -212,14 +246,20 @@ export function createApplicationMenu() { { role: "about" }, { type: "separator" }, { - label: "Settings...", + label: i18n._({ + id: "main.menu.app.settings", + message: "Settings...", + }), accelerator: openSettingsAccelerator, click: () => { menuEmitter.emit("open-settings"); }, }, { - label: "Check for Updates...", + label: i18n._({ + id: "main.menu.app.checkForUpdates", + message: "Check for Updates...", + }), click: () => { checkForUpdatesInteractive(); }, @@ -233,7 +273,10 @@ export function createApplicationMenu() { { type: "separator" }, { role: "quit" }, { - label: "Quit Superset Completely", + label: i18n._({ + id: "main.menu.app.quitCompletely", + message: "Quit Superset Completely", + }), click: () => { void confirmAndQuitCompletely(); }, diff --git a/apps/desktop/src/main/lib/notifications/notification-manager.ts b/apps/desktop/src/main/lib/notifications/notification-manager.ts index 542887689b..c7c8554d9a 100644 --- a/apps/desktop/src/main/lib/notifications/notification-manager.ts +++ b/apps/desktop/src/main/lib/notifications/notification-manager.ts @@ -1,3 +1,4 @@ +import { i18n } from "@superset/i18n"; import type { AgentLifecycleEvent, NotificationIds, @@ -65,15 +66,30 @@ export class NotificationManager { const isPermissionRequest = event.eventType === "PermissionRequest"; const isPendingQuestion = event.eventType === "PendingQuestion"; + const isWaiting = isPermissionRequest || isPendingQuestion; const notification = this.deps.createNotification({ - title: - isPermissionRequest || isPendingQuestion - ? `Awaiting Response — ${workspaceName}` - : `Agent Complete — ${workspaceName}`, - body: - isPermissionRequest || isPendingQuestion - ? `"${title}" is waiting for your reply` - : `"${title}" has finished its task`, + title: isWaiting + ? i18n._({ + id: "main.notification.awaitingResponse.title", + message: "Awaiting Response — {workspaceName}", + values: { workspaceName }, + }) + : i18n._({ + id: "main.notification.agentComplete.title", + message: "Agent Complete — {workspaceName}", + values: { workspaceName }, + }), + body: isWaiting + ? i18n._({ + id: "main.notification.awaitingResponse.body", + message: '"{title}" is waiting for your reply', + values: { title }, + }) + : i18n._({ + id: "main.notification.agentComplete.body", + message: '"{title}" has finished its task', + values: { title }, + }), silent: true, }); diff --git a/apps/desktop/src/main/lib/notifications/utils.ts b/apps/desktop/src/main/lib/notifications/utils.ts index e8bb44423a..6352cef93c 100644 --- a/apps/desktop/src/main/lib/notifications/utils.ts +++ b/apps/desktop/src/main/lib/notifications/utils.ts @@ -1,3 +1,4 @@ +import { i18n } from "@superset/i18n"; /** * Extracts the workspace ID from a hash-routed URL. * @@ -83,7 +84,12 @@ export function getNotificationTitle({ }): string { const tab = tabId ? tabs?.find((t) => t.id === tabId) : undefined; const pane = paneId ? panes?.[paneId] : undefined; - return tab?.userTitle?.trim() || tab?.name || pane?.name || "Terminal"; + return ( + tab?.userTitle?.trim() || + tab?.name || + pane?.name || + i18n._({ id: "main.notification.fallbackTitle", message: "Terminal" }) + ); } interface Workspace { @@ -105,5 +111,9 @@ export function getWorkspaceName({ workspace?: Workspace | null; worktree?: Worktree | null; }): string { - return workspace?.name || worktree?.branch || "Workspace"; + return ( + workspace?.name || + worktree?.branch || + i18n._({ id: "main.notification.fallbackWorkspace", message: "Workspace" }) + ); } diff --git a/apps/desktop/src/main/lib/pageContent/index.ts b/apps/desktop/src/main/lib/pageContent/index.ts deleted file mode 100644 index 0506fde795..0000000000 --- a/apps/desktop/src/main/lib/pageContent/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - PAGE_SCHEME, - pageProtocolHandler, - registerPageContent, - releasePageContent, -} from "./pageContent"; diff --git a/apps/desktop/src/main/lib/pageContent/pageContent.ts b/apps/desktop/src/main/lib/pageContent/pageContent.ts deleted file mode 100644 index edb4df9599..0000000000 --- a/apps/desktop/src/main/lib/pageContent/pageContent.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { randomUUID } from "node:crypto"; - -export const PAGE_SCHEME = "superset-page"; - -const contents = new Map(); - -export function registerPageContent(html: string): { - token: string; - url: string; -} { - const token = randomUUID(); - contents.set(token, html); - return { token, url: `${PAGE_SCHEME}://${token}/` }; -} - -export function releasePageContent(token: string): void { - contents.delete(token); -} - -export function readPageContent(token: string): string | undefined { - return contents.get(token); -} - -const PAGE_CSP = [ - "default-src 'none'", - "script-src 'unsafe-inline'", - "style-src 'unsafe-inline'", - "img-src data: blob: https:", - "media-src data: blob: https:", - "font-src data: https:", - "form-action 'none'", -].join("; "); - -export function pageProtocolHandler(request: Request): Response { - const url = new URL(request.url); - if (url.pathname !== "/") { - return new Response("Not found", { status: 404 }); - } - const html = readPageContent(url.hostname); - if (html === undefined) { - return new Response("Not found", { status: 404 }); - } - return new Response(html, { - status: 200, - headers: { - "Content-Type": "text/html; charset=utf-8", - "Content-Security-Policy": PAGE_CSP, - "Cache-Control": "no-store", - }, - }); -} diff --git a/apps/desktop/src/main/lib/port-forward/index.ts b/apps/desktop/src/main/lib/port-forward/index.ts new file mode 100644 index 0000000000..69d890a679 --- /dev/null +++ b/apps/desktop/src/main/lib/port-forward/index.ts @@ -0,0 +1,22 @@ +import { canBindPort } from "../host-service-utils"; +import { portManager } from "../terminal/port-manager"; +import { PortForwardManager } from "./port-forward-manager"; +import { RelayForwardTransport } from "./relay-forward-transport"; + +export { PortForwardManager } from "./port-forward-manager"; +export { RelayForwardTransport } from "./relay-forward-transport"; +export type { ForwardTransport } from "./types"; + +let relayToken: string | null = null; + +export function setRelayToken(token: string | null): void { + relayToken = token; +} + +export const portForwardManager = new PortForwardManager({ + transport: new RelayForwardTransport({ getToken: () => relayToken }), + getLocalPorts: () => portManager.getAllPorts(), + killLocalPort: ({ terminalId, workspaceId, port }) => + portManager.killPort({ terminalId, workspaceId, port }), + canBindPort, +}); diff --git a/apps/desktop/src/main/lib/port-forward/mux-session.test.ts b/apps/desktop/src/main/lib/port-forward/mux-session.test.ts new file mode 100644 index 0000000000..530e3123b8 --- /dev/null +++ b/apps/desktop/src/main/lib/port-forward/mux-session.test.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { AddressInfo } from "node:net"; +import { + decodeMuxFrame, + decodeOpenPort, + decodeWindowDelta, + encodeClose, + encodeData, + encodeEof, + encodeHello, + encodeOpened, + encodeOpenFail, + encodePong, + encodeWindow, + MUX_INITIAL_WINDOW_BYTES, + MUX_MAX_DATA_BYTES, + MuxFrameType, + MuxOpenFailCode, +} from "@superset/shared/port-forward-mux"; +import { type WebSocket, WebSocketServer } from "ws"; +import { RelayForwardTransport } from "./relay-forward-transport"; + +/** + * A protocol-faithful mini host: echoes every stream, credits received DATA, + * and respects the desktop's WINDOW credits when echoing back. + */ +interface MiniHostStream { + sendWindow: number; + queue: Buffer[]; + eofPending: boolean; + eofSent: boolean; +} + +function startMiniHost(options?: { + skipHello?: boolean; + closeOnConnect?: boolean; + refusePort?: number; +}) { + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const sockets = new Set(); + let connections = 0; + + wss.on("connection", (ws) => { + connections++; + sockets.add(ws); + ws.on("close", () => sockets.delete(ws)); + if (options?.closeOnConnect) { + ws.close(1008, "nope"); + return; + } + if (!options?.skipHello) ws.send(encodeHello()); + + const streams = new Map(); + const drain = (id: number, s: MiniHostStream) => { + while (s.queue.length > 0 && s.sendWindow > 0) { + let chunk = s.queue.shift(); + if (!chunk) break; + const budget = Math.min(s.sendWindow, MUX_MAX_DATA_BYTES); + if (chunk.byteLength > budget) { + s.queue.unshift(chunk.subarray(budget)); + chunk = chunk.subarray(0, budget); + } + s.sendWindow -= chunk.byteLength; + ws.send(encodeData(id, chunk)); + } + if (s.eofPending && s.queue.length === 0 && !s.eofSent) { + s.eofSent = true; + ws.send(encodeEof(id)); + } + }; + + ws.on("message", (raw) => { + const data = Array.isArray(raw) + ? Buffer.concat(raw) + : Buffer.isBuffer(raw) + ? raw + : Buffer.from(raw as ArrayBuffer); + const frame = decodeMuxFrame(data); + if (!frame) return; + switch (frame.type) { + case MuxFrameType.ping: + ws.send(encodePong()); + return; + case MuxFrameType.open: { + const port = decodeOpenPort(frame); + if (port !== null && port === options?.refusePort) { + ws.send( + encodeOpenFail( + frame.streamId, + MuxOpenFailCode.portNotOwned, + "port not owned by workspace", + ), + ); + return; + } + streams.set(frame.streamId, { + sendWindow: MUX_INITIAL_WINDOW_BYTES, + queue: [], + eofPending: false, + eofSent: false, + }); + ws.send(encodeOpened(frame.streamId)); + return; + } + case MuxFrameType.data: { + const s = streams.get(frame.streamId); + if (!s) return; + // Credit first (we consumed it), then echo within window. + ws.send(encodeWindow(frame.streamId, frame.payload.byteLength)); + s.queue.push(Buffer.from(frame.payload)); + drain(frame.streamId, s); + return; + } + case MuxFrameType.window: { + const s = streams.get(frame.streamId); + const delta = decodeWindowDelta(frame); + if (s && delta !== null) { + s.sendWindow += delta; + drain(frame.streamId, s); + } + return; + } + case MuxFrameType.eof: { + const s = streams.get(frame.streamId); + if (!s) return; + s.eofPending = true; + drain(frame.streamId, s); + return; + } + case MuxFrameType.close: { + const s = streams.get(frame.streamId); + if (s) { + streams.delete(frame.streamId); + ws.send(encodeClose(frame.streamId)); + } + return; + } + default: + return; + } + }); + }); + + const port = (wss.address() as AddressInfo).port; + return { + hostUrl: `http://127.0.0.1:${port}`, + connectionCount: () => connections, + close: async () => { + for (const s of sockets) s.terminate(); + // Bun ships its own `ws` shim whose server close callback does not + // fire while a just-terminated client lingers; don't await it. + wss.close(); + await new Promise((r) => setTimeout(r, 25)); + }, + }; +} + +function transportFor(): RelayForwardTransport { + return new RelayForwardTransport({ + getToken: () => "token", + fetchFn: async () => + new Response(JSON.stringify({ ok: true, proto: 2 }), { status: 200 }), + }); +} + +function target(hostUrl: string, remotePort = 3000) { + return { hostUrl, workspaceId: "ws1", remotePort }; +} + +const hosts: { close: () => Promise }[] = []; +afterEach(async () => { + while (hosts.length) await hosts.pop()?.close(); +}); + +function miniHost(options?: Parameters[0]) { + const host = startMiniHost(options); + hosts.push(host); + return host; +} + +describe("RelayForwardTransport over a mux session", () => { + test("a stream opens and echoes bytes both ways", async () => { + const host = miniHost(); + const transport = transportFor(); + const stream = await transport.openStream(target(host.hostUrl)); + stream.write(Buffer.from("marco")); + const echoed = await new Promise((r) => stream.once("data", r)); + expect(echoed.toString()).toBe("marco"); + stream.destroy(); + }); + + test("many streams share one WebSocket connection", async () => { + const host = miniHost(); + const transport = transportFor(); + const a = await transport.openStream(target(host.hostUrl, 3000)); + const b = await transport.openStream(target(host.hostUrl, 4000)); + a.write(Buffer.from("aaa")); + b.write(Buffer.from("bbb")); + const [fromA, fromB] = await Promise.all([ + new Promise((r) => a.once("data", r)), + new Promise((r) => b.once("data", r)), + ]); + expect(fromA.toString()).toBe("aaa"); + expect(fromB.toString()).toBe("bbb"); + expect(host.connectionCount()).toBe(1); + a.destroy(); + b.destroy(); + }); + + test("a transfer larger than the window completes intact", async () => { + const host = miniHost(); + const transport = transportFor(); + const stream = await transport.openStream(target(host.hostUrl)); + const size = MUX_INITIAL_WINDOW_BYTES * 3; + const payload = Buffer.alloc(size); + for (let i = 0; i < size; i++) payload[i] = i % 251; + + const received: Buffer[] = []; + let total = 0; + const done = new Promise((resolve) => { + stream.on("data", (chunk: Buffer) => { + received.push(chunk); + total += chunk.byteLength; + if (total >= size) resolve(); + }); + }); + stream.write(payload); + await done; + expect(Buffer.concat(received).equals(payload)).toBe(true); + stream.destroy(); + }); + + test("OPEN_FAIL surfaces as a rejected openStream", async () => { + const host = miniHost({ refusePort: 5432 }); + const transport = transportFor(); + expect(transport.openStream(target(host.hostUrl, 5432))).rejects.toThrow( + "port not owned by workspace", + ); + }); + + test("an old host closing before HELLO names the real problem", async () => { + const host = miniHost({ closeOnConnect: true }); + const transport = transportFor(); + expect(transport.openStream(target(host.hostUrl))).rejects.toThrow( + "Host does not support port forwarding", + ); + }); + + test("ending the local side sends EOF and the echo finishes", async () => { + const host = miniHost(); + const transport = transportFor(); + const stream = await transport.openStream(target(host.hostUrl)); + const chunks: Buffer[] = []; + const ended = new Promise((r) => stream.on("end", r)); + stream.on("data", (c: Buffer) => chunks.push(c)); + stream.end(Buffer.from("last words")); + await ended; + expect(Buffer.concat(chunks).toString()).toBe("last words"); + }); + + test("a dead session rejects, and the next open dials fresh", async () => { + const host = miniHost(); + const transport = transportFor(); + const stream = await transport.openStream(target(host.hostUrl)); + // The manager always attaches an error handler; without one a dead + // session's destroy(err) would crash instead of closing. + stream.on("error", () => {}); + const closed = new Promise((r) => stream.on("close", r)); + await host.close(); + await closed; + + const revived = miniHost(); + // Same transport, new host on a new port: fresh session per key. + const stream2 = await transport.openStream(target(revived.hostUrl)); + stream2.write(Buffer.from("back")); + const echoed = await new Promise((r) => stream2.once("data", r)); + expect(echoed.toString()).toBe("back"); + stream2.destroy(); + }); +}); diff --git a/apps/desktop/src/main/lib/port-forward/mux-session.ts b/apps/desktop/src/main/lib/port-forward/mux-session.ts new file mode 100644 index 0000000000..aa691aa317 --- /dev/null +++ b/apps/desktop/src/main/lib/port-forward/mux-session.ts @@ -0,0 +1,378 @@ +import { Duplex } from "node:stream"; +import { + decodeHelloVersion, + decodeMuxFrame, + decodeOpenFail, + decodeWindowDelta, + encodeClose, + encodeData, + encodeEof, + encodeOpen, + encodePing, + encodeWindow, + MUX_INITIAL_WINDOW_BYTES, + MUX_MAX_DATA_BYTES, + MUX_MAX_STREAMS, + MUX_PING_INTERVAL_MS, + MUX_PONG_TIMEOUT_MS, + MUX_PROTOCOL_VERSION, + type MuxFrame, + MuxFrameType, +} from "@superset/shared/port-forward-mux"; +import { WebSocket } from "ws"; + +const HELLO_TIMEOUT_MS = 15_000; +const OPEN_TIMEOUT_MS = 20_000; +/** A session with no live streams closes itself after this long. */ +const IDLE_CLOSE_MS = 60_000; + +interface StreamRecord { + duplex: Duplex; + /** Bytes we may still send toward the host before it credits us. */ + sendWindow: number; + /** Chunks waiting on send-window; the head may be partially sent. */ + sendQueue: { chunk: Buffer; done: (err?: Error | null) => void }[]; + headOffset: number; + /** Received bytes not yet credited back to the host. */ + uncredited: number; + creditScheduled: boolean; + /** push() returned false; credits pause until the consumer reads. */ + readPaused: boolean; + settleOpen: { + resolve: (d: Duplex) => void; + reject: (err: Error) => void; + timer: ReturnType; + } | null; + remoteClosed: boolean; +} + +/** + * The desktop end of one port-forward mux session — one WebSocket through + * the relay per (host, workspace), every forwarded TCP connection a numbered + * stream inside it (@superset/shared/port-forward-mux). Opening a connection + * costs one OPEN frame on this warm pipe instead of a relay dial-back. + */ +export class MuxSession { + /** Resolves once the host's HELLO proves it speaks the protocol. */ + readonly ready: Promise; + private readonly ws: WebSocket; + private readonly streams = new Map(); + private nextStreamId = 1; + private dead = false; + private helloSeen = false; + private lastPongAt = Date.now(); + private pingTimer: ReturnType | null = null; + private idleTimer: ReturnType | null = null; + + constructor( + url: string, + private readonly options: { onClosed: () => void }, + ) { + this.ws = new WebSocket(url); + this.ready = new Promise((resolve, reject) => { + const helloTimer = setTimeout(() => { + this.ws.terminate(); + reject(new Error("Host did not answer")); + }, HELLO_TIMEOUT_MS); + this.ws.once("unexpected-response", (_req, res) => { + clearTimeout(helloTimer); + reject(new Error(describeUpgradeFailure(res.statusCode))); + }); + this.ws.once("error", (err) => { + clearTimeout(helloTimer); + reject(err); + }); + this.ws.on("message", (data) => { + const frame = decodeMuxFrame(toUint8(data)); + if (!frame) return; + if (!this.helloSeen) { + if (frame.type !== MuxFrameType.hello) return; + const version = decodeHelloVersion(frame); + clearTimeout(helloTimer); + if (version !== MUX_PROTOCOL_VERSION) { + this.ws.close(1002, "protocol version mismatch"); + reject(new Error("Host speaks an unsupported forwarding protocol")); + return; + } + this.helloSeen = true; + this.startTimers(); + this.armIdleClose(); + resolve(); + return; + } + this.dispatch(frame); + }); + this.ws.on("close", () => { + clearTimeout(helloTimer); + const reason = this.helloSeen + ? new Error("Forward tunnel disconnected") + : // An older host-service has no /fwd route; the relay pairs + // the stream, then the host's own server refuses it. + new Error( + "Host does not support port forwarding (update the host-service)", + ); + this.destroySession(reason); + reject(reason); + }); + }); + // A session that never becomes ready surfaces through openStream; + // nothing awaits `ready` bare. + this.ready.catch(() => {}); + } + + get isDead(): boolean { + return this.dead; + } + + streamCount(): number { + return this.streams.size; + } + + openStream(port: number): Promise { + if (this.dead) { + return Promise.reject(new Error("Forward tunnel disconnected")); + } + if (this.streams.size >= MUX_MAX_STREAMS) { + return Promise.reject(new Error("Too many concurrent connections")); + } + this.cancelIdleClose(); + const id = this.nextStreamId++; + return new Promise((resolve, reject) => { + const record: StreamRecord = { + duplex: null as unknown as Duplex, + sendWindow: MUX_INITIAL_WINDOW_BYTES, + sendQueue: [], + headOffset: 0, + uncredited: 0, + creditScheduled: false, + readPaused: false, + settleOpen: { + resolve, + reject, + timer: setTimeout(() => { + this.streams.delete(id); + this.armIdleClose(); + reject(new Error("Host did not answer")); + }, OPEN_TIMEOUT_MS), + }, + remoteClosed: false, + }; + record.duplex = this.createDuplex(id, record); + this.streams.set(id, record); + this.ws.send(encodeOpen(id, port)); + }); + } + + close(): void { + this.ws.close(1000, "session closed"); + } + + private createDuplex(id: number, record: StreamRecord): Duplex { + const session = this; + return new Duplex({ + write(chunk: Buffer, _enc, cb) { + record.sendQueue.push({ chunk, done: cb }); + session.pumpSend(id, record); + }, + read() { + record.readPaused = false; + session.scheduleCredit(id, record); + }, + final(cb) { + if (!session.dead && !record.remoteClosed) { + session.ws.send(encodeEof(id)); + } + cb(); + }, + destroy(err, cb) { + if (session.streams.get(id) === record) { + session.streams.delete(id); + if (!session.dead && !record.remoteClosed) { + session.ws.send(encodeClose(id)); + } + session.armIdleClose(); + } + for (const pending of record.sendQueue) { + pending.done(err ?? new Error("stream destroyed")); + } + record.sendQueue = []; + cb(err); + }, + }); + } + + private dispatch(frame: MuxFrame): void { + if (frame.type === MuxFrameType.pong) { + this.lastPongAt = Date.now(); + return; + } + const record = this.streams.get(frame.streamId); + // Frames racing our CLOSE for an already-dropped stream are normal. + if (!record) return; + + switch (frame.type) { + case MuxFrameType.opened: { + if (record.settleOpen) { + clearTimeout(record.settleOpen.timer); + const { resolve } = record.settleOpen; + record.settleOpen = null; + resolve(record.duplex); + } + return; + } + case MuxFrameType.openFail: { + this.streams.delete(frame.streamId); + this.armIdleClose(); + if (record.settleOpen) { + clearTimeout(record.settleOpen.timer); + const { reject } = record.settleOpen; + record.settleOpen = null; + reject(new Error(decodeOpenFail(frame).message)); + } + return; + } + case MuxFrameType.data: { + const chunk = Buffer.from(frame.payload); + record.uncredited += chunk.byteLength; + if (!record.duplex.push(chunk)) { + record.readPaused = true; + } + this.scheduleCredit(frame.streamId, record); + return; + } + case MuxFrameType.window: { + const delta = decodeWindowDelta(frame); + if (delta !== null) { + record.sendWindow += delta; + this.pumpSend(frame.streamId, record); + } + return; + } + case MuxFrameType.eof: { + record.duplex.push(null); + return; + } + case MuxFrameType.close: { + record.remoteClosed = true; + this.streams.delete(frame.streamId); + this.armIdleClose(); + if (record.settleOpen) { + clearTimeout(record.settleOpen.timer); + const { reject } = record.settleOpen; + record.settleOpen = null; + reject(new Error("Connection refused by host")); + return; + } + record.duplex.destroy(); + return; + } + default: + return; + } + } + + private pumpSend(id: number, record: StreamRecord): void { + if (this.dead) return; + while (record.sendQueue.length > 0 && record.sendWindow > 0) { + const head = record.sendQueue[0]; + const remaining = head.chunk.byteLength - record.headOffset; + const len = Math.min(remaining, record.sendWindow, MUX_MAX_DATA_BYTES); + this.ws.send( + encodeData( + id, + head.chunk.subarray(record.headOffset, record.headOffset + len), + ), + ); + record.sendWindow -= len; + record.headOffset += len; + if (record.headOffset === head.chunk.byteLength) { + record.sendQueue.shift(); + record.headOffset = 0; + // Completing the write callback only once the bytes are inside + // the window is what propagates backpressure to the local socket. + head.done(); + } + } + } + + // Credits are flushed once per tick so a burst of DATA frames becomes one + // WINDOW frame. A paused reader holds credits entirely; _read releases. + private scheduleCredit(id: number, record: StreamRecord): void { + if (record.creditScheduled) return; + record.creditScheduled = true; + setImmediate(() => { + record.creditScheduled = false; + if (this.dead || this.streams.get(id) !== record) return; + if (record.readPaused) return; + if (record.uncredited > 0) { + this.ws.send(encodeWindow(id, record.uncredited)); + record.uncredited = 0; + } + }); + } + + private startTimers(): void { + this.pingTimer = setInterval(() => { + if (Date.now() - this.lastPongAt > MUX_PONG_TIMEOUT_MS) { + this.ws.terminate(); + return; + } + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send(encodePing()); + } + }, MUX_PING_INTERVAL_MS); + this.pingTimer.unref?.(); + } + + private armIdleClose(): void { + if (this.dead || this.streams.size > 0) return; + this.cancelIdleClose(); + this.idleTimer = setTimeout(() => { + if (this.streams.size === 0) this.close(); + }, IDLE_CLOSE_MS); + this.idleTimer.unref?.(); + } + + private cancelIdleClose(): void { + if (this.idleTimer) clearTimeout(this.idleTimer); + this.idleTimer = null; + } + + private destroySession(reason: Error): void { + if (this.dead) return; + this.dead = true; + if (this.pingTimer) clearInterval(this.pingTimer); + this.cancelIdleClose(); + for (const [id, record] of this.streams) { + this.streams.delete(id); + if (record.settleOpen) { + clearTimeout(record.settleOpen.timer); + record.settleOpen.reject(reason); + } else { + record.duplex.destroy(reason); + } + } + this.options.onClosed(); + } +} + +function toUint8(data: Buffer | ArrayBuffer | Buffer[]): Uint8Array { + if (Buffer.isBuffer(data)) return data; + if (Array.isArray(data)) return Buffer.concat(data); + return new Uint8Array(data); +} + +export function describeUpgradeFailure(status: number | undefined): string { + switch (status) { + case 401: + return "Session expired, sign in again"; + case 403: + return "No access to this host"; + case 503: + return "Host is offline"; + case 504: + return "Host did not answer"; + default: + return `Relay refused the stream (${status ?? "unknown"})`; + } +} diff --git a/apps/desktop/src/main/lib/port-forward/port-forward-manager.test.ts b/apps/desktop/src/main/lib/port-forward/port-forward-manager.test.ts new file mode 100644 index 0000000000..460ea9be75 --- /dev/null +++ b/apps/desktop/src/main/lib/port-forward/port-forward-manager.test.ts @@ -0,0 +1,375 @@ +import { describe, expect, test } from "bun:test"; +import net from "node:net"; +import { type Duplex, PassThrough } from "node:stream"; +import type { DetectedPort } from "@superset/port-scanner"; +import type { ForwardTarget } from "shared/types"; +import { PortForwardManager } from "./port-forward-manager"; +import type { ForwardTransport } from "./types"; + +const HOST = "https://relay.test/hosts/org:machine"; + +async function startEcho(): Promise<{ port: number; close: () => void }> { + const server = net.createServer((s) => s.pipe(s)); + const port = await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => + resolve((server.address() as net.AddressInfo).port), + ), + ); + return { port, close: () => server.close() }; +} + +async function freePort(): Promise { + const server = net.createServer(); + return new Promise((resolve) => + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as net.AddressInfo; + server.close(() => resolve(port)); + }), + ); +} + +/** A transport that opens a TCP connection to an echo server on this machine. */ +function echoTransport(echoPort: number): ForwardTransport & { + opened: ForwardTarget[]; +} { + const opened: ForwardTarget[] = []; + return { + kind: "relay", + opened, + probe: async () => {}, + openStream: async (target) => { + opened.push(target); + const socket = net.connect({ host: "127.0.0.1", port: echoPort }); + await new Promise((r) => socket.once("connect", () => r())); + return socket as Duplex; + }, + }; +} + +function manager( + transport: ForwardTransport, + overrides: Partial[0]> = {}, +) { + return new PortForwardManager({ + transport, + getLocalPorts: () => [], + killLocalPort: async () => ({ success: true }), + canBindPort: async () => true, + ...overrides, + }); +} + +async function roundTrip(port: number, payload: string): Promise { + const socket = net.connect({ host: "127.0.0.1", port }); + await new Promise((r) => socket.once("connect", () => r())); + socket.write(payload); + // TCP may split the echo across chunks; collect until it is all here. + const expected = Buffer.byteLength(payload); + const data = await new Promise((resolve) => { + const chunks: Buffer[] = []; + let total = 0; + socket.on("data", (chunk: Buffer) => { + chunks.push(chunk); + total += chunk.byteLength; + if (total >= expected) resolve(Buffer.concat(chunks)); + }); + }); + socket.destroy(); + return data.toString(); +} + +describe("PortForwardManager", () => { + test("sync listens on the requested port and bridges bytes", async () => { + const echo = await startEcho(); + const transport = echoTransport(echo.port); + const m = manager(transport); + const local = await freePort(); + const [fwd] = await m.sync({ + clientId: "w1", + hostUrl: HOST, + workspaceId: "ws1", + ports: [local], + }); + expect(fwd?.status).toEqual({ state: "active", localPort: local }); + expect(await roundTrip(local, "ping")).toBe("ping"); + expect(transport.opened[0]).toEqual({ + hostUrl: HOST, + workspaceId: "ws1", + remotePort: local, + }); + m.stopAll(); + echo.close(); + }); + + test("sync for another workspace stops the previous forwards", async () => { + const echo = await startEcho(); + const m = manager(echoTransport(echo.port)); + const a = await freePort(); + await m.sync({ + clientId: "w1", + hostUrl: HOST, + workspaceId: "ws1", + ports: [a], + }); + const b = await freePort(); + const list = await m.sync({ + clientId: "w1", + hostUrl: HOST, + workspaceId: "ws2", + ports: [b], + }); + expect(list.map((f) => f.target.workspaceId)).toEqual(["ws2"]); + expect(await freeToBind(a)).toBe(true); + m.stopAll(); + echo.close(); + }); + + test("a busy local port reports busy with the owner when known", async () => { + const echo = await startEcho(); + const owner: DetectedPort = { + port: echo.port, + pid: 42, + processName: "node", + terminalId: "t1", + workspaceId: "local-ws", + detectedAt: 0, + address: "127.0.0.1", + }; + const m = manager(echoTransport(echo.port), { + getLocalPorts: () => [owner], + }); + const [fwd] = await m.sync({ + clientId: "w1", + hostUrl: HOST, + workspaceId: "ws1", + ports: [echo.port], + }); + expect(fwd?.status).toEqual({ + state: "busy", + localPort: echo.port, + localOwner: { + pid: 42, + processName: "node", + terminalId: "t1", + workspaceId: "local-ws", + }, + }); + m.stopAll(); + echo.close(); + }); + + test("retryEphemeral moves a busy forward to another local port", async () => { + const echo = await startEcho(); + const m = manager(echoTransport(echo.port)); + const [fwd] = await m.sync({ + clientId: "w1", + hostUrl: HOST, + workspaceId: "ws1", + ports: [echo.port], + }); + expect(fwd?.status.state).toBe("busy"); + const retried = await m.retryEphemeral(fwd?.id ?? ""); + expect(retried?.status.state).toBe("active"); + if (retried?.status.state !== "active") throw new Error("not active"); + expect(retried.status.localPort).not.toBe(echo.port); + expect(await roundTrip(retried.status.localPort, "x")).toBe("x"); + m.stopAll(); + echo.close(); + }); + + test("killLocalOwner restarts the forward once the port frees", async () => { + const echo = await startEcho(); + let killed = false; + const m = manager(echoTransport(echo.port), { + getLocalPorts: () => [ + { + port: echo.port, + pid: 1, + processName: "node", + terminalId: "t1", + workspaceId: "local-ws", + detectedAt: 0, + address: "127.0.0.1", + }, + ], + killLocalPort: async () => { + killed = true; + echo.close(); + return { success: true }; + }, + canBindPort: freeToBind, + }); + const [fwd] = await m.sync({ + clientId: "w1", + hostUrl: HOST, + workspaceId: "ws1", + ports: [echo.port], + }); + const result = await m.killLocalOwner(fwd?.id ?? ""); + expect(killed).toBe(true); + expect(result).toEqual({ success: true }); + expect(m.list()[0]?.status).toEqual({ + state: "active", + localPort: echo.port, + }); + m.stopAll(); + }); + + test("a failed probe yields an error status", async () => { + const m = manager({ + kind: "relay", + probe: async () => { + throw new Error("protocol v1"); + }, + openStream: async () => new PassThrough(), + }); + const [fwd] = await m.sync({ + clientId: "w1", + hostUrl: HOST, + workspaceId: "ws1", + ports: [await freePort()], + }); + expect(fwd?.status).toEqual({ state: "error", message: "protocol v1" }); + m.stopAll(); + }); + + test("stopAll releases every listener", async () => { + const echo = await startEcho(); + const m = manager(echoTransport(echo.port)); + const local = await freePort(); + await m.sync({ + clientId: "w1", + hostUrl: HOST, + workspaceId: "ws1", + ports: [local], + }); + m.stopAll(); + expect(m.list()).toEqual([]); + expect(await freeToBind(local)).toBe(true); + echo.close(); + }); + + test("a sync during the probe never orphans a listener", async () => { + const local = await freePort(); + let releaseProbe: () => void = () => {}; + const transport: ForwardTransport = { + kind: "relay", + probe: () => + new Promise((resolve) => { + releaseProbe = resolve; + }), + openStream: async () => new PassThrough() as unknown as Duplex, + }; + const m = manager(transport); + const first = m.sync({ + clientId: "w1", + hostUrl: HOST, + workspaceId: "ws1", + ports: [local], + }); + // Deselect before the probe answers. + await m.sync({ clientId: "w1", hostUrl: HOST, workspaceId: "", ports: [] }); + releaseProbe(); + await first; + expect(m.list()).toEqual([]); + expect(await freeToBind(local)).toBe(true); + }); + + test("a later successful stream restores active after a failure", async () => { + const echo = await startEcho(); + let fail = true; + const echoing = echoTransport(echo.port); + const transport: ForwardTransport = { + kind: "relay", + probe: async () => {}, + openStream: (target) => { + if (fail) return Promise.reject(new Error("relay hiccup")); + return echoing.openStream(target); + }, + }; + const m = manager(transport); + const local = await freePort(); + await m.sync({ + clientId: "w1", + hostUrl: HOST, + workspaceId: "ws1", + ports: [local], + }); + // The first connection fails and poisons the status. + const s = net.connect({ host: "127.0.0.1", port: local }); + await new Promise((r) => s.once("close", r)); + expect(m.list()[0]?.status.state).toBe("error"); + // The listener is still bound; the next connection heals it. + fail = false; + expect(await roundTrip(local, "healed")).toBe("healed"); + expect(m.list()[0]?.status).toEqual({ + state: "active", + localPort: local, + }); + m.stopAll(); + echo.close(); + }); + + test("two clients wanting different forwards do not fight", async () => { + const echo = await startEcho(); + const m = manager(echoTransport(echo.port)); + const a = await freePort(); + const b = await freePort(); + await m.sync({ + clientId: "w1", + hostUrl: HOST, + workspaceId: "ws1", + ports: [a], + }); + await m.sync({ + clientId: "w2", + hostUrl: HOST, + workspaceId: "ws2", + ports: [b], + }); + // Both forwards run; neither sync tore the other down. + expect( + m + .list() + .map((f) => f.target.remotePort) + .sort(), + ).toEqual([a, b].sort()); + // Releasing one client stops only its forward. + await m.releaseClient("w1"); + expect(m.list().map((f) => f.target.remotePort)).toEqual([b]); + expect(await freeToBind(a)).toBe(true); + m.stopAll(); + echo.close(); + }); + + test("a forward both clients want survives either releasing it", async () => { + const echo = await startEcho(); + const m = manager(echoTransport(echo.port)); + const a = await freePort(); + await m.sync({ + clientId: "w1", + hostUrl: HOST, + workspaceId: "ws1", + ports: [a], + }); + await m.sync({ + clientId: "w2", + hostUrl: HOST, + workspaceId: "ws1", + ports: [a], + }); + await m.releaseClient("w1"); + expect(m.list()).toHaveLength(1); + expect(await roundTrip(a, "still here")).toBe("still here"); + m.stopAll(); + echo.close(); + }); +}); + +function freeToBind(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => server.close(() => resolve(true))); + }); +} diff --git a/apps/desktop/src/main/lib/port-forward/port-forward-manager.ts b/apps/desktop/src/main/lib/port-forward/port-forward-manager.ts new file mode 100644 index 0000000000..674aa41345 --- /dev/null +++ b/apps/desktop/src/main/lib/port-forward/port-forward-manager.ts @@ -0,0 +1,348 @@ +import { EventEmitter } from "node:events"; +import net from "node:net"; +import type { DetectedPort } from "@superset/port-scanner"; +import type { + ForwardTarget, + LocalPortOwner, + PortForward, + PortForwardStatus, +} from "shared/types"; +import { portForwardId } from "shared/types"; +import type { ForwardTransport } from "./types"; + +interface ForwardEntry { + forward: PortForward; + server: net.Server | null; + sockets: Set; +} + +export interface PortForwardManagerOptions { + transport: ForwardTransport; + /** Local ports the desktop's own port scanner attributes to a workspace. */ + getLocalPorts: () => DetectedPort[]; + killLocalPort: (owner: LocalPortOwner & { port: number }) => Promise<{ + success: boolean; + error?: string; + }>; + canBindPort: (port: number) => Promise; +} + +const MAX_PENDING_FRAMES = 64; +const MAX_PENDING_BYTES = 4 * 1024 * 1024; +const KILL_WAIT_MS = 3_000; +const KILL_POLL_MS = 250; + +/** + * Owns every local listener that forwards to a remote workspace port. Each + * window is a client with its own wanted set (its selected workspace's remote + * ports); the manager keeps exactly the union running, so two windows on + * different workspaces don't tear each other's forwards down. A client's set + * is dropped when its window's subscription goes away. + */ +export class PortForwardManager extends EventEmitter<{ + change: [PortForward[]]; +}> { + private readonly entries = new Map(); + private readonly wantedByClient = new Map< + string, + Map + >(); + + constructor(private readonly options: PortForwardManagerOptions) { + super(); + } + + list(): PortForward[] { + return Array.from(this.entries.values(), (e) => ({ ...e.forward })); + } + + async sync({ + clientId, + hostUrl, + workspaceId, + ports, + }: { + clientId: string; + hostUrl: string; + workspaceId: string; + ports: number[]; + }): Promise { + const wanted = new Map(); + for (const remotePort of new Set(ports)) { + const target = { hostUrl, workspaceId, remotePort }; + wanted.set(portForwardId(target), target); + } + if (wanted.size > 0) { + this.wantedByClient.set(clientId, wanted); + } else { + this.wantedByClient.delete(clientId); + } + return this.reconcile(); + } + + /** A window went away; whatever only it wanted stops. */ + async releaseClient(clientId: string): Promise { + if (!this.wantedByClient.delete(clientId)) return; + await this.reconcile(); + } + + private async reconcile(): Promise { + const union = new Map(); + for (const wanted of this.wantedByClient.values()) { + for (const [id, target] of wanted) union.set(id, target); + } + for (const id of Array.from(this.entries.keys())) { + if (!union.has(id)) this.stop(id); + } + await Promise.all( + Array.from(union.entries()) + .filter(([id]) => !this.entries.has(id)) + .map(([, target]) => this.start(target)), + ); + return this.list(); + } + + async retryEphemeral(id: string): Promise { + const entry = this.entries.get(id); + if (!entry) return null; + if (entry.forward.status.state !== "busy") return { ...entry.forward }; + await this.listen({ entry, localPort: 0 }); + if (this.entries.get(id) !== entry) { + this.closeServer(entry); + return null; + } + return { ...entry.forward }; + } + + async killLocalOwner( + id: string, + ): Promise<{ success: boolean; error?: string }> { + const entry = this.entries.get(id); + if (!entry) return { success: false, error: "Forward not found" }; + const { status } = entry.forward; + if (status.state !== "busy" || !status.localOwner) { + return { success: false, error: "Local process is not known" }; + } + const port = entry.forward.target.remotePort; + const result = await this.options.killLocalPort({ + ...status.localOwner, + port, + }); + if (!result.success) return result; + const deadline = Date.now() + KILL_WAIT_MS; + while (Date.now() < deadline) { + if (this.entries.get(id) !== entry) { + return { success: false, error: "Forward was stopped" }; + } + if (await this.options.canBindPort(port)) { + await this.listen({ entry, localPort: port }); + if (this.entries.get(id) !== entry) this.closeServer(entry); + return { success: true }; + } + await new Promise((r) => setTimeout(r, KILL_POLL_MS)); + } + return { success: false, error: "Local port is still busy" }; + } + + stopAll(): void { + for (const id of Array.from(this.entries.keys())) this.stop(id); + } + + private async start(target: ForwardTarget): Promise { + const id = portForwardId(target); + const entry: ForwardEntry = { + forward: { + id, + target, + status: { state: "error", message: "Starting" }, + transport: this.options.transport.kind, + connections: 0, + }, + server: null, + sockets: new Set(), + }; + this.entries.set(id, entry); + try { + await this.options.transport.probe(target); + } catch (err) { + this.setStatus({ + entry, + status: { state: "error", message: messageOf(err) }, + }); + return; + } + // A sync during the probe may have stopped this forward; binding now + // would orphan a listener nothing owns. + if (this.entries.get(id) !== entry) return; + await this.listen({ entry, localPort: target.remotePort }); + } + + private listen({ + entry, + localPort, + }: { + entry: ForwardEntry; + localPort: number; + }): Promise { + this.closeServer(entry); + return new Promise((resolve) => { + const server = net.createServer((socket) => + this.accept({ entry, socket }), + ); + entry.server = server; + server.once("error", (err: NodeJS.ErrnoException) => { + entry.server = null; + if (err.code === "EADDRINUSE") { + this.setStatus({ + entry, + status: { + state: "busy", + localPort, + localOwner: this.findLocalOwner(localPort), + }, + }); + } else { + this.setStatus({ + entry, + status: { state: "error", message: messageOf(err) }, + }); + } + resolve(); + }); + server.listen(localPort, "127.0.0.1", () => { + const address = server.address(); + const bound = + address && typeof address === "object" ? address.port : localPort; + this.setStatus({ + entry, + status: { state: "active", localPort: bound }, + }); + resolve(); + }); + }); + } + + private accept({ + entry, + socket, + }: { + entry: ForwardEntry; + socket: net.Socket; + }): void { + entry.sockets.add(socket); + entry.forward.connections = entry.sockets.size; + this.emitChange(); + socket.on("close", () => { + entry.sockets.delete(socket); + entry.forward.connections = entry.sockets.size; + this.emitChange(); + }); + socket.on("error", () => socket.destroy()); + + // Bytes the client sends while the relay stream is still opening. A + // consumer must be attached from the first tick: an unread socket drops + // data under Bun, and a browser sends its request right after connect. + const pending: Buffer[] = []; + let pendingBytes = 0; + const buffer = (chunk: Buffer) => { + pending.push(chunk); + pendingBytes += chunk.byteLength; + if ( + pending.length > MAX_PENDING_FRAMES || + pendingBytes > MAX_PENDING_BYTES + ) { + socket.destroy(); + } + }; + socket.on("data", buffer); + + this.options.transport + .openStream(entry.forward.target) + .then((stream) => { + socket.off("data", buffer); + if (socket.destroyed) { + stream.destroy(); + return; + } + // A transient stream failure flips the forward to error; the + // listener is still bound, so the next success restores it. + if ( + entry.forward.status.state === "error" && + this.entries.get(entry.forward.id) === entry && + entry.server + ) { + const address = entry.server.address(); + const bound = + address && typeof address === "object" + ? address.port + : entry.forward.target.remotePort; + this.setStatus({ + entry, + status: { state: "active", localPort: bound }, + }); + } + stream.on("error", () => socket.destroy()); + stream.on("close", () => socket.destroy()); + socket.on("close", () => stream.destroy()); + for (const chunk of pending) stream.write(chunk); + pending.length = 0; + socket.pipe(stream); + stream.pipe(socket); + }) + .catch((err) => { + socket.destroy(); + if (this.entries.get(entry.forward.id) === entry) { + this.setStatus({ + entry, + status: { state: "error", message: messageOf(err) }, + }); + } + }); + } + + private findLocalOwner(port: number): LocalPortOwner | null { + const match = this.options.getLocalPorts().find((p) => p.port === port); + if (!match) return null; + return { + pid: match.pid, + processName: match.processName, + terminalId: match.terminalId, + workspaceId: match.workspaceId, + }; + } + + private stop(id: string): void { + const entry = this.entries.get(id); + if (!entry) return; + this.entries.delete(id); + this.closeServer(entry); + this.emitChange(); + } + + private closeServer(entry: ForwardEntry): void { + for (const socket of entry.sockets) socket.destroy(); + entry.sockets.clear(); + entry.forward.connections = 0; + entry.server?.close(); + entry.server = null; + } + + private setStatus({ + entry, + status, + }: { + entry: ForwardEntry; + status: PortForwardStatus; + }): void { + entry.forward.status = status; + this.emitChange(); + } + + private emitChange(): void { + this.emit("change", this.list()); + } +} + +function messageOf(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/apps/desktop/src/main/lib/port-forward/relay-forward-transport.ts b/apps/desktop/src/main/lib/port-forward/relay-forward-transport.ts new file mode 100644 index 0000000000..a53a9c6652 --- /dev/null +++ b/apps/desktop/src/main/lib/port-forward/relay-forward-transport.ts @@ -0,0 +1,83 @@ +import type { Duplex } from "node:stream"; +import type { ForwardTarget } from "shared/types"; +import { MuxSession } from "./mux-session"; +import type { ForwardTransport } from "./types"; + +export interface RelayForwardTransportOptions { + getToken: () => string | null; + fetchFn?: (input: string) => Promise; +} + +/** + * Forwards over one mux session per (host, workspace), spliced through + * relay2's per-stream dial-back exactly like a terminal stream. The first + * connection after selecting a workspace pays the relay dial; every later + * connection is an OPEN frame on the warm session. Only protocol v2 relays + * can carry binary client frames, so the probe refuses v1 (`/health` + * without `proto: 2`). + */ +export class RelayForwardTransport implements ForwardTransport { + readonly kind = "relay" as const; + private readonly probes = new Map>(); + private readonly sessions = new Map(); + + constructor(private readonly options: RelayForwardTransportOptions) {} + + async probe(target: ForwardTarget): Promise { + const origin = new URL(target.hostUrl).origin; + let pending = this.probes.get(origin); + if (!pending) { + pending = this.checkProtocol(origin).catch((err) => { + // Let a transient failure retry on the next sync. + this.probes.delete(origin); + throw err; + }); + this.probes.set(origin, pending); + } + await pending; + // Establish the mux session now, so a host without forwarding support + // errors the row at sync time and the first connection is one OPEN + // frame on an already-warm pipe instead of a relay dial. + await this.session(target).ready; + } + + private async checkProtocol(origin: string): Promise { + const fetchFn = this.options.fetchFn ?? fetch; + const res = await fetchFn(`${origin}/health`); + if (!res.ok) throw new Error(`Relay health check failed (${res.status})`); + const body = (await res.json()) as { proto?: number }; + if (body.proto !== 2) { + throw new Error( + "Host relay does not support port forwarding (protocol v1)", + ); + } + } + + async openStream(target: ForwardTarget): Promise { + const session = this.session(target); + await session.ready; + return session.openStream(target.remotePort); + } + + private session(target: ForwardTarget): MuxSession { + const key = `${target.hostUrl}|${target.workspaceId}`; + const existing = this.sessions.get(key); + if (existing && !existing.isDead) return existing; + + const token = this.options.getToken(); + if (!token) throw new Error("Not signed in"); + const url = new URL(`${target.hostUrl}/fwd`); + if (url.protocol === "http:") url.protocol = "ws:"; + if (url.protocol === "https:") url.protocol = "wss:"; + url.searchParams.set("workspaceId", target.workspaceId); + url.searchParams.set("token", token); + + const session = new MuxSession(url.toString(), { + onClosed: () => { + if (this.sessions.get(key) === session) this.sessions.delete(key); + }, + }); + this.sessions.set(key, session); + return session; + } +} diff --git a/apps/desktop/src/main/lib/port-forward/types.ts b/apps/desktop/src/main/lib/port-forward/types.ts new file mode 100644 index 0000000000..c8f67d53b1 --- /dev/null +++ b/apps/desktop/src/main/lib/port-forward/types.ts @@ -0,0 +1,20 @@ +import type { Duplex } from "node:stream"; +import type { ForwardTarget, ForwardTransportKind } from "shared/types"; + +/** + * How bytes get from the local machine to 127.0.0.1: on the host. + * The relay implementation ships first; a direct (Tailscale, VPN, LAN) or SSH + * implementation plugs in here without touching the manager or the UI. + */ +export interface ForwardTransport { + readonly kind: ForwardTransportKind; + /** + * Resolves when the transport can serve this target; rejects with a + * reason. Also where a transport warms anything it wants ready before the + * first connection (the relay transport establishes its mux session here, + * so an unsupported host errors the row at sync time, not on first click). + */ + probe(target: ForwardTarget): Promise; + /** One bidirectional byte stream to 127.0.0.1: on the host. */ + openStream(target: ForwardTarget): Promise; +} diff --git a/apps/desktop/src/main/lib/quit-completely.ts b/apps/desktop/src/main/lib/quit-completely.ts index 09b254957e..df3494cab9 100644 --- a/apps/desktop/src/main/lib/quit-completely.ts +++ b/apps/desktop/src/main/lib/quit-completely.ts @@ -1,3 +1,4 @@ +import { i18n } from "@superset/i18n"; import { dialog } from "electron"; import { quitAppCompletely } from "main/index"; @@ -5,13 +6,28 @@ export async function confirmAndQuitCompletely(): Promise { try { const { response } = await dialog.showMessageBox({ type: "warning", - buttons: ["Quit Completely", "Cancel"], + buttons: [ + i18n._({ + id: "main.quitCompletely.confirm", + message: "Quit Completely", + }), + i18n._({ id: "main.dialog.cancel", message: "Cancel" }), + ], defaultId: 1, cancelId: 1, - title: "Quit Superset Completely", - message: "Quit Superset and stop all background services?", - detail: - "All open terminal sessions will be killed and any running host-services will be stopped. Use “Close Superset” instead if you want services to keep running for the next launch.", + title: i18n._({ + id: "main.quitCompletely.title", + message: "Quit Superset Completely", + }), + message: i18n._({ + id: "main.quitCompletely.message", + message: "Quit Superset and stop all background services?", + }), + detail: i18n._({ + id: "main.quitCompletely.detail", + message: + "All open terminal sessions will be killed and any running host-services will be stopped. Use “Close Superset” instead if you want services to keep running for the next launch.", + }), }); if (response === 0) { quitAppCompletely(); diff --git a/apps/desktop/src/main/lib/terminal-host/terminal-viewport-regression.test.ts b/apps/desktop/src/main/lib/terminal-host/terminal-viewport-regression.test.ts new file mode 100644 index 0000000000..864953c186 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal-host/terminal-viewport-regression.test.ts @@ -0,0 +1,78 @@ +/** + * Viewport regression guard for the Pi scroll jump. + * + * Pi redraws with a synchronized update that ends in ED3 (`CSI 3 J`, erase + * scrollback). Through @xterm/xterm 6.1.0-beta.289 that path trimmed the + * scrollback and clamped ydisp but left `isUserScrolling` armed, so the + * viewport stayed frozen where the user had scrolled to and never followed + * subsequent output again. beta.302 clears the flag, scoped to the normal + * buffer. + */ + +import { expect, test } from "bun:test"; +import "../../terminal-host/xterm-env-polyfill"; +import { Terminal } from "@xterm/headless"; + +const CSI = "\x1b["; +const ERASE_SCROLLBACK = `${CSI}3J`; +const ENTER_ALT_SCREEN = `${CSI}?1049h`; +const EXIT_ALT_SCREEN = `${CSI}?1049l`; +// Pi's full redraw: begin synchronized update, clear, home, erase scrollback. +const PI_REDRAW = `${CSI}?2026h${CSI}2J${CSI}H${ERASE_SCROLLBACK}redrawn${CSI}?2026l`; + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)); +} + +function lines(prefix: string, count: number): string { + return Array.from({ length: count }, (_, i) => `${prefix} ${i}\r\n`).join(""); +} + +function newTerminal(): Terminal { + return new Terminal({ cols: 20, rows: 5, scrollback: 100 }); +} + +test("viewport follows new output after Pi erases the scrollback", async () => { + const terminal = newTerminal(); + + try { + await write(terminal, lines("line", 40)); + terminal.scrollLines(-15); + expect(terminal.buffer.active.viewportY).toBeLessThan( + terminal.buffer.active.baseY, + ); + + await write(terminal, PI_REDRAW); + await write(terminal, lines("after", 20)); + + // Before beta.302 the viewport stayed pinned at 0 while baseY advanced. + expect(terminal.buffer.active.viewportY).toBe(terminal.buffer.active.baseY); + } finally { + terminal.dispose(); + } +}); + +test("ED3 on the alt screen leaves the normal buffer's scroll position alone", async () => { + const terminal = newTerminal(); + + try { + await write(terminal, lines("line", 40)); + terminal.scrollLines(-15); + const scrolledTo = terminal.buffer.active.viewportY; + + await write( + terminal, + `${ENTER_ALT_SCREEN}${ERASE_SCROLLBACK}${EXIT_ALT_SCREEN}`, + ); + await write(terminal, "more\r\n"); + + // The flag tracks the normal buffer, so an alt-screen ED3 must not clear + // it and yank the user back to the bottom. + expect(terminal.buffer.active.viewportY).toBe(scrolledTo); + expect(terminal.buffer.active.viewportY).toBeLessThan( + terminal.buffer.active.baseY, + ); + } finally { + terminal.dispose(); + } +}); diff --git a/apps/desktop/src/main/lib/tray/index.ts b/apps/desktop/src/main/lib/tray/index.ts index 5bf803d175..187a1339ff 100644 --- a/apps/desktop/src/main/lib/tray/index.ts +++ b/apps/desktop/src/main/lib/tray/index.ts @@ -14,6 +14,7 @@ import { focusMainWindow, quitApp } from "main/index"; import { checkForUpdatesInteractive } from "main/lib/auto-updater"; import { getHostServiceCoordinator, + type HostServiceStatus, type HostServiceStatusEvent, } from "main/lib/host-service-coordinator"; import { menuEmitter } from "main/lib/menu-events"; @@ -120,6 +121,27 @@ async function fetchHostInfo(organizationId: string): Promise { } } +/** Host-service run state as the user reads it in the tray, not the wire value. */ +function statusLabel(status: HostServiceStatus): string { + switch (status) { + case "starting": + return i18n._({ + id: "tray.hostService.status.starting", + message: "starting", + }); + case "running": + return i18n._({ + id: "tray.hostService.status.running", + message: "running", + }); + case "stopped": + return i18n._({ + id: "tray.hostService.status.stopped", + message: "stopped", + }); + } +} + function buildHostServiceSubmenu( orgIds: string[], infos: Map, @@ -148,19 +170,25 @@ function buildHostServiceSubmenu( const status = coordinator.getProcessStatus(orgId); const info = infos.get(orgId); const isRunning = status === "running"; - const label = info?.organizationName ?? `Organization ${orgId.slice(0, 8)}`; + const label = + info?.organizationName ?? + i18n._({ + id: "tray.hostService.unnamedOrganization", + message: "Organization {id}", + values: { id: orgId.slice(0, 8) }, + }); const versionSuffix = info?.version ? ` (v${info.version})` : ""; menuItems.push({ label, enabled: false }); menuItems.push({ - label: ` ${status}${versionSuffix}`, + label: ` ${statusLabel(status)}${versionSuffix}`, enabled: false, }); menuItems.push({ // Enabled in "stopped" too — that's the state where users most need // restart to work (host-service crashed or never came up). Disabled // only while a start is in flight, to avoid racing the pending start. - label: " Restart", + label: ` ${i18n._({ id: "tray.hostService.restart", message: "Restart" })}`, enabled: status !== "starting", click: () => { void (async () => { @@ -182,7 +210,7 @@ function buildHostServiceSubmenu( }, }); menuItems.push({ - label: " Stop", + label: ` ${i18n._({ id: "tray.hostService.stop", message: "Stop" })}`, enabled: isRunning, click: () => { coordinator.stop(orgId); @@ -212,8 +240,12 @@ async function updateTrayMenu(): Promise { const hasActive = orgIds.length > 0; const hostServiceLabel = hasActive - ? `Host Service (${orgIds.length})` - : "Host Service"; + ? i18n._({ + id: "tray.hostService.withCount", + message: "Host Service ({count})", + values: { count: orgIds.length }, + }) + : i18n._({ id: "tray.hostService", message: "Host Service" }); const hostServiceSubmenu = buildHostServiceSubmenu(orgIds, infos); @@ -228,23 +260,29 @@ async function updateTrayMenu(): Promise { click: focusMainWindow, }, { - label: "Settings", + label: i18n._({ id: "tray.settings", message: "Settings" }), click: openSettings, }, { - label: "Check for Updates", + label: i18n._({ + id: "tray.checkForUpdates", + message: "Check for Updates", + }), click: () => { checkForUpdatesInteractive(); }, }, { type: "separator" }, { - label: "Close Superset", + label: i18n._({ id: "tray.closeApp", message: "Close Superset" }), click: () => quitApp(), }, { type: "separator" }, { - label: "Quit Superset Completely", + label: i18n._({ + id: "tray.quitCompletely", + message: "Quit Superset Completely", + }), click: () => { void confirmAndQuitCompletely(); }, @@ -254,6 +292,12 @@ async function updateTrayMenu(): Promise { tray.setContextMenu(menu); } +/** Rebuild the tray menu in place (e.g. after the display language changes). */ +export function refreshTrayMenu(): void { + if (!tray) return; + void updateTrayMenu(); +} + /** Call once after app.whenReady() */ export function initTray(): void { if (tray) { diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index ba138a70d2..ea3b5ea15f 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { join } from "node:path"; import * as Sentry from "@sentry/electron/main"; +import { i18n } from "@superset/i18n"; import { workspaces, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import type { BrowserWindow } from "electron"; @@ -67,8 +68,15 @@ let ipcHandler: ReturnType | null = null; // (tracked by the window registry) rather than a single stored reference. const getWindow = (): BrowserWindow | null => getFocusedOrLastWindow(); +function fallbackWorkspaceName(): string { + return i18n._({ + id: "main.notification.fallbackWorkspace", + message: "Workspace", + }); +} + function getWorkspaceNameFromDb(workspaceId: string | undefined): string { - if (!workspaceId) return "Workspace"; + if (!workspaceId) return fallbackWorkspaceName(); try { const workspace = localDb .select() @@ -85,7 +93,7 @@ function getWorkspaceNameFromDb(workspaceId: string | undefined): string { return getWorkspaceName({ workspace, worktree }); } catch (error) { console.error("[notifications] Failed to get workspace name:", error); - return "Workspace"; + return fallbackWorkspaceName(); } } diff --git a/apps/desktop/src/renderer/commandPalette/core/execute.ts b/apps/desktop/src/renderer/commandPalette/core/execute.ts index f4f4589aff..fafd09f2bb 100644 --- a/apps/desktop/src/renderer/commandPalette/core/execute.ts +++ b/apps/desktop/src/renderer/commandPalette/core/execute.ts @@ -1,3 +1,4 @@ +import { i18n } from "@superset/i18n"; import { errorMessage } from "@superset/i18n/errors"; import { toast } from "@superset/ui/sonner"; import { track } from "renderer/lib/analytics"; @@ -13,6 +14,12 @@ export async function executeCommand( await command.run(context); } catch (error) { const message = errorMessage(error); - toast.error(`Command "${command.title}" failed: ${message}`); + toast.error( + i18n._({ + id: "commandPalette.execute.failed", + message: 'Command "{title}" failed: {message}', + values: { title: i18n._(command.title), message }, + }), + ); } } diff --git a/apps/desktop/src/renderer/commandPalette/core/rankCommands.test.ts b/apps/desktop/src/renderer/commandPalette/core/rankCommands.test.ts index 0dc22fa025..fd5ec23bf5 100644 --- a/apps/desktop/src/renderer/commandPalette/core/rankCommands.test.ts +++ b/apps/desktop/src/renderer/commandPalette/core/rankCommands.test.ts @@ -1,14 +1,18 @@ import { describe, expect, test } from "bun:test"; +import { initI18n } from "@superset/i18n"; import { rankCommands, rankSections, scoreCommand } from "./rankCommands"; import type { Command, CommandSection } from "./types"; +// Command titles are message descriptors resolved through i18n at rank time. +initI18n(); + function command( id: string, title: string, section: Command["section"], keywords?: string[], ): Command { - return { id, title, section, keywords }; + return { id, title: { id: `test.${id}`, message: title }, section, keywords }; } const DELETE_WORKSPACE = command( @@ -23,7 +27,7 @@ const DELETE_WORKSPACE = command( const SECTIONS: CommandSection[] = [ { id: "workspace", - label: "Workspace actions", + label: { id: "test.section.workspace", message: "Workspace actions" }, commands: [ command("workspace.new", "New workspace", "workspace"), command("workspace.quickCreate", "Quick create workspace", "workspace", [ @@ -59,7 +63,7 @@ const SECTIONS: CommandSection[] = [ }, { id: "actions", - label: "Actions", + label: { id: "test.section.actions", message: "Actions" }, commands: [ command("actions.toggleTheme", "Toggle theme", "actions", [ "dark", @@ -110,7 +114,7 @@ const SECTIONS: CommandSection[] = [ }, { id: "navigation", - label: "Navigation", + label: { id: "test.section.navigation", message: "Navigation" }, commands: [ command("nav.settings", "Settings", "navigation"), command("nav.recentlyViewed", "Recently Viewed", "navigation", [ @@ -130,7 +134,7 @@ const SECTIONS: CommandSection[] = [ }, { id: "add-project", - label: "Add project", + label: { id: "test.section.addProject", message: "Add project" }, commands: [ command("addProject.createNew", "Create new project", "add-project", [ "add project", diff --git a/apps/desktop/src/renderer/commandPalette/core/rankCommands.ts b/apps/desktop/src/renderer/commandPalette/core/rankCommands.ts index 02cada4f7b..9dca9c1724 100644 --- a/apps/desktop/src/renderer/commandPalette/core/rankCommands.ts +++ b/apps/desktop/src/renderer/commandPalette/core/rankCommands.ts @@ -1,3 +1,4 @@ +import { i18n } from "@superset/i18n"; import type { Command, CommandSection } from "./types"; const TITLE_EXACT = 100; @@ -31,7 +32,7 @@ export function scoreCommand(command: Searchable, rawQuery: string): number { const query = normalize(rawQuery); if (!query) return 1; - const title = normalize(command.title); + const title = normalize(i18n._(command.title)); if (title === query) return TITLE_EXACT; if (title.startsWith(query)) return TITLE_PREFIX; diff --git a/apps/desktop/src/renderer/commandPalette/core/sections.ts b/apps/desktop/src/renderer/commandPalette/core/sections.ts index 6a3c4b4d8f..ef79ca3f2e 100644 --- a/apps/desktop/src/renderer/commandPalette/core/sections.ts +++ b/apps/desktop/src/renderer/commandPalette/core/sections.ts @@ -1,14 +1,25 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; import { env } from "renderer/env.renderer"; import type { CommandContext, SectionId } from "./types"; const BASE: SectionId[] = ["actions", "navigation", "add-project"]; -export const SECTION_LABELS: Record = { - workspace: "Workspace actions", - actions: "Actions", - navigation: "Navigation", - "add-project": "Add project", - dev: "Dev", +export const SECTION_LABELS: Record = { + workspace: msg({ + id: "commandPalette.section.workspace", + message: "Workspace actions", + }), + actions: msg({ id: "commandPalette.section.actions", message: "Actions" }), + navigation: msg({ + id: "commandPalette.section.navigation", + message: "Navigation", + }), + "add-project": msg({ + id: "commandPalette.section.addProject", + message: "Add project", + }), + dev: msg({ id: "commandPalette.section.dev", message: "Dev" }), }; export function resolveSectionOrder(context: CommandContext): SectionId[] { diff --git a/apps/desktop/src/renderer/commandPalette/core/types.ts b/apps/desktop/src/renderer/commandPalette/core/types.ts index 0b24982bb4..46faa1fb4e 100644 --- a/apps/desktop/src/renderer/commandPalette/core/types.ts +++ b/apps/desktop/src/renderer/commandPalette/core/types.ts @@ -1,3 +1,4 @@ +import type { MessageDescriptor } from "@lingui/core"; import type { ExternalApp } from "@superset/local-db"; import type { ElementType } from "react"; import type { HotkeyId } from "renderer/hotkeys/registry"; @@ -36,7 +37,7 @@ export interface CommandContext { export interface Command { id: string; - title: string; + title: MessageDescriptor; section: SectionId; icon?: ElementType<{ className?: string }>; iconUrl?: string; @@ -55,6 +56,6 @@ export interface CommandProvider { export interface CommandSection { id: SectionId; - label: string; + label: MessageDescriptor; commands: Command[]; } diff --git a/apps/desktop/src/renderer/commandPalette/modules/actions/commands.tsx b/apps/desktop/src/renderer/commandPalette/modules/actions/commands.tsx index 8d9c7980f9..03f73118e3 100644 --- a/apps/desktop/src/renderer/commandPalette/modules/actions/commands.tsx +++ b/apps/desktop/src/renderer/commandPalette/modules/actions/commands.tsx @@ -1,3 +1,5 @@ +import { msg } from "@lingui/core/macro"; +import { i18n } from "@superset/i18n"; import { errorMessage } from "@superset/i18n/errors"; import type { DesktopNotice } from "@superset/shared/desktop-notices"; import { toast } from "@superset/ui/sonner"; @@ -116,7 +118,10 @@ export const actionsProvider: CommandProvider = { const commands: Command[] = [ { id: "actions.toggleTheme", - title: "Toggle theme", + title: msg({ + id: "commandPalette.actions.toggleTheme", + message: "Toggle theme", + }), section: "actions", icon: PaletteIcon, keywords: ["dark", "light", "appearance", "color"], @@ -126,8 +131,14 @@ export const actionsProvider: CommandProvider = { { id: "actions.toggleNotificationSounds", title: context.notificationSoundsMuted - ? "Unmute notifications" - : "Mute notifications", + ? msg({ + id: "commandPalette.actions.unmuteNotifications", + message: "Unmute notifications", + }) + : msg({ + id: "commandPalette.actions.muteNotifications", + message: "Mute notifications", + }), section: "actions", icon: context.notificationSoundsMuted ? BellIcon : BellOffIcon, keywords: ["dnd", "silence", "notifications", "ringtone"], @@ -138,7 +149,10 @@ export const actionsProvider: CommandProvider = { checkResourcesCommand, { id: "actions.toggleLeftSidebar", - title: "Toggle left sidebar", + title: msg({ + id: "commandPalette.actions.toggleLeftSidebar", + message: "Toggle left sidebar", + }), section: "actions", icon: PanelLeftIcon, hotkeyId: "TOGGLE_WORKSPACE_SIDEBAR", @@ -149,7 +163,10 @@ export const actionsProvider: CommandProvider = { if (context.workspace) { commands.push({ id: "actions.toggleRightSidebar", - title: "Toggle right sidebar", + title: msg({ + id: "commandPalette.actions.toggleRightSidebar", + message: "Toggle right sidebar", + }), section: "actions", icon: PanelRightIcon, hotkeyId: "TOGGLE_SIDEBAR", @@ -160,7 +177,10 @@ export const actionsProvider: CommandProvider = { commands.push( { id: "actions.showShortcuts", - title: "Show keyboard shortcuts", + title: msg({ + id: "commandPalette.actions.showShortcuts", + message: "Show keyboard shortcuts", + }), section: "actions", icon: KeyboardIcon, hotkeyId: "SHOW_HOTKEYS", @@ -169,7 +189,10 @@ export const actionsProvider: CommandProvider = { }, { id: "actions.checkUpdates", - title: "Check for updates", + title: msg({ + id: "commandPalette.actions.checkUpdates", + message: "Check for updates", + }), section: "actions", icon: RefreshCwIcon, keywords: ["update", "upgrade"], @@ -178,13 +201,22 @@ export const actionsProvider: CommandProvider = { await electronTrpcClient.autoUpdate.checkInteractive.mutate(); } catch (error) { const message = errorMessage(error); - toast.error(`Failed to check for updates: ${message}`); + toast.error( + i18n._({ + id: "commandPalette.actions.checkUpdatesFailed", + message: "Failed to check for updates: {message}", + values: { message }, + }), + ); } }, }, { id: "actions.newWindow", - title: "New window", + title: msg({ + id: "commandPalette.actions.newWindow", + message: "New window", + }), section: "actions", icon: AppWindowIcon, keywords: ["open", "multi"], @@ -193,7 +225,13 @@ export const actionsProvider: CommandProvider = { await electronTrpcClient.window.openNew.mutate(); } catch (error) { const message = errorMessage(error); - toast.error(`Failed to open new window: ${message}`); + toast.error( + i18n._({ + id: "commandPalette.actions.newWindowFailed", + message: "Failed to open new window: {message}", + values: { message }, + }), + ); } }, }, @@ -204,7 +242,10 @@ export const actionsProvider: CommandProvider = { commands.push( { id: "dev.simulateUpdateDownloading", - title: "Simulate update: downloading", + title: { + id: "commandPalette.dev", + message: "Simulate update: downloading", + }, section: "dev", icon: DownloadIcon, keywords: ["update", "dev", "simulate", "test"], @@ -214,7 +255,10 @@ export const actionsProvider: CommandProvider = { }, { id: "dev.simulateUpdateReady", - title: "Simulate update: ready", + title: { + id: "commandPalette.dev", + message: "Simulate update: ready", + }, section: "dev", icon: CircleCheckIcon, keywords: ["update", "dev", "simulate", "test"], @@ -224,7 +268,10 @@ export const actionsProvider: CommandProvider = { }, { id: "dev.simulateUpdateError", - title: "Simulate update: error", + title: { + id: "commandPalette.dev", + message: "Simulate update: error", + }, section: "dev", icon: TriangleAlertIcon, keywords: ["update", "dev", "simulate", "test"], @@ -234,7 +281,7 @@ export const actionsProvider: CommandProvider = { }, { id: "dev.previewNoticeInfo", - title: "Preview notice: info", + title: { id: "commandPalette.dev", message: "Preview notice: info" }, section: "dev", icon: InfoIcon, keywords: PREVIEW_KEYWORDS, @@ -242,7 +289,10 @@ export const actionsProvider: CommandProvider = { }, { id: "dev.previewNoticeWarning", - title: "Preview notice: warning", + title: { + id: "commandPalette.dev", + message: "Preview notice: warning", + }, section: "dev", icon: TriangleAlertIcon, keywords: PREVIEW_KEYWORDS, @@ -250,7 +300,10 @@ export const actionsProvider: CommandProvider = { }, { id: "dev.previewNoticeBlocking", - title: "Preview notice: blocking (update required)", + title: { + id: "commandPalette.dev", + message: "Preview notice: blocking (update required)", + }, section: "dev", icon: OctagonAlertIcon, keywords: PREVIEW_KEYWORDS, @@ -258,7 +311,10 @@ export const actionsProvider: CommandProvider = { }, { id: "dev.previewNoticePostUpdate", - title: "Preview notice: post-update announcement", + title: { + id: "commandPalette.dev", + message: "Preview notice: post-update announcement", + }, section: "dev", icon: MegaphoneIcon, keywords: PREVIEW_KEYWORDS, @@ -266,7 +322,10 @@ export const actionsProvider: CommandProvider = { }, { id: "dev.previewNoticePreUpdate", - title: "Preview notice: pre-update confirm", + title: { + id: "commandPalette.dev", + message: "Preview notice: pre-update confirm", + }, section: "dev", icon: DownloadIcon, keywords: PREVIEW_KEYWORDS, @@ -282,7 +341,7 @@ export const actionsProvider: CommandProvider = { }, { id: "dev.clearNoticePreview", - title: "Clear notice preview", + title: { id: "commandPalette.dev", message: "Clear notice preview" }, section: "dev", icon: XIcon, keywords: PREVIEW_KEYWORDS, @@ -290,7 +349,10 @@ export const actionsProvider: CommandProvider = { }, { id: "dev.previewStarNagToast", - title: "Preview: GitHub star nag toast", + title: { + id: "commandPalette.dev", + message: "Preview: GitHub star nag toast", + }, section: "dev", icon: StarIcon, keywords: ["star", "github", "nag", "dev", "preview", "test"], @@ -302,7 +364,10 @@ export const actionsProvider: CommandProvider = { }, { id: "dev.resetStarNagState", - title: "Reset GitHub star nag state", + title: { + id: "commandPalette.dev", + message: "Reset GitHub star nag state", + }, section: "dev", icon: RefreshCwIcon, keywords: ["star", "github", "nag", "dev", "reset", "test"], diff --git a/apps/desktop/src/renderer/commandPalette/modules/addProject/commands.tsx b/apps/desktop/src/renderer/commandPalette/modules/addProject/commands.tsx index 5cc6344393..f67e805ad6 100644 --- a/apps/desktop/src/renderer/commandPalette/modules/addProject/commands.tsx +++ b/apps/desktop/src/renderer/commandPalette/modules/addProject/commands.tsx @@ -1,3 +1,4 @@ +import { msg } from "@lingui/core/macro"; import { FolderInputIcon, FolderPlusIcon, @@ -14,7 +15,10 @@ export const addProjectProvider: CommandProvider = { const commands: Command[] = [ { id: "addProject.createNew", - title: "Create new project", + title: msg({ + id: "commandPalette.addProject.createNew", + message: "Create new project", + }), section: "add-project", icon: FolderPlusIcon, keywords: ["add project", "new", "blank", "empty", "folder", "init"], @@ -24,7 +28,10 @@ export const addProjectProvider: CommandProvider = { }, { id: "addProject.cloneFromUrl", - title: "Clone from URL", + title: msg({ + id: "commandPalette.addProject.cloneFromUrl", + message: "Clone from URL", + }), section: "add-project", icon: PlusIcon, keywords: ["add project", "repository", "repo", "git", "clone"], @@ -34,7 +41,10 @@ export const addProjectProvider: CommandProvider = { }, { id: "addProject.openFromFolder", - title: "Open from folder", + title: msg({ + id: "commandPalette.addProject.openFromFolder", + message: "Open from folder", + }), section: "add-project", icon: FolderInputIcon, keywords: ["add project", "import", "local", "directory"], @@ -42,7 +52,10 @@ export const addProjectProvider: CommandProvider = { }, { id: "addProject.startFromTemplate", - title: "Start from a template", + title: msg({ + id: "commandPalette.addProject.startFromTemplate", + message: "Start from a template", + }), section: "add-project", icon: LayoutTemplateIcon, keywords: ["add project", "new", "gallery", "starter"], diff --git a/apps/desktop/src/renderer/commandPalette/modules/navigation/commands.tsx b/apps/desktop/src/renderer/commandPalette/modules/navigation/commands.tsx index c6fdf9a7c8..75e4d929c0 100644 --- a/apps/desktop/src/renderer/commandPalette/modules/navigation/commands.tsx +++ b/apps/desktop/src/renderer/commandPalette/modules/navigation/commands.tsx @@ -1,3 +1,4 @@ +import { msg } from "@lingui/core/macro"; import { BookOpenIcon, HistoryIcon, SettingsIcon } from "lucide-react"; import { LuLayers } from "react-icons/lu"; import type { Command, CommandProvider } from "../../core/types"; @@ -11,7 +12,7 @@ export const navigationProvider: CommandProvider = { const commands: Command[] = [ { id: "nav.settings", - title: "Settings", + title: msg({ id: "commandPalette.nav.settings", message: "Settings" }), section: "navigation", icon: SettingsIcon, hotkeyId: "OPEN_SETTINGS", @@ -20,7 +21,10 @@ export const navigationProvider: CommandProvider = { }, { id: "nav.recentlyViewed", - title: "Recently Viewed", + title: msg({ + id: "commandPalette.nav.recentlyViewed", + message: "Recently Viewed", + }), section: "navigation", icon: HistoryIcon, keywords: ["history", "recent", "back"], @@ -28,7 +32,10 @@ export const navigationProvider: CommandProvider = { }, { id: "nav.workspaces", - title: "Workspaces", + title: msg({ + id: "commandPalette.nav.workspaces", + message: "Workspaces", + }), section: "navigation", icon: LuLayers, keywords: ["workspace", "project", "repo", "repository", "switch"], @@ -36,7 +43,10 @@ export const navigationProvider: CommandProvider = { }, { id: "nav.docs", - title: "Open documentation", + title: msg({ + id: "commandPalette.nav.openDocs", + message: "Open documentation", + }), section: "navigation", icon: BookOpenIcon, keywords: ["docs", "help"], diff --git a/apps/desktop/src/renderer/commandPalette/modules/openIn/commands.ts b/apps/desktop/src/renderer/commandPalette/modules/openIn/commands.ts index a6d1ff63fb..437e89225e 100644 --- a/apps/desktop/src/renderer/commandPalette/modules/openIn/commands.ts +++ b/apps/desktop/src/renderer/commandPalette/modules/openIn/commands.ts @@ -1,3 +1,5 @@ +import { msg } from "@lingui/core/macro"; +import { i18n } from "@superset/i18n"; import { errorMessage } from "@superset/i18n/errors"; import type { ExternalApp } from "@superset/local-db"; import { toast } from "@superset/ui/sonner"; @@ -25,7 +27,7 @@ async function resolvePath(context: CommandContext): Promise { hostServiceStatus: context.hostServiceStatus, machineId: context.localMachineId, }, - { action: "resolve the workspace path" }, + { action: "resolveWorkspacePath" }, ); return null; } @@ -35,13 +37,24 @@ async function resolvePath(context: CommandContext): Promise { context.activeHostUrl, ).workspace.get.query({ id: context.workspace.id }); if (!workspace?.worktreePath) { - toast.error("Workspace path is not available"); + toast.error( + i18n._({ + id: "commandPalette.openIn.pathUnavailable", + message: "Workspace path is not available", + }), + ); return null; } return workspace.worktreePath; } catch (error) { const message = errorMessage(error); - toast.error(`Failed to resolve workspace path: ${message}`); + toast.error( + i18n._({ + id: "commandPalette.openIn.resolvePathFailed", + message: "Failed to resolve workspace path: {message}", + values: { message }, + }), + ); return null; } } @@ -65,7 +78,13 @@ async function openIn( } } catch (error) { const message = errorMessage(error); - toast.error(`Failed to open in ${app}: ${message}`); + toast.error( + i18n._({ + id: "commandPalette.openIn.openFailed", + message: "Failed to open in {app}: {message}", + values: { app, message }, + }), + ); } } @@ -89,7 +108,10 @@ export const openInProvider: CommandProvider = { const submenuChildren: Command[] = APP_OPTIONS.map((option) => ({ id: `openIn.${option.id}`, - title: option.label, + title: { + id: `commandPalette.openIn.app.${option.id}`, + message: option.label, + }, section: "workspace", iconUrl: option.darkIcon, keywords: ["editor", option.id, option.label], @@ -109,7 +131,13 @@ export const openInProvider: CommandProvider = { if (preferredOption) { commands.push({ id: `openIn.preferred:${preferredOption.id}`, - title: `Open in ${preferredOption.label}`, + title: { + ...msg({ + id: "commandPalette.openIn.preferred", + message: "Open in {app}", + }), + values: { app: preferredOption.label }, + }, section: "workspace", iconUrl: preferredOption.darkIcon, hotkeyId: "OPEN_IN_APP", @@ -120,7 +148,7 @@ export const openInProvider: CommandProvider = { commands.push({ id: "openIn.menu", - title: "Open in…", + title: msg({ id: "commandPalette.openIn.menu", message: "Open in…" }), section: "workspace", icon: ArrowUpRightIcon, keywords: ["editor", "finder", "cursor", "vscode"], diff --git a/apps/desktop/src/renderer/commandPalette/modules/resources/commands.tsx b/apps/desktop/src/renderer/commandPalette/modules/resources/commands.tsx index 900825f93b..441b2f8f4d 100644 --- a/apps/desktop/src/renderer/commandPalette/modules/resources/commands.tsx +++ b/apps/desktop/src/renderer/commandPalette/modules/resources/commands.tsx @@ -1,3 +1,4 @@ +import { msg } from "@lingui/core/macro"; import { HiOutlineChartBar, HiOutlineCpuChip } from "react-icons/hi2"; import { getUsageLastSection, @@ -16,7 +17,7 @@ import type { Command } from "../../core/types"; */ export const openUsageCommand: Command = { id: "usage.open", - title: "Usage", + title: msg({ id: "commandPalette.resources.usage", message: "Usage" }), section: "actions", icon: HiOutlineChartBar, keywords: [ @@ -41,7 +42,10 @@ export const openUsageCommand: Command = { */ export const checkResourcesCommand: Command = { id: "resources.check", - title: "Check resources", + title: msg({ + id: "commandPalette.resources.checkResources", + message: "Check resources", + }), section: "actions", icon: HiOutlineCpuChip, hotkeyId: "CHECK_RESOURCES", diff --git a/apps/desktop/src/renderer/commandPalette/modules/settings/commands.ts b/apps/desktop/src/renderer/commandPalette/modules/settings/commands.ts index dc27defa5b..efa6ab1596 100644 --- a/apps/desktop/src/renderer/commandPalette/modules/settings/commands.ts +++ b/apps/desktop/src/renderer/commandPalette/modules/settings/commands.ts @@ -1,3 +1,5 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; import { BeakerIcon, BellIcon, @@ -25,7 +27,7 @@ import type { Command } from "../../core/types"; interface SettingsTab { id: string; - title: string; + title: MessageDescriptor; path: string; icon: LucideIcon; keywords?: string[]; @@ -34,108 +36,175 @@ interface SettingsTab { const TABS: SettingsTab[] = [ { id: "account", - title: "Account", + title: msg({ + id: "commandPalette.settingsTab.account", + message: "Account", + }), path: "/settings/account", icon: UserIcon, }, { id: "appearance", - title: "Appearance", + title: msg({ + id: "commandPalette.settingsTab.appearance", + message: "Appearance", + }), path: "/settings/appearance", icon: PaletteIcon, keywords: ["theme", "color"], }, { id: "behavior", - title: "Behavior", + title: msg({ + id: "commandPalette.settingsTab.behavior", + message: "Behavior", + }), path: "/settings/behavior", icon: SlidersIcon, }, { id: "models", - title: "Models", + title: msg({ id: "commandPalette.settingsTab.models", message: "Models" }), path: "/settings/models", icon: CpuIcon, keywords: ["ai", "llm"], }, { id: "terminal", - title: "Terminal", + title: msg({ + id: "commandPalette.settingsTab.terminal", + message: "Terminal", + }), path: "/settings/terminal", icon: TerminalIcon, keywords: ["terminal scripts", "scripts", "presets", "commands"], }, - { id: "git", title: "Git", path: "/settings/git", icon: GitBranchIcon }, + { + id: "git", + title: msg({ id: "commandPalette.settingsTab.git", message: "Git" }), + path: "/settings/git", + icon: GitBranchIcon, + }, { id: "experimental", - title: "Experimental", + title: msg({ + id: "commandPalette.settingsTab.experimental", + message: "Experimental", + }), path: "/settings/experimental", icon: BeakerIcon, }, { id: "integrations", - title: "Integrations", + title: msg({ + id: "commandPalette.settingsTab.integrations", + message: "Integrations", + }), path: "/settings/integrations", icon: LinkIcon, }, { id: "organization", - title: "Organization", + title: msg({ + id: "commandPalette.settingsTab.organization", + message: "Organization", + }), path: "/settings/organization", icon: BuildingIcon, }, - { id: "teams", title: "Teams", path: "/settings/teams", icon: UsersIcon }, + { + id: "teams", + title: msg({ id: "commandPalette.settingsTab.teams", message: "Teams" }), + path: "/settings/teams", + icon: UsersIcon, + }, { id: "keyboard", - title: "Keyboard shortcuts", + title: msg({ + id: "commandPalette.settingsTab.keyboard", + message: "Keyboard shortcuts", + }), path: "/settings/keyboard", icon: KeyboardIcon, keywords: ["hotkeys", "shortcuts"], }, - { id: "links", title: "Links", path: "/settings/links", icon: BookmarkIcon }, + { + id: "links", + title: msg({ id: "commandPalette.settingsTab.links", message: "Links" }), + path: "/settings/links", + icon: BookmarkIcon, + }, { id: "permissions", - title: "Permissions", + title: msg({ + id: "commandPalette.settingsTab.permissions", + message: "Permissions", + }), path: "/settings/permissions", icon: ShieldIcon, }, - { id: "hosts", title: "Hosts", path: "/settings/hosts", icon: ServerIcon }, + { + id: "hosts", + title: msg({ id: "commandPalette.settingsTab.hosts", message: "Hosts" }), + path: "/settings/hosts", + icon: ServerIcon, + }, { id: "projects", - title: "Projects", + title: msg({ + id: "commandPalette.settingsTab.projects", + message: "Projects", + }), path: "/settings/projects", icon: FolderIcon, }, { id: "ringtones", - title: "Ringtones", + title: msg({ + id: "commandPalette.settingsTab.ringtones", + message: "Ringtones", + }), path: "/settings/ringtones", icon: BellIcon, }, { id: "usage", - title: "Usage", + title: msg({ id: "commandPalette.settingsTab.usage", message: "Usage" }), path: "/settings/usage", icon: ChartBarIcon, keywords: ["tokens", "cost", "quota", "cpu", "memory", "resources"], }, { id: "billing", - title: "Billing", + title: msg({ + id: "commandPalette.settingsTab.billing", + message: "Billing", + }), path: "/settings/billing", icon: CreditCardIcon, }, { id: "security", - title: "Remote Workspaces", + title: msg({ + id: "commandPalette.settingsTab.security", + message: "Remote Access", + }), path: "/settings/security", icon: KeyRoundIcon, keywords: ["security", "relay"], }, - { id: "agents", title: "Agents", path: "/settings/agents", icon: WrenchIcon }, + { + id: "agents", + title: msg({ id: "commandPalette.settingsTab.agents", message: "Agents" }), + path: "/settings/agents", + icon: WrenchIcon, + }, { id: "api-keys", - title: "API keys", + title: msg({ + id: "commandPalette.settingsTab.apiKeys", + message: "API keys", + }), path: "/settings/api-keys", icon: KeyRoundIcon, keywords: ["token"], diff --git a/apps/desktop/src/renderer/commandPalette/modules/workspace/commands.tsx b/apps/desktop/src/renderer/commandPalette/modules/workspace/commands.tsx index 32d32eff6c..5351e3deb5 100644 --- a/apps/desktop/src/renderer/commandPalette/modules/workspace/commands.tsx +++ b/apps/desktop/src/renderer/commandPalette/modules/workspace/commands.tsx @@ -1,3 +1,4 @@ +import { msg } from "@lingui/core/macro"; import { ArchiveIcon, FileIcon, @@ -21,7 +22,10 @@ export const workspaceProvider: CommandProvider = { // v2 dashboard view (e.g. the workspaces list), not just an open one. const quickCreate: Command = { id: "workspace.quickCreate", - title: "Quick create workspace", + title: msg({ + id: "commandPalette.workspace.quickCreate", + message: "Quick create workspace", + }), section: "workspace", icon: ZapIcon, hotkeyId: "QUICK_CREATE_WORKSPACE", @@ -40,7 +44,10 @@ export const workspaceProvider: CommandProvider = { const commands: Command[] = [ { id: "workspace.new", - title: "New workspace", + title: msg({ + id: "commandPalette.workspace.new", + message: "New workspace", + }), section: "workspace", icon: PlusIcon, hotkeyId: "NEW_WORKSPACE", @@ -50,7 +57,10 @@ export const workspaceProvider: CommandProvider = { quickCreate, { id: "files.quickOpen", - title: "Search files", + title: msg({ + id: "commandPalette.workspace.searchFiles", + message: "Search files", + }), section: "workspace", icon: FileIcon, keywords: ["file picker", "quick open"], @@ -62,7 +72,10 @@ export const workspaceProvider: CommandProvider = { }, { id: "workspace.linkTask", - title: "Link task", + title: msg({ + id: "commandPalette.workspace.linkTask", + message: "Link task", + }), section: "workspace", icon: LinkIcon, keywords: ["issue", "linear"], @@ -73,7 +86,10 @@ export const workspaceProvider: CommandProvider = { if (workspace.projectId) { commands.push({ id: `workspace.removeFromSidebar:${workspace.id}`, - title: "Remove from sidebar", + title: msg({ + id: "commandPalette.workspace.removeFromSidebar", + message: "Remove from sidebar", + }), section: "workspace", icon: ArchiveIcon, keywords: ["hide"], @@ -90,7 +106,10 @@ export const workspaceProvider: CommandProvider = { if (!isMain) { commands.push({ id: `workspace.delete:${workspace.id}`, - title: "Delete workspace", + title: msg({ + id: "commandPalette.workspace.delete", + message: "Delete workspace", + }), section: "workspace", icon: Trash2Icon, keywords: ["archive", "remove", "close"], diff --git a/apps/desktop/src/renderer/commandPalette/ui/CommandItemRow/CommandItemRow.tsx b/apps/desktop/src/renderer/commandPalette/ui/CommandItemRow/CommandItemRow.tsx index d0c8a37605..45a28e091a 100644 --- a/apps/desktop/src/renderer/commandPalette/ui/CommandItemRow/CommandItemRow.tsx +++ b/apps/desktop/src/renderer/commandPalette/ui/CommandItemRow/CommandItemRow.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { CommandItem, CommandShortcut } from "@superset/ui/command"; import { useHotkeyDisplay } from "renderer/hotkeys/hooks/useHotkeyDisplay"; import type { Command } from "../../core/types"; @@ -8,6 +9,7 @@ interface CommandItemRowProps { } export function CommandItemRow({ command, onSelect }: CommandItemRowProps) { + const { i18n } = useLingui(); const display = useHotkeyDisplay(command.hotkeyId ?? ""); const Icon = command.icon; const hasShortcut = @@ -23,7 +25,7 @@ export function CommandItemRow({ command, onSelect }: CommandItemRowProps) { ) : Icon ? ( ) : null} - {command.title} + {i18n._(command.title)} {hasShortcut ? {display.text} : null} ); diff --git a/apps/desktop/src/renderer/commandPalette/ui/CommandListView/CommandListView.tsx b/apps/desktop/src/renderer/commandPalette/ui/CommandListView/CommandListView.tsx index 489b15c831..76207e1b72 100644 --- a/apps/desktop/src/renderer/commandPalette/ui/CommandListView/CommandListView.tsx +++ b/apps/desktop/src/renderer/commandPalette/ui/CommandListView/CommandListView.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { CommandEmpty, CommandGroup, CommandList } from "@superset/ui/command"; import { useMemo } from "react"; import { useCommandContext } from "../../core/ContextProvider"; @@ -13,6 +13,7 @@ interface CommandListViewProps { } export function CommandListView({ query, onSelect }: CommandListViewProps) { + const { i18n } = useLingui(); const context = useCommandContext(); const sections = useActiveCommands(context); const ranked = useMemo( @@ -26,7 +27,7 @@ export function CommandListView({ query, onSelect }: CommandListViewProps) { No commands found. {ranked.map((section) => ( - + {section.commands.map((command) => ( s.open); const setOpen = useFrameStackStore((s) => s.setOpen); const frames = useFrameStackStore((s) => s.frames); @@ -103,15 +104,22 @@ export function CommandPalette() { if (!open) setQuery(""); }, [open]); - const placeholder = currentFrame - ? `Search in ${currentFrame.command.title}…` - : "Type a command or search…"; + const frameTitle = currentFrame ? i18n._(currentFrame.command.title) : null; + const placeholder = frameTitle + ? t({ + id: "commandPalette.input.searchIn", + message: `Search in ${frameTitle}…`, + }) + : t({ + id: "commandPalette.input.placeholder", + message: "Type a command or search…", + }); const backButton = (
- - System - + {systemLabel} {activeThemeId === SYSTEM_THEME_ID ? ( ✓ ) : null} @@ -109,21 +125,21 @@ export function ThemeFrame() { {showSystem && hasThemeGroup && } { void navigateToV2Workspace(workspaceId, navigate); diff --git a/apps/desktop/src/renderer/components/AgentSelect/AgentSelect.tsx b/apps/desktop/src/renderer/components/AgentSelect/AgentSelect.tsx index 3c03992344..3f8c1eb8a6 100644 --- a/apps/desktop/src/renderer/components/AgentSelect/AgentSelect.tsx +++ b/apps/desktop/src/renderer/components/AgentSelect/AgentSelect.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { Select, SelectContent, @@ -52,10 +53,14 @@ export function AgentSelect({ contentClassName, iconClassName = "size-3.5 object-contain", allowNone = false, - noneLabel = "No agent", + noneLabel, noneValue, }: AgentSelectProps) { + const { t } = useLingui(); const navigate = useNavigate(); + const resolvedNoneLabel = + noneLabel ?? + t({ id: "components.agentSelect.noAgent", message: "No agent" }); const isDark = useIsDarkTheme(); const selectableIds = new Set(agents.map((agent) => agent.id)); const selectedValue = @@ -86,7 +91,7 @@ export function AgentSelect({ {allowNone && noneValue != null && ( - {noneLabel} + {resolvedNoneLabel} )} {agents.map((agent) => { const icon = getPresetIcon(agent.iconId ?? agent.id, isDark); diff --git a/apps/desktop/src/renderer/components/AnimatedStarButton/AnimatedStarButton.tsx b/apps/desktop/src/renderer/components/AnimatedStarButton/AnimatedStarButton.tsx index 6113cf4ebc..88a41cd631 100644 --- a/apps/desktop/src/renderer/components/AnimatedStarButton/AnimatedStarButton.tsx +++ b/apps/desktop/src/renderer/components/AnimatedStarButton/AnimatedStarButton.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { cn } from "@superset/ui/utils"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { Star } from "lucide-react"; @@ -68,6 +69,7 @@ export function AnimatedStarButton({ className, compact, }: AnimatedStarButtonProps) { + const { t } = useLingui(); const [particles, setParticles] = useState([]); const [justStarred, setJustStarred] = useState(false); const prevStateRef = useRef(state); @@ -95,7 +97,17 @@ export function AnimatedStarButton({ }, [state, prefersReducedMotion]); const isStarred = state === "starred"; - const label = isStarred ? "Starred" : busy ? "Starring…" : "Star on GitHub"; + const label = isStarred + ? t({ id: "components.animatedStarButton.starred", message: "Starred" }) + : busy + ? t({ + id: "components.animatedStarButton.starring", + message: "Starring…", + }) + : t({ + id: "components.animatedStarButton.starOnGithub", + message: "Star on GitHub", + }); return ( ); })} @@ -242,7 +269,7 @@ export const SlashCommand = Extension.create({ }, items: ({ query }: { query: string }) => { return COMMANDS.filter((item) => - item.title.toLowerCase().includes(query.toLowerCase()), + i18n._(item.title).toLowerCase().includes(query.toLowerCase()), ); }, render: () => { diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx index 99f98d4325..a8fcdf745c 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { LuImageOff } from "react-icons/lu"; /** @@ -46,11 +46,15 @@ interface SafeImageProps { * malicious markdown content. */ export function SafeImage({ src, alt, className }: SafeImageProps) { + const { t } = useLingui(); if (!isSafeImageSrc(src)) { return (
diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/BubbleMenuToolbar/BubbleMenuToolbar.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/BubbleMenuToolbar/BubbleMenuToolbar.tsx index 293b13b702..efbea48397 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/BubbleMenuToolbar/BubbleMenuToolbar.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/BubbleMenuToolbar/BubbleMenuToolbar.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import type { Editor } from "@tiptap/core"; import { useCallback, useEffect, useRef, useState } from "react"; import { @@ -47,6 +47,7 @@ function ToolbarButton({ } function HeadingDropdown({ editor }: { editor: Editor }) { + const { t } = useLingui(); const [open, setOpen] = useState(false); const containerRef = useRef(null); @@ -72,7 +73,10 @@ function HeadingDropdown({ editor }: { editor: Editor }) { ? 3 : 0; - const label = currentLevel === 0 ? "Text" : `H${currentLevel}`; + const label = + currentLevel === 0 + ? t({ id: "components.bubbleMenuToolbar.textStyleText", message: "Text" }) + : `H${currentLevel}`; const handleSelect = useCallback( (level: number, e: React.MouseEvent) => { @@ -95,7 +99,10 @@ function HeadingDropdown({ editor }: { editor: Editor }) {
@@ -338,7 +377,10 @@ export function BubbleMenuToolbar({ editor }: BubbleMenuToolbarProps) {
{ prevent(e); @@ -349,7 +391,10 @@ export function BubbleMenuToolbar({ editor }: BubbleMenuToolbarProps) { { prevent(e); @@ -360,7 +405,10 @@ export function BubbleMenuToolbar({ editor }: BubbleMenuToolbarProps) { { prevent(e); @@ -371,7 +419,10 @@ export function BubbleMenuToolbar({ editor }: BubbleMenuToolbarProps) { { prevent(e); @@ -382,7 +433,10 @@ export function BubbleMenuToolbar({ editor }: BubbleMenuToolbarProps) { { prevent(e); @@ -395,7 +449,10 @@ export function BubbleMenuToolbar({ editor }: BubbleMenuToolbarProps) {
{ prevent(e); @@ -411,7 +468,10 @@ export function BubbleMenuToolbar({ editor }: BubbleMenuToolbarProps) { { prevent(e); @@ -422,7 +482,10 @@ export function BubbleMenuToolbar({ editor }: BubbleMenuToolbarProps) { { prevent(e); diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index d054e4fe06..9213b91148 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { errorMessage } from "@superset/i18n/errors"; import { PromptInputProvider, @@ -47,6 +47,7 @@ function PromptInputResetSync() { export function NewWorkspaceModal() { const isOpen = useNewWorkspaceModalOpen(); + const { t } = useLingui(); const closeModal = useCloseNewWorkspaceModal(); const { openNew } = useOpenProject(); const openEmptyProject = useOpenEmptyProjectModal(); @@ -61,9 +62,13 @@ export function NewWorkspaceModal() { try { await openNew(); } catch (error) { - toast.error("Failed to open project", { - description: errorMessage(error, "An unknown error occurred"), - }); + toast.error( + t({ + id: "components.newWorkspaceModal.openProjectFailed", + message: "Failed to open project", + }), + { description: errorMessage(error, "An unknown error occurred") }, + ); } }; diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx index 8bbfbc37b1..684686c10c 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { errorMessage, rawErrorMessage } from "@superset/i18n/errors"; import type { AgentLaunchRequest } from "@superset/shared/agent-launch"; import { buildPromptAgentLaunchRequest } from "@superset/shared/agent-launch-request"; @@ -187,6 +187,7 @@ function ProjectPickerPill({ onImportRepo: () => void; onNewProject: () => void; }) { + const { t } = useLingui(); const [open, setOpen] = useState(false); return ( @@ -222,7 +223,12 @@ function ProjectPickerPill({ onWheel={(event) => event.stopPropagation()} > - + @@ -317,6 +323,7 @@ function CompareBaseBranchPickerInline({ onOpenWorktree: (action: OpenableWorktreeAction) => void; onOpenActiveWorkspace: (workspaceId: string) => void; }) { + const { t } = useLingui(); const [open, setOpen] = useState(false); const [branchSearch, setBranchSearch] = useState(""); const [filterMode, setFilterMode] = useState<"all" | "worktrees">("all"); @@ -412,7 +419,10 @@ function CompareBaseBranchPickerInline({ })}
@@ -579,6 +589,7 @@ function PromptGroupInner({ onImportRepo, onNewProject, }: PromptGroupProps) { + const { t } = useLingui(); const navigate = useNavigate(); const modKey = PLATFORM === "mac" ? "⌘" : "Ctrl"; const isNewWorkspaceModalOpen = useNewWorkspaceModalOpen(); @@ -769,7 +780,12 @@ function PromptGroupInner({ const handleCreate = useCallback( async (preConvertedFiles?: ConvertedFile[]) => { if (!projectId) { - toast.error("Select a project first"); + toast.error( + t({ + id: "components.promptGroup.selectProjectFirst", + message: "Select a project first", + }), + ); return; } @@ -826,7 +842,12 @@ function PromptGroupInner({ const message = rawErrorMessage(error); if (message.includes("timeout")) { console.warn("[PromptGroup] AI generation timeout"); - toast.info("Using random branch name (AI generation timed out)"); + toast.info( + t({ + id: "components.promptGroup.branchNameTimeout", + message: "Using random branch name (AI generation timed out)", + }), + ); } else if ( message.toLowerCase().includes("auth") || message.includes("401") || @@ -834,14 +855,22 @@ function PromptGroupInner({ ) { console.error("[PromptGroup] AI auth error:", error); toast.error( - "AI authentication failed. Please check your AI settings.", + t({ + id: "components.promptGroup.aiAuthFailed", + message: + "AI authentication failed. Please check your AI settings.", + }), ); clearPendingWorkspace(pendingWorkspaceId); return; } else { console.warn("[PromptGroup] AI generation failed:", error); toast.info( - "Using random branch name (AI generation unavailable)", + t({ + id: "components.promptGroup.branchNameUnavailable", + message: + "Using random branch name (AI generation unavailable)", + }), ); } } finally { @@ -864,7 +893,10 @@ function PromptGroupInner({ toast.error( err instanceof Error ? err.message - : "Failed to process attachments", + : t({ + id: "components.promptGroup.processAttachmentsFailed", + message: "Failed to process attachments", + }), ); return; } @@ -994,7 +1026,10 @@ ${sanitizeText(truncatedBody)}`; toast.error( error instanceof Error ? error.message - : "Failed to prepare agent launch", + : t({ + id: "components.promptGroup.prepareLaunchFailed", + message: "Failed to prepare agent launch", + }), ); return; } @@ -1008,12 +1043,21 @@ ${sanitizeText(truncatedBody)}`; launchRequest ?? undefined, ), { - loading: `Creating workspace from PR #${linkedPR.prNumber}...`, - success: "Workspace created from PR", + loading: t({ + id: "components.promptGroup.creatingFromPr", + message: `Creating workspace from PR #${linkedPR.prNumber}...`, + }), + success: t({ + id: "components.promptGroup.createdFromPr", + message: "Workspace created from PR", + }), error: (err) => err instanceof Error ? err.message - : "Failed to create workspace from PR", + : t({ + id: "components.promptGroup.createFromPrFailed", + message: "Failed to create workspace from PR", + }), }, { closeAndReset: false }, ).finally(() => { @@ -1051,9 +1095,22 @@ ${sanitizeText(truncatedBody)}`; }, ), { - loading: "Creating workspace...", - success: "Workspace created", - error: (err) => errorMessage(err, "Failed to create workspace"), + loading: t({ + id: "components.promptGroup.creatingWorkspace", + message: "Creating workspace...", + }), + success: t({ + id: "components.promptGroup.workspaceCreated", + message: "Workspace created", + }), + error: (err) => + errorMessage( + err, + t({ + id: "components.promptGroup.createWorkspaceFailed", + message: "Failed to create workspace", + }), + ), }, { closeAndReset: false }, ).finally(() => { @@ -1087,6 +1144,7 @@ ${sanitizeText(truncatedBody)}`; setPendingWorkspace, setPendingWorkspaceStatus, trimmedPrompt, + t, utils, workspaceName, workspaceNameEdited, @@ -1134,9 +1192,22 @@ ${sanitizeText(truncatedBody)}`; worktreeId: action.worktreeId, }), { - loading: "Opening worktree...", - success: "Worktree opened", - error: (err) => errorMessage(err, "Failed to open worktree"), + loading: t({ + id: "components.promptGroup.openingWorktree", + message: "Opening worktree...", + }), + success: t({ + id: "components.promptGroup.worktreeOpened", + message: "Worktree opened", + }), + error: (err) => + errorMessage( + err, + t({ + id: "components.promptGroup.openWorktreeFailed", + message: "Failed to open worktree", + }), + ), }, ); } else { @@ -1146,9 +1217,22 @@ ${sanitizeText(truncatedBody)}`; worktreePath: action.worktreePath, }), { - loading: "Opening worktree...", - success: "Worktree opened", - error: (err) => errorMessage(err, "Failed to open worktree"), + loading: t({ + id: "components.promptGroup.openingWorktree", + message: "Opening worktree...", + }), + success: t({ + id: "components.promptGroup.worktreeOpened", + message: "Worktree opened", + }), + error: (err) => + errorMessage( + err, + t({ + id: "components.promptGroup.openWorktreeFailed", + message: "Failed to open worktree", + }), + ), }, ); } @@ -1158,6 +1242,7 @@ ${sanitizeText(truncatedBody)}`; runAsyncAction, openExternalWorktree.mutateAsync, openTrackedWorktree.mutateAsync, + t, ], ); @@ -1211,7 +1296,10 @@ ${sanitizeText(truncatedBody)}`;
updateDraft({ @@ -1230,7 +1318,10 @@ ${sanitizeText(truncatedBody)}`; className={cn( "border-none bg-transparent dark:bg-transparent shadow-none text-xs font-mono text-muted-foreground/60 px-0 h-auto focus-visible:ring-0 placeholder:text-muted-foreground/30 focus:text-muted-foreground text-right placeholder:text-right overflow-hidden text-ellipsis", )} - placeholder="branch name" + placeholder={t({ + id: "components.promptGroup.branchNamePlaceholder", + message: "branch name", + })} value={branchName} onChange={(e) => updateDraft({ @@ -1317,7 +1408,10 @@ ${sanitizeText(truncatedBody)}`; )} updateDraft({ prompt: e.target.value })} @@ -1327,13 +1421,19 @@ ${sanitizeText(truncatedBody)}`; agents={enabledAgentPresets} value={selectedAgent} - placeholder="No agent" + placeholder={t({ + id: "components.promptGroup.noAgentPlaceholder", + message: "No agent", + })} onValueChange={setSelectedAgent} onBeforeConfigureAgents={closeModal} triggerClassName={`${PILL_BUTTON_CLASS} px-1.5 gap-1 text-foreground w-auto max-w-[160px]`} iconClassName="size-3 object-contain" allowNone - noneLabel="No agent" + noneLabel={t({ + id: "components.promptGroup.noAgentLabel", + message: "No agent", + })} noneValue="none" /> diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx index 3ba40a2df6..a3ffcfa7ec 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { Checkbox } from "@superset/ui/checkbox"; import { Command, @@ -47,6 +47,7 @@ export function GitHubIssueLinkCommand({ projectId, anchorRef, }: GitHubIssueLinkCommandProps) { + const { t } = useLingui(); const [searchQuery, setSearchQuery] = useState(""); const [showClosed, setShowClosed] = useState(false); const showClosedId = useId(); @@ -122,7 +123,10 @@ export function GitHubIssueLinkCommand({ > diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx index 21c0cf329d..46a6685200 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { Button } from "@superset/ui/button"; import { XIcon } from "lucide-react"; import { @@ -23,6 +23,7 @@ export function LinkedGitHubIssuePill({ state, onRemove, }: LinkedGitHubIssuePillProps) { + const { t } = useLingui(); return (
diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteWorkspacesDemo/RemoteWorkspacesDemo.tsx b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteAccessDemo/RemoteAccessDemo.tsx similarity index 80% rename from apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteWorkspacesDemo/RemoteWorkspacesDemo.tsx rename to apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteAccessDemo/RemoteAccessDemo.tsx index 108b64bc22..b85c13f704 100644 --- a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteWorkspacesDemo/RemoteWorkspacesDemo.tsx +++ b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteAccessDemo/RemoteAccessDemo.tsx @@ -1,7 +1,7 @@ import { Trans } from "@lingui/react/macro"; import { HiOutlineComputerDesktop, HiOutlineSignal } from "react-icons/hi2"; -export function RemoteWorkspacesDemo() { +export function RemoteAccessDemo() { return (
@@ -13,8 +13,8 @@ export function RemoteWorkspacesDemo() {
- - Remote Workspaces + + Remote Access
@@ -27,9 +27,7 @@ export function RemoteWorkspacesDemo() {
- - This Mac - + This Mac
@@ -42,9 +40,7 @@ export function RemoteWorkspacesDemo() {
- - Remote - + Remote
@@ -52,22 +48,20 @@ export function RemoteWorkspacesDemo() {
- + Tunnel established - live + live
- - Latency - + Latency - + 42ms diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteAccessDemo/index.ts b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteAccessDemo/index.ts new file mode 100644 index 0000000000..1206a59828 --- /dev/null +++ b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteAccessDemo/index.ts @@ -0,0 +1 @@ +export { RemoteAccessDemo } from "./RemoteAccessDemo"; diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeatureSidebar/FeatureSidebar.tsx b/apps/desktop/src/renderer/components/Paywall/components/FeatureSidebar/FeatureSidebar.tsx index 1c90c9f408..2e41037ddf 100644 --- a/apps/desktop/src/renderer/components/Paywall/components/FeatureSidebar/FeatureSidebar.tsx +++ b/apps/desktop/src/renderer/components/Paywall/components/FeatureSidebar/FeatureSidebar.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { cn } from "@superset/ui/utils"; import { useMemo } from "react"; import type { ProFeature } from "../../constants"; @@ -56,6 +56,7 @@ interface FeatureButtonProps { } function FeatureButton({ feature, isSelected, onSelect }: FeatureButtonProps) { + const { i18n } = useLingui(); const Icon = feature.icon; return ( @@ -85,7 +86,7 @@ function FeatureButton({ feature, isSelected, onSelect }: FeatureButtonProps) { isSelected ? "text-foreground" : "", )} > - {feature.title} + {i18n._(feature.title)} {feature.comingSoon && ( diff --git a/apps/desktop/src/renderer/components/Paywall/constants.ts b/apps/desktop/src/renderer/components/Paywall/constants.ts index e48c3e5a7d..40d06f49e7 100644 --- a/apps/desktop/src/renderer/components/Paywall/constants.ts +++ b/apps/desktop/src/renderer/components/Paywall/constants.ts @@ -1,3 +1,5 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; import type { IconType } from "react-icons"; import { FaSlack } from "react-icons/fa"; import { @@ -10,7 +12,7 @@ import { export const GATED_FEATURES = { INVITE_MEMBERS: "invite-members", TASKS: "tasks", - REMOTE_WORKSPACES: "remote-workspaces", + REMOTE_ACCESS: "remote-access", MOBILE_APP: "mobile-app", } as const; @@ -18,8 +20,8 @@ export type GatedFeature = (typeof GATED_FEATURES)[keyof typeof GATED_FEATURES]; export interface ProFeature { id: string; - title: string; - description: string; + title: MessageDescriptor; + description: MessageDescriptor; icon: IconType; iconColor: string; gradientColors: readonly [string, string, string, string]; @@ -28,46 +30,76 @@ export interface ProFeature { export const PRO_FEATURES: ProFeature[] = [ { - id: "remote-workspaces", - title: "Remote Workspaces", - description: - "Reach this Mac from anywhere via the Superset relay, or spin up cloud workspaces. Connect from any client.", + id: "remote-access", + title: msg({ + id: "components.paywall.remoteAccess.title", + message: "Remote Access", + }), + description: msg({ + id: "components.paywall.remoteAccess.description", + message: + "Reach this Mac from anywhere via the Superset relay, or spin up cloud workspaces. Connect from any client.", + }), icon: HiOutlineSignal, iconColor: "text-pink-500", gradientColors: ["#be185d", "#9d174d", "#831843", "#1a1a2e"], }, { id: "team-collaboration", - title: "Team Collaboration", - description: - "Invite your team to shared workspaces. See real-time updates, sync configurations, and manage team access across agents.", + title: msg({ + id: "components.paywall.teamCollaboration.title", + message: "Team Collaboration", + }), + description: msg({ + id: "components.paywall.teamCollaboration.description", + message: + "Invite your team to shared workspaces. See real-time updates, sync configurations, and manage team access across agents.", + }), icon: HiUsers, iconColor: "text-blue-500", gradientColors: ["#1e40af", "#1e3a8a", "#172554", "#1a1a2e"], }, { id: "tasks", - title: "Tasks", - description: - "Track and manage tasks synced from Linear. Stay on top of your work without leaving Superset.", + title: msg({ + id: "components.paywall.tasks.title", + message: "Tasks", + }), + description: msg({ + id: "components.paywall.tasks.description", + message: + "Track and manage tasks synced from Linear. Stay on top of your work without leaving Superset.", + }), icon: HiOutlineClipboardDocumentList, iconColor: "text-emerald-500", gradientColors: ["#047857", "#065f46", "#064e3b", "#1a1a2e"], }, { id: "slack-integration", - title: "Slack Integration", - description: - "Turn Slack conversations into tasks, run agents from your workspace, and keep teammates in the loop where work starts.", + title: msg({ + id: "components.paywall.slackIntegration.title", + message: "Slack Integration", + }), + description: msg({ + id: "components.paywall.slackIntegration.description", + message: + "Turn Slack conversations into tasks, run agents from your workspace, and keep teammates in the loop where work starts.", + }), icon: FaSlack, iconColor: "text-violet-500", gradientColors: ["#7c3aed", "#4f46e5", "#0f766e", "#1a1a2e"], }, { id: "mobile-app", - title: "Mobile App", - description: - "Monitor workspaces and manage tasks on the go. Continue conversations from anywhere.", + title: msg({ + id: "components.paywall.mobileApp.title", + message: "Mobile App", + }), + description: msg({ + id: "components.paywall.mobileApp.description", + message: + "Monitor workspaces and manage tasks on the go. Continue conversations from anywhere.", + }), icon: HiDevicePhoneMobile, iconColor: "text-red-500", gradientColors: ["#7f1d1d", "#991b1b", "#450a0a", "#1a1a2e"], @@ -79,6 +111,6 @@ export const PRO_FEATURES: ProFeature[] = [ export const FEATURE_ID_MAP: Record = { [GATED_FEATURES.INVITE_MEMBERS]: "team-collaboration", [GATED_FEATURES.TASKS]: "tasks", - [GATED_FEATURES.REMOTE_WORKSPACES]: "remote-workspaces", + [GATED_FEATURES.REMOTE_ACCESS]: "remote-access", [GATED_FEATURES.MOBILE_APP]: "mobile-app", }; diff --git a/apps/desktop/src/renderer/components/ProviderAuth/components/AnthropicOAuthDialog/AnthropicOAuthDialog.tsx b/apps/desktop/src/renderer/components/ProviderAuth/components/AnthropicOAuthDialog/AnthropicOAuthDialog.tsx index 882e6a3d64..03947174f9 100644 --- a/apps/desktop/src/renderer/components/ProviderAuth/components/AnthropicOAuthDialog/AnthropicOAuthDialog.tsx +++ b/apps/desktop/src/renderer/components/ProviderAuth/components/AnthropicOAuthDialog/AnthropicOAuthDialog.tsx @@ -1,14 +1,33 @@ +import { msg } from "@lingui/core/macro"; import { OAuthDialog, type OAuthDialogProps } from "../OAuthDialog"; const ANTHROPIC_PROVIDER: OAuthDialogProps["provider"] = { - title: "Connect Anthropic", - description: - "Approve access in your browser, then paste the callback URL or `code#state` here.", - codeLabel: "Authorization code", - codePlaceholder: "Paste callback URL or code#state", - codeHint: - "Anthropic usually returns a full callback URL. Pasting either format works.", - preparingLabel: "Preparing Anthropic browser login...", + title: msg({ + id: "components.anthropicOauthDialog.title", + message: "Connect Anthropic", + }), + description: msg({ + id: "components.anthropicOauthDialog.description", + message: + "Approve access in your browser, then paste the callback URL or `code#state` here.", + }), + codeLabel: msg({ + id: "components.anthropicOauthDialog.codeLabel", + message: "Authorization code", + }), + codePlaceholder: msg({ + id: "components.anthropicOauthDialog.codePlaceholder", + message: "Paste callback URL or code#state", + }), + codeHint: msg({ + id: "components.anthropicOauthDialog.codeHint", + message: + "Anthropic usually returns a full callback URL. Pasting either format works.", + }), + preparingLabel: msg({ + id: "components.anthropicOauthDialog.preparingLabel", + message: "Preparing Anthropic browser login...", + }), }; type AnthropicOAuthDialogProps = Omit; diff --git a/apps/desktop/src/renderer/components/ProviderAuth/components/OAuthDialog/OAuthDialog.tsx b/apps/desktop/src/renderer/components/ProviderAuth/components/OAuthDialog/OAuthDialog.tsx index fd33012a1c..4e0264bad3 100644 --- a/apps/desktop/src/renderer/components/ProviderAuth/components/OAuthDialog/OAuthDialog.tsx +++ b/apps/desktop/src/renderer/components/ProviderAuth/components/OAuthDialog/OAuthDialog.tsx @@ -1,4 +1,5 @@ -import { Trans } from "@lingui/react/macro"; +import type { MessageDescriptor } from "@lingui/core"; +import { Trans, useLingui } from "@lingui/react/macro"; import { Button } from "@superset/ui/button"; import { Dialog, @@ -13,12 +14,12 @@ import { useCallback, useState } from "react"; export interface OAuthDialogProps { provider: { - title: string; - description: string; - codeLabel: string; - codePlaceholder: string; - codeHint: string; - preparingLabel: string; + title: MessageDescriptor; + description: MessageDescriptor; + codeLabel: MessageDescriptor; + codePlaceholder: MessageDescriptor; + codeHint: MessageDescriptor; + preparingLabel: MessageDescriptor; }; open: boolean; authUrl: string | null; @@ -55,6 +56,7 @@ export function OAuthDialog({ onRetry, onSubmit, }: OAuthDialogProps) { + const { i18n } = useLingui(); const hasAuthUrl = Boolean(authUrl); const showCodeInput = hasAuthUrl || isPending; const canSubmit = @@ -72,14 +74,14 @@ export function OAuthDialog({ - {provider.title} - {provider.description} + {i18n._(provider.title)} + {i18n._(provider.description)}
{isPreparing ? (
- {provider.preparingLabel} + {i18n._(provider.preparingLabel)}
) : null} @@ -111,11 +113,11 @@ export function OAuthDialog({
- + onCodeChange(event.target.value)} onKeyDown={(event) => { @@ -133,13 +135,13 @@ export function OAuthDialog({ />

- {provider.codeHint} + {i18n._(provider.codeHint)}

) : !isPreparing ? (
- {provider.preparingLabel} + {i18n._(provider.preparingLabel)}
) : null} diff --git a/apps/desktop/src/renderer/components/ProviderAuth/components/OpenAIOAuthDialog/OpenAIOAuthDialog.tsx b/apps/desktop/src/renderer/components/ProviderAuth/components/OpenAIOAuthDialog/OpenAIOAuthDialog.tsx index a356da92ec..e02ff34115 100644 --- a/apps/desktop/src/renderer/components/ProviderAuth/components/OpenAIOAuthDialog/OpenAIOAuthDialog.tsx +++ b/apps/desktop/src/renderer/components/ProviderAuth/components/OpenAIOAuthDialog/OpenAIOAuthDialog.tsx @@ -1,13 +1,32 @@ +import { msg } from "@lingui/core/macro"; import { OAuthDialog, type OAuthDialogProps } from "../OAuthDialog"; const OPENAI_PROVIDER: OAuthDialogProps["provider"] = { - title: "Connect OpenAI", - description: - "Approve access in your browser. If the callback does not finish, paste the redirected callback URL below.", - codeLabel: "Callback URL (optional)", - codePlaceholder: "Paste callback URL", - codeHint: "Leave this empty if browser login finishes on its own.", - preparingLabel: "Preparing OpenAI browser login...", + title: msg({ + id: "components.openaiOauthDialog.title", + message: "Connect OpenAI", + }), + description: msg({ + id: "components.openaiOauthDialog.description", + message: + "Approve access in your browser. If the callback does not finish, paste the redirected callback URL below.", + }), + codeLabel: msg({ + id: "components.openaiOauthDialog.codeLabel", + message: "Callback URL (optional)", + }), + codePlaceholder: msg({ + id: "components.openaiOauthDialog.codePlaceholder", + message: "Paste callback URL", + }), + codeHint: msg({ + id: "components.openaiOauthDialog.codeHint", + message: "Leave this empty if browser login finishes on its own.", + }), + preparingLabel: msg({ + id: "components.openaiOauthDialog.preparingLabel", + message: "Preparing OpenAI browser login...", + }), }; type OpenAIOAuthDialogProps = Omit; diff --git a/apps/desktop/src/renderer/components/RemotePathPicker/RemotePathPicker.tsx b/apps/desktop/src/renderer/components/RemotePathPicker/RemotePathPicker.tsx index d52d12b2a0..0f86ebf4ba 100644 --- a/apps/desktop/src/renderer/components/RemotePathPicker/RemotePathPicker.tsx +++ b/apps/desktop/src/renderer/components/RemotePathPicker/RemotePathPicker.tsx @@ -1,4 +1,5 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; +import { i18n } from "@superset/i18n"; import { Breadcrumb, BreadcrumbEllipsis, @@ -60,10 +61,24 @@ const MAX_VISIBLE_SEGMENTS = 4; function pathToSegments(path: string, homePath: string | null): Segment[] { const segments: Segment[] = []; if (homePath && (path === homePath || path === `${homePath}/`)) { - return [{ label: "Home", path: homePath }]; + return [ + { + label: i18n._({ + id: "components.remotePathPicker.homeSegment", + message: "Home", + }), + path: homePath, + }, + ]; } if (homePath && path.startsWith(`${homePath}/`)) { - segments.push({ label: "Home", path: homePath }); + segments.push({ + label: i18n._({ + id: "components.remotePathPicker.homeSegment", + message: "Home", + }), + path: homePath, + }); const rest = path.slice(homePath.length + 1); let cumulative = homePath; for (const part of rest.split("/").filter(Boolean)) { @@ -92,10 +107,20 @@ export function RemotePathPicker({ hostName, initialPath, onPick, - title = "Choose a folder", + title, description, - confirmLabel = "Use this folder", + confirmLabel, }: RemotePathPickerProps) { + const { t } = useLingui(); + const resolvedTitle = + title ?? + t({ id: "components.remotePathPicker.title", message: "Choose a folder" }); + const resolvedConfirmLabel = + confirmLabel ?? + t({ + id: "components.remotePathPicker.confirmLabel", + message: "Use this folder", + }); const [currentPath, setCurrentPath] = useState( initialPath ?? null, ); @@ -127,10 +152,13 @@ export function RemotePathPicker({ toast.error( query.error instanceof Error ? query.error.message - : "Could not list directory", + : t({ + id: "components.remotePathPicker.listDirectoryFailed", + message: "Could not list directory", + }), ); } - }, [query.error]); + }, [query.error, t]); const allSegments = query.data ? pathToSegments(query.data.path, query.data.homePath) @@ -157,7 +185,7 @@ export function RemotePathPicker({ - {title} + {resolvedTitle} {description ?? `Browse folders on ${hostName}.`} @@ -213,7 +241,10 @@ export function RemotePathPicker({ type="button" onClick={() => query.refetch()} disabled={query.isFetching} - aria-label="Refresh" + aria-label={t({ + id: "components.remotePathPicker.refresh", + message: "Refresh", + })} className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50" > - {confirmLabel} + {resolvedConfirmLabel} diff --git a/apps/desktop/src/renderer/components/SidebarCardSlot/cards/useHiringCard/useHiringCard.ts b/apps/desktop/src/renderer/components/SidebarCardSlot/cards/useHiringCard/useHiringCard.ts index 6b00085ffd..8d4fed9635 100644 --- a/apps/desktop/src/renderer/components/SidebarCardSlot/cards/useHiringCard/useHiringCard.ts +++ b/apps/desktop/src/renderer/components/SidebarCardSlot/cards/useHiringCard/useHiringCard.ts @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { COMPANY, FEATURE_FLAGS } from "@superset/shared/constants"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { track } from "renderer/lib/analytics"; @@ -10,6 +11,7 @@ export function useHiringCard({ }: { surface: "v1" | "v2"; }): SidebarCardEntry | null { + const { t } = useLingui(); const isEnabled = useFeatureFlagEnabled(FEATURE_FLAGS.HIRING_BANNER); const dismissed = useHiringBannerStore((s) => s.dismissed); const dismiss = useHiringBannerStore((s) => s.dismiss); @@ -19,10 +21,22 @@ export function useHiringCard({ return { id: "hiring", - badge: "We're hiring", - title: "Like building with Superset?", - description: "You're one of our most active users. Come help us build it.", - actionLabel: "View open roles", + badge: t({ + id: "components.hiringCard.badge", + message: "We're hiring", + }), + title: t({ + id: "components.hiringCard.title", + message: "Like building with Superset?", + }), + description: t({ + id: "components.hiringCard.description", + message: "You're one of our most active users. Come help us build it.", + }), + actionLabel: t({ + id: "components.hiringCard.actionLabel", + message: "View open roles", + }), onAction: () => { track("hiring_banner_clicked"); openUrlMutation.mutate(COMPANY.CAREERS_URL); diff --git a/apps/desktop/src/renderer/components/SidebarCardSlot/cards/useStarNagCard/useStarNagCard.tsx b/apps/desktop/src/renderer/components/SidebarCardSlot/cards/useStarNagCard/useStarNagCard.tsx index 4f1b83db1e..22bc51700e 100644 --- a/apps/desktop/src/renderer/components/SidebarCardSlot/cards/useStarNagCard/useStarNagCard.tsx +++ b/apps/desktop/src/renderer/components/SidebarCardSlot/cards/useStarNagCard/useStarNagCard.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { FEATURE_FLAGS } from "@superset/shared/constants"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { useEffect, useReducer } from "react"; @@ -20,6 +21,7 @@ export function useStarNagCard({ }: { isCollapsed?: boolean; }): SidebarCardEntry | null { + const { t } = useLingui(); const isEnabled = useFeatureFlagEnabled(FEATURE_FLAGS.STAR_NAG_CARD); const shouldShow = useStarNagStore((s) => s.shouldShowThresholdCard()); const deferredUntil = useStarNagStore((s) => s.deferredUntil); @@ -60,9 +62,15 @@ export function useStarNagCard({ return { id: "star-nag", - title: "Enjoying Superset?", - description: - "Superset is open source. If it's helped you today, a GitHub star helps other developers find it.", + title: t({ + id: "components.starNagCard.title", + message: "Enjoying Superset?", + }), + description: t({ + id: "components.starNagCard.description", + message: + "Superset is open source. If it's helped you today, a GitHub star helps other developers find it.", + }), onDismiss: () => { track("star_nag_dismissed", { surface: "card" }); dismiss(); diff --git a/apps/desktop/src/renderer/components/StarNagToast/StarNagToast.tsx b/apps/desktop/src/renderer/components/StarNagToast/StarNagToast.tsx index 4b813f18aa..732cdc56c5 100644 --- a/apps/desktop/src/renderer/components/StarNagToast/StarNagToast.tsx +++ b/apps/desktop/src/renderer/components/StarNagToast/StarNagToast.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { toast } from "@superset/ui/sonner"; import { X } from "lucide-react"; import { useEffect } from "react"; @@ -11,6 +11,7 @@ import { track } from "renderer/lib/analytics"; import { useStarNagStore } from "renderer/stores/star-nag"; function StarNagToastContent({ toastId }: { toastId: string | number }) { + const { t } = useLingui(); const { state, activate, isBusy } = useGithubStarAction(); const dismiss = useStarNagStore((s) => s.dismiss); @@ -45,7 +46,10 @@ function StarNagToastContent({ toastId }: { toastId: string | number }) { @@ -96,7 +100,10 @@ export function AutomationDetailHeader({ variant="ghost" size="icon-sm" disabled={readOnly} - aria-label="More actions" + aria-label={t({ + id: "dashboard.automations.detailHeader.moreActionsAriaLabel", + message: "More actions", + })} > diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/PreviousRunsList/PreviousRunsList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/PreviousRunsList/PreviousRunsList.tsx index eb0d77be7d..fe1bc1b352 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/PreviousRunsList/PreviousRunsList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/PreviousRunsList/PreviousRunsList.tsx @@ -1,5 +1,7 @@ +import { msg } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import type { SelectAutomationRun } from "@superset/db/schema"; +import { i18n } from "@superset/i18n"; import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; import { formatDistanceStrict } from "date-fns"; @@ -14,9 +16,11 @@ import { } from "../../../utils/staleAgentError"; function describeRunError(error: string): string { - if (isHostOfflineError(error)) return `${error}. ${HOST_OFFLINE_HELP}`; + if (isHostOfflineError(error)) + return `${error}. ${i18n._(HOST_OFFLINE_HELP)}`; // Lead with the plain-language fix; keep the raw host error for reports. - if (isStaleAgentError(error)) return `${STALE_AGENT_HELP}\n\n(${error})`; + if (isStaleAgentError(error)) + return `${i18n._(STALE_AGENT_HELP)}\n\n(${error})`; return error; } @@ -35,8 +39,20 @@ interface PreviousRunsListProps { function formatAgo(date: Date, now: Date): string { const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); - if (seconds < 60) return "less than a minute ago"; - return `${formatDistanceStrict(date, now)} ago`; + if (seconds < 60) + return i18n._( + msg({ + id: "dashboard.automations.previousRuns.lessThanMinuteAgo", + message: "less than a minute ago", + }), + ); + const distance = formatDistanceStrict(date, now); + return i18n._( + msg({ + id: "dashboard.automations.previousRuns.distanceAgo", + message: `${distance} ago`, + }), + ); } export function PreviousRunsList({ runs }: PreviousRunsListProps) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx index b1b0d2d6ee..c4aebbdce2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/TriggersCard.tsx @@ -1,13 +1,7 @@ -import { Trans } from "@lingui/react/macro"; +import { formatDateTime } from "@superset/i18n/format"; import type { DraftTrigger } from "@superset/shared/automation-triggers"; -import { - formatDateTimeInTimezone, - nextOccurrenceAfter, -} from "@superset/shared/rrule"; +import { nextOccurrenceAfter } from "@superset/shared/rrule"; import type { RouterOutputs } from "@superset/trpc"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { formatDistanceStrict } from "date-fns"; -import { useMemo } from "react"; import { useRecentProjects } from "renderer/hooks/host-projects/useRecentProjects"; import type { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { DevicePicker } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; @@ -15,6 +9,7 @@ import { ProjectPicker } from "../../../components/ProjectPicker"; import { RelayOfflineNotice } from "../../../components/RelayOfflineNotice"; import { TriggersEditor } from "../../../components/TriggersEditor"; import { WorkspacePicker } from "../../../components/WorkspacePicker"; +import { AutomationTagsPicker } from "./components/AutomationTagsPicker"; export type AutomationUpdatePatch = Partial< Omit[0], "id"> @@ -47,48 +42,6 @@ export function TriggersCard({ (p) => p.id === automation.v2ProjectId, ); - // Per trigger, not per automation: an automation can hold several schedules, - // and the automation-level nextRunAt is only the soonest of them — showing it - // on every row claims they all fire at the same time. - const nextRunByTriggerId = useMemo(() => { - // The zone travels with the date: schedules on one automation can sit in - // different timezones, so formatting them all in the automation-level one - // would label the tooltip wrongly for every schedule but the soonest. - const entries = new Map(); - - for (const trigger of automation.triggers) { - const config = trigger.config as DraftTrigger["config"]; - if (config.kind !== "schedule") continue; - - // The saved nextRunAt is the dispatcher's truth; the computed one - // covers a paused automation, whose stored value is stale. - if (automation.enabled && trigger.nextRunAt) { - entries.set(trigger.id, { - at: new Date(trigger.nextRunAt), - timezone: config.timezone, - }); - continue; - } - try { - const next = nextOccurrenceAfter({ - rrule: config.rrule, - dtstart: new Date(config.dtstart), - timezone: config.timezone, - after: new Date(), - }); - if (next) - entries.set(trigger.id, { at: next, timezone: config.timezone }); - } catch (error) { - console.warn( - `[TriggersCard] failed to compute next occurrence for trigger ${trigger.id}`, - error, - ); - } - } - - return entries; - }, [automation.triggers, automation.enabled]); - // The scope line is the same grammar as a trigger sentence — "in X on Y using // Z" — so it uses the same chips. Left alone, the three pickers render at // 36px/12px, 22px/11px and 36px/12px, none of which match the 24px/13px chips @@ -97,30 +50,37 @@ export function TriggersCard({ const SCOPE_CHIP = "h-6 gap-1 rounded-[6px] bg-foreground/[0.06] px-2 text-[13px] font-normal hover:bg-foreground/10"; - const renderNextRun = (triggerId?: string) => { - const run = triggerId ? nextRunByTriggerId.get(triggerId) : undefined; - if (!run) return null; - return ( - - - - {automation.enabled ? ( - - Next run - - ) : ( - - Would run - - )}{" "} - {formatDistanceStrict(run.at, new Date(), { addSuffix: true })} - - - - {formatDateTimeInTimezone(run.at, run.timezone)} - - - ); + // Computed from the rule on screen, never the dispatcher's persisted + // nextRunAt: that value lags behind edits, goes stale while paused, and + // doesn't exist for a row that hasn't been saved yet. + const renderNextRun = ( + config: Extract, + ) => { + try { + const next = nextOccurrenceAfter({ + rrule: config.rrule, + dtstart: new Date(config.dtstart), + timezone: config.timezone, + after: new Date(), + }); + if (!next) return null; + return ( + + {"Next run "} + {formatDateTime(next, { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + timeZone: config.timezone, + })} + + ); + } catch { + return null; + } }; return ( @@ -134,59 +94,60 @@ export function TriggersCard({ organizationId={automation.organizationId} renderNextRun={renderNextRun} readOnly={readOnly} - /> -
- - in - - onUpdate({ v2ProjectId })} - /> - - on - - - onUpdate({ targetHostId: nextHostId }) - } - /> - - - using - - - - onUpdate({ - v2WorkspaceId, - // Denormalized pin: the picker is scoped to this host/project, - // so send both — the cloud stores them without a - // workspace-registry lookup. A null project means the pin is a - // session workspace. - ...(v2WorkspaceId && hostId - ? { - targetHostId: hostId, - v2ProjectId: automation.v2ProjectId, - } - : {}), - }) - } - /> -
+ > +
+ in + onUpdate({ v2ProjectId })} + /> + on + + onUpdate({ targetHostId: nextHostId }) + } + /> + using + + onUpdate({ + v2WorkspaceId, + // Denormalized pin: the picker is scoped to this host/project, + // so send both — the cloud stores them without a + // workspace-registry lookup. A null project means the pin is a + // session workspace. + ...(v2WorkspaceId && hostId + ? { + targetHostId: hostId, + v2ProjectId: automation.v2ProjectId, + } + : {}), + }) + } + /> + tagged + onUpdate({ tags })} + /> +
+
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/components/AutomationTagsPicker/AutomationTagsPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/components/AutomationTagsPicker/AutomationTagsPicker.tsx new file mode 100644 index 0000000000..8d0907a412 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/components/AutomationTagsPicker/AutomationTagsPicker.tsx @@ -0,0 +1,132 @@ +import { msg } from "@lingui/core/macro"; +import { i18n } from "@superset/i18n"; +import { + normalizeWorkspaceTag, + normalizeWorkspaceTags, +} from "@superset/shared/workspace-tags"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { Input } from "@superset/ui/input"; +import { useMemo, useState } from "react"; +import { useHostWorkspaces } from "renderer/routes/_authenticated/providers/HostWorkspacesProvider"; +import { ChipButton } from "../../../../../components/TriggerSentence/components/ChipButton"; + +/** + * The automation's workspace-tag set: every run's created workspace carries + * these tags, filing it into the matching sidebar folders. Unchecking a tag + * removes it; the field adds one (normalized like every tag surface), and + * the project's existing workspace tags are offered as suggestions so runs + * can join folders that already exist. + */ +export function AutomationTagsPicker({ + tags, + projectId, + disabled, + onChange, + className, +}: { + tags: string[]; + /** Scope for tag suggestions; null (session mode) suggests nothing. */ + projectId: string | null; + disabled?: boolean; + onChange: (tags: string[]) => void; + className?: string; +}) { + const [draft, setDraft] = useState(""); + const { workspaces: hostWorkspaces } = useHostWorkspaces(); + const suggestions = useMemo(() => { + if (projectId === null) return []; + return normalizeWorkspaceTags( + hostWorkspaces + .filter((workspace) => workspace.projectId === projectId) + .flatMap((workspace) => workspace.tags ?? []), + ).filter((tag) => !tags.includes(tag)); + }, [hostWorkspaces, projectId, tags]); + + const addDraft = () => { + const tag = normalizeWorkspaceTag(draft); + setDraft(""); + if (tag == null || tags.includes(tag)) return; + onChange(normalizeWorkspaceTags([...tags, tag])); + }; + + const label = + tags.length > 0 + ? tags.join(", ") + : i18n._( + msg({ + id: "dashboard.automations.tagsPicker.empty", + message: "no tags", + }), + ); + + return ( + + + + + + + + {tags.map((tag) => ( + + onChange(tags.filter((existing) => existing !== tag)) + } + > + {tag} + + ))} + {suggestions.map((tag) => ( + + onChange(normalizeWorkspaceTags([...tags, tag])) + } + > + {tag} + + ))} + {(tags.length > 0 || suggestions.length > 0) && ( + + )} +
+ setDraft(event.target.value)} + // The menu owns arrow keys and typeahead; the field keeps + // what it types. + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + addDraft(); + } + }} + className="h-7 text-[13px]" + /> +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/components/AutomationTagsPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/components/AutomationTagsPicker/index.ts new file mode 100644 index 0000000000..282cac0705 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/TriggersCard/components/AutomationTagsPicker/index.ts @@ -0,0 +1 @@ +export { AutomationTagsPicker } from "./AutomationTagsPicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/VersionHistorySheet/VersionHistorySheet.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/VersionHistorySheet/VersionHistorySheet.tsx index 149f0c4d7f..49314dd0c4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/VersionHistorySheet/VersionHistorySheet.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/VersionHistorySheet/VersionHistorySheet.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { errorMessage } from "@superset/i18n/errors"; import { alert } from "@superset/ui/atoms/Alert"; import { Button } from "@superset/ui/button"; @@ -36,6 +36,7 @@ export function VersionHistorySheet({ open, onOpenChange, }: VersionHistorySheetProps) { + const { t } = useLingui(); const queryClient = useQueryClient(); const [selectedVersionId, setSelectedVersionId] = useState( null, @@ -81,7 +82,12 @@ export function VersionHistorySheet({ onSuccess: (restored) => { queryClient.invalidateQueries({ queryKey: versionsQueryKey }); setSelectedVersionId(restored?.id ?? null); - toast.success("Prompt restored"); + toast.success( + t({ + id: "dashboard.automations.versionHistory.restoredToast", + message: "Prompt restored", + }), + ); }, }); @@ -91,18 +97,41 @@ export function VersionHistorySheet({ if (!selectedVersionId) return; const versionId = selectedVersionId; alert({ - title: "Restore this version?", - description: - 'The current prompt will be replaced with the selected version. A new "Restored" entry will be added to history so you can undo this.', + title: t({ + id: "dashboard.automations.versionHistory.restoreDialogTitle", + message: "Restore this version?", + }), + description: t({ + id: "dashboard.automations.versionHistory.restoreDialogDescription", + message: + 'The current prompt will be replaced with the selected version. A new "Restored" entry will be added to history so you can undo this.', + }), actions: [ - { label: "Cancel", variant: "outline" }, { - label: "Restore", + label: t({ + id: "dashboard.automations.versionHistory.restoreDialogCancel", + message: "Cancel", + }), + variant: "outline", + }, + { + label: t({ + id: "dashboard.automations.versionHistory.restoreDialogConfirm", + message: "Restore", + }), onClick: async () => { try { await restoreMutation.mutateAsync(versionId); } catch (error) { - toast.error(errorMessage(error, "Failed to restore")); + toast.error( + errorMessage( + error, + t({ + id: "dashboard.automations.versionHistory.restoreFailedToast", + message: "Failed to restore", + }), + ), + ); throw error; } }, @@ -143,7 +172,14 @@ export function VersionHistorySheet({ - diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/page.tsx index 6ecd17aa7c..fdbbbffd7b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/page.tsx @@ -1,4 +1,5 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; +import { i18n } from "@superset/i18n"; import { errorMessage } from "@superset/i18n/errors"; import { alert } from "@superset/ui/atoms/Alert"; import { toast } from "@superset/ui/sonner"; @@ -34,6 +35,7 @@ export const Route = createFileRoute( const RECENT_RUNS_LIMIT = 10; function AutomationDetailPage() { + const { t } = useLingui(); const { automationId } = Route.useParams(); const { history } = Route.useSearch(); const navigate = useNavigate(); @@ -81,13 +83,27 @@ function AutomationDetailPage() { void utils.automation.list.invalidate(); }, onError: (error) => - toast.error(errorMessage(error, "Failed to update automation")), + toast.error( + errorMessage( + error, + t({ + id: "dashboard.automations.detail.updateFailedToast", + message: "Failed to update automation", + }), + ), + ), }); const runNowMutation = useMutation({ mutationFn: () => apiTrpcClient.automation.runNow.mutate({ id: automationId }), - onSuccess: () => toast.success("Running now"), + onSuccess: () => + toast.success( + t({ + id: "dashboard.automations.detail.runningNowToast", + message: "Running now", + }), + ), onError: (error) => { const message = error instanceof Error ? error.message : null; if (isHostOfflineError(message)) { @@ -95,10 +111,16 @@ function AutomationDetailPage() { return; } if (isStaleAgentError(message)) { - toast.error(STALE_AGENT_HELP); + toast.error(i18n._(STALE_AGENT_HELP)); return; } - toast.error(message ?? "Failed to trigger run"); + toast.error( + message ?? + t({ + id: "dashboard.automations.detail.runFailedToast", + message: "Failed to trigger run", + }), + ); }, }); @@ -147,21 +169,46 @@ function AutomationDetailPage() { name={automation.name} onDelete={() => { alert({ - title: "Delete automation?", - description: `"${automation.name}" will stop firing and its run history will be removed. This can't be undone.`, + title: t({ + id: "dashboard.automations.detail.deleteDialogTitle", + message: "Delete automation?", + }), + description: t({ + id: "dashboard.automations.detail.deleteDialogDescription", + message: `"${automation.name}" will stop firing and its run history will be removed. This can't be undone.`, + }), actions: [ - { label: "Cancel", variant: "outline", onClick: () => {} }, { - label: "Delete", + label: t({ + id: "dashboard.automations.detail.deleteDialogCancel", + message: "Cancel", + }), + variant: "outline", + onClick: () => {}, + }, + { + label: t({ + id: "dashboard.automations.detail.deleteDialogConfirm", + message: "Delete", + }), variant: "destructive", onClick: () => { toast.promise(deleteMutation.mutateAsync(), { - loading: "Deleting automation...", - success: `"${automation.name}" deleted`, + loading: t({ + id: "dashboard.automations.detail.deletingToast", + message: "Deleting automation...", + }), + success: t({ + id: "dashboard.automations.detail.deletedToast", + message: `"${automation.name}" deleted`, + }), error: (err) => err instanceof Error ? err.message - : "Failed to delete automation", + : t({ + id: "dashboard.automations.detail.deleteFailedToast", + message: "Failed to delete automation", + }), }); }, }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentPicker/AgentPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentPicker/AgentPicker.tsx index a5f2e0b73b..45f5d8920e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentPicker/AgentPicker.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentPicker/AgentPicker.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { getPresetById } from "@superset/shared/host-agent-presets"; import { DropdownMenu, @@ -40,6 +40,7 @@ export function AgentPicker({ className, disabled, }: AgentPickerProps) { + const { t } = useLingui(); const navigate = useNavigate(); const hostUrl = useHostUrl(hostId); const { agents } = useV2AgentChoices(hostUrl); @@ -69,7 +70,13 @@ export function AgentPicker({ ) } - label={selectedLabel ?? "Select agent"} + label={ + selectedLabel ?? + t({ + id: "dashboard.automations.agentPicker.selectAgent", + message: "Select agent", + }) + } /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationRow/AutomationRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationRow/AutomationRow.tsx index efaa541fd9..040c348726 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationRow/AutomationRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationRow/AutomationRow.tsx @@ -1,5 +1,9 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; import type { SelectAutomationRun, SelectUser } from "@superset/db/schema"; +import { i18n } from "@superset/i18n"; +import { formatCompactRelativeTime } from "@superset/i18n/format"; import { describeSchedule, formatDateTimeInTimezone, @@ -50,34 +54,75 @@ interface AutomationRowProps { // A run's terminal success state is workspace creation — say so. const LAST_RUN_META: Record< SelectAutomationRun["status"], - { dot: string; label: string; failed?: boolean } + { dot: string; label: MessageDescriptor; failed?: boolean } > = { - dispatched: { dot: "bg-emerald-500", label: "created" }, - dispatching: { dot: "bg-amber-500", label: "creating" }, - skipped_offline: { dot: "bg-red-500", label: "failed", failed: true }, - dispatch_failed: { dot: "bg-red-500", label: "failed", failed: true }, + dispatched: { + dot: "bg-emerald-500", + label: msg({ + id: "dashboard.automations.row.lastRunCreated", + message: "created", + }), + }, + dispatching: { + dot: "bg-amber-500", + label: msg({ + id: "dashboard.automations.row.lastRunCreating", + message: "creating", + }), + }, + skipped_offline: { + dot: "bg-red-500", + label: msg({ + id: "dashboard.automations.row.lastRunFailedOffline", + message: "failed", + }), + failed: true, + }, + dispatch_failed: { + dot: "bg-red-500", + label: msg({ + id: "dashboard.automations.row.lastRunFailed", + message: "failed", + }), + failed: true, + }, // Neither created a workspace, so neither is `failed` — that flag offers to // open one. - debounced: { dot: "bg-slate-400", label: "superseded" }, - rejected: { dot: "bg-amber-500", label: "blocked" }, + debounced: { + dot: "bg-slate-400", + label: msg({ + id: "dashboard.automations.row.lastRunSuperseded", + message: "superseded", + }), + }, + rejected: { + dot: "bg-amber-500", + label: msg({ + id: "dashboard.automations.row.lastRunBlocked", + message: "blocked", + }), + }, }; +// Both directions come from Intl.RelativeTimeFormat: it renders the compact +// "3d ago" / "in 2h" shape in every locale, so these need no catalog entries +// beyond the two "right now" cases where a bare unit would read oddly. function compactUntil(at: number, now: Date): string { - const minutes = Math.floor((at - now.getTime()) / 60_000); - if (minutes < 1) return "soon"; - if (minutes < 60) return `in ${minutes}m`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `in ${hours}h`; - return `in ${Math.floor(hours / 24)}d`; + if (at - now.getTime() < 60_000) { + return i18n._( + msg({ id: "dashboard.automations.row.untilSoon", message: "soon" }), + ); + } + return formatCompactRelativeTime(at, now); } function compactAgo(at: number, now: Date): string { - const minutes = Math.floor((now.getTime() - at) / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; + if (now.getTime() - at < 60_000) { + return i18n._( + msg({ id: "dashboard.automations.row.agoJustNow", message: "just now" }), + ); + } + return formatCompactRelativeTime(at, now); } export function AutomationRow({ @@ -212,10 +257,13 @@ export function AutomationRow({ className="text-xs text-muted-foreground" title={ automation.enabled && automation.nextRunAt - ? `Next run ${formatDateTimeInTimezone( - new Date(automation.nextRunAt), - automation.timezone ?? "UTC", - )}` + ? t({ + id: "dashboard.automations.row.nextRunTitle", + message: `Next run ${formatDateTimeInTimezone( + new Date(automation.nextRunAt), + automation.timezone ?? "UTC", + )}`, + }) : undefined } > @@ -255,7 +303,7 @@ export function AutomationRow({ lastRunMeta.dot, )} /> - {lastRunMeta.label} + {i18n._(lastRunMeta.label)} e.stopPropagation()} - aria-label="Row actions" + aria-label={t({ + id: "dashboard.automations.row.rowActionsAriaLabel", + message: "Row actions", + })} className="opacity-0 group-hover/row:opacity-100 data-[state=open]:opacity-100 focus-visible:opacity-100" > diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationStatCards/AutomationStatCards.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationStatCards/AutomationStatCards.tsx index 58801c6fb9..3ca3d7f73e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationStatCards/AutomationStatCards.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationStatCards/AutomationStatCards.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { cn } from "@superset/ui/utils"; interface AutomationStatCardsProps { @@ -25,6 +25,7 @@ export function AutomationStatCards({ canFilterFailed, onToggleFailedFilter, }: AutomationStatCardsProps) { + const { t } = useLingui(); const total = created7d + failed7d; const pct = (n: number) => total > 0 ? `${((n / total) * 100).toFixed(1)}%` : null; @@ -39,7 +40,10 @@ export function AutomationStatCards({

@@ -57,10 +61,20 @@ export function AutomationStatCards({ onClick={onToggleFailedFilter} title={ failedFilter - ? "Show all automations again" + ? t({ + id: "dashboard.automations.statCards.showAllTitle", + message: "Show all automations again", + }) : canFilterFailed - ? "Show only automations whose last run failed" - : "Runs failed earlier this week, but nothing is failing right now" + ? t({ + id: "dashboard.automations.statCards.showFailedTitle", + message: "Show only automations whose last run failed", + }) + : t({ + id: "dashboard.automations.statCards.noFailingTitle", + message: + "Runs failed earlier this week, but nothing is failing right now", + }) } className={cn( CARD, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationsEmptyState/AutomationsEmptyState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationsEmptyState/AutomationsEmptyState.tsx index 63e67025d1..f6c64f8c83 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationsEmptyState/AutomationsEmptyState.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationsEmptyState/AutomationsEmptyState.tsx @@ -66,7 +66,10 @@ export function AutomationsEmptyState({

diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/SchedulePicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/SchedulePicker.tsx deleted file mode 100644 index 54d2ee7042..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/SchedulePicker.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { Trans } from "@lingui/react/macro"; -import { - describeSchedule, - isValidRrule, - type Weekday, -} from "@superset/shared/rrule"; -import { Input } from "@superset/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@superset/ui/select"; -import { useMemo, useRef, useState } from "react"; -import { LuClock } from "react-icons/lu"; -import { PickerTrigger } from "renderer/components/PickerTrigger"; -import { - DAY_OPTIONS, - formatTimeInputValue, - PRESET_OPTIONS, - type PresetKind, - parseTimeInputValue, - rruleFromState, - type SchedulePickerState, - stateFromRrule, -} from "./scheduleState"; - -interface SchedulePickerProps { - rrule: string; - onRruleChange: (rrule: string) => void; - className?: string; -} - -export function SchedulePicker({ - rrule, - onRruleChange, - className, -}: SchedulePickerProps) { - const [state, setState] = useState(() => - stateFromRrule(rrule), - ); - // Resync when the rrule changes underneath us (remote edit, version - // restore) — but not when our own emission echoes back through the row, - // which would collapse an in-progress Custom edit into a preset. - const lastEmittedRef = useRef(rrule); - const [prevRrule, setPrevRrule] = useState(rrule); - if (rrule !== prevRrule) { - setPrevRrule(rrule); - if (rrule !== lastEmittedRef.current) { - lastEmittedRef.current = rrule; - setState(stateFromRrule(rrule)); - } - } - - const emit = (serialized: string) => { - lastEmittedRef.current = serialized; - onRruleChange(serialized); - }; - - const update = (patch: Partial) => { - const next = { ...state, ...patch }; - if (patch.kind === "custom" && state.kind !== "custom") { - // Entering Custom mode: seed from the current saved rule (a stale - // draft from a prior visit would silently mismatch what's persisted). - next.customRrule = rrule; - } - setState(next); - // Custom text commits on blur/Enter once it validates; presets are - // always complete rules. - if (next.kind !== "custom") emit(rruleFromState(next)); - }; - - const customDraft = state.customRrule.trim(); - const customValid = useMemo(() => isValidRrule(customDraft), [customDraft]); - - const commitCustom = () => { - if (!customDraft || customDraft === rrule || !customValid) return; - emit(customDraft); - }; - - const triggerLabel = useMemo(() => describeSchedule(rrule), [rrule]); - - return ( - - - } - label={triggerLabel} - /> - - -
- - - Schedule - - - - - - {state.kind === "weekly" && ( - - )} - - {(state.kind === "daily" || - state.kind === "weekdays" || - state.kind === "weekly") && ( - { - const parsed = parseTimeInputValue(event.target.value); - if (parsed) update(parsed); - }} - /> - )} - - {state.kind === "custom" && ( -
- - update({ customRrule: event.target.value }) - } - onBlur={commitCustom} - onKeyDown={(event) => { - if (event.key === "Enter") commitCustom(); - }} - /> - {customDraft && !customValid && ( - - - Invalid recurrence rule — changes aren't saved - - - )} - {customDraft && customValid && ( - - {describeSchedule(customDraft)} - - )} -
- )} -
-
-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/index.ts deleted file mode 100644 index c9a9b93cfe..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SchedulePicker } from "./SchedulePicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/ScheduleSentence/ScheduleSentence.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/ScheduleSentence/ScheduleSentence.tsx index 5d4b8c9984..f40688b71d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/ScheduleSentence/ScheduleSentence.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/ScheduleSentence/ScheduleSentence.tsx @@ -1,24 +1,20 @@ -import { Trans } from "@lingui/react/macro"; import { rruleProblem, timezoneAbbreviation, type Weekday, } from "@superset/shared/rrule"; -import { Input } from "@superset/ui/input"; import { cn } from "@superset/ui/utils"; import { type ReactNode, useMemo, useRef, useState } from "react"; +import { CHIP } from "../TriggerSentence/chipStyles"; +import { SelectChip } from "../TriggerSentence/components/SelectChip"; import { DAY_OPTIONS, formatTimeInputValue, - PRESET_OPTIONS, - type PresetKind, parseTimeInputValue, rruleFromState, - type SchedulePickerState, + type ScheduleState, stateFromRrule, -} from "../SchedulePicker/scheduleState"; -import { CHIP } from "../TriggerSentence/chipStyles"; -import { SelectChip } from "../TriggerSentence/components/SelectChip"; +} from "./scheduleState"; interface ScheduleSentenceProps { rrule: string; @@ -26,24 +22,28 @@ interface ScheduleSentenceProps { timezone: string; /** Trailing "Next run ..." text, shown inline at the end of the sentence. */ nextRun?: ReactNode; - className?: string; disabled?: boolean; } +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, hour) => { + const value = formatTimeInputValue(hour, 0); + return { value, label: value }; +}); + /** - * Sentence-chip schedule editor: - * "[Daily ▾] at [8:00 AM] · Los Angeles (PDT)", decomposed into inline - * controls instead of one opaque popover chip. + * One schedule trigger as a sentence: "Every week on [Monday ▾] at [09:00 ▾] + * PDT · Next run ...". The cadence is fixed when the trigger is added — the + * Add Trigger menu is where Hourly vs Daily is chosen — so only the + * parameters (day, time, custom rule) are editable here. */ export function ScheduleSentence({ rrule, onRruleChange, timezone, nextRun, - className, disabled, }: ScheduleSentenceProps) { - const [state, setState] = useState(() => + const [state, setState] = useState(() => stateFromRrule(rrule), ); // Resync when the rrule changes underneath us (remote edit, version @@ -64,131 +64,125 @@ export function ScheduleSentence({ onRruleChange(serialized); }; - const update = (patch: Partial) => { + const update = (patch: Partial) => { const next = { ...state, ...patch }; - if (patch.kind === "custom" && state.kind !== "custom") { - // Entering Custom mode: seed from the current saved rule (a stale - // draft from a prior visit would silently mismatch what's persisted). - next.customRrule = rrule; - } setState(next); - // Custom text commits on blur/Enter once it validates; presets are - // always complete rules. - if (next.kind !== "custom") emit(rruleFromState(next)); + if (next.kind !== "custom") { + emit(rruleFromState(next)); + return; + } + // Custom commits on every keystroke that validates — the rows are drafts + // until "Save triggers", so this only moves the sentence's "Next run" + // live; invalid intermediate states keep the last valid rule. + const draft = next.customRrule.trim(); + if (draft && draft !== rrule && !rruleProblem(draft)) emit(draft); }; + const timeValue = formatTimeInputValue(state.hour, state.minute); + const timeOptions = useMemo(() => { + if (state.minute === 0) return HOUR_OPTIONS; + // The list offers whole hours, but a rule written elsewhere (CLI, MCP) + // can carry minutes — keep that value selectable so the chip shows it. + const options = [...HOUR_OPTIONS]; + options.splice(state.hour + 1, 0, { value: timeValue, label: timeValue }); + return options; + }, [state.hour, state.minute, timeValue]); + const customDraft = state.customRrule.trim(); const customProblem = useMemo(() => rruleProblem(customDraft), [customDraft]); // The saved rule itself can be exhausted (a run-once schedule that already // ran); that is history, not an edit gone wrong, so only a changed draft // gets the complaint. const draftEdited = customDraft !== "" && customDraft !== rrule; + // Clearing a saved rule is an edit that cannot save, but it has no problem + // to report — an empty field parses as nothing at all — so it needs saying + // separately, or the row sits blank under the previous rule's next run. + const draftCleared = customDraft === "" && rrule !== ""; + const showsProblem = + state.kind === "custom" && + (draftCleared || (draftEdited && !!customProblem)); - const commitCustom = () => { - if (!draftEdited || customProblem) return; - emit(customDraft); - }; - - const showsDay = state.kind === "weekly"; - const showsTime = - state.kind === "daily" || - state.kind === "weekdays" || - state.kind === "weekly"; + const showsTime = state.kind === "daily" || state.kind === "weekly"; + // A fragment, not a wrapper: the row this sits in is the flex container that + // wraps, and a nested one is a single item that cannot share a line with the + // row's icon — it drops to its own line and takes the whole sentence with it. return ( -
-
- update({ kind: value as PresetKind })} - /> - - {showsDay && ( - <> - - - on - - - update({ day: value as Weekday })} - /> - - )} - - {showsTime && ( - <> - - - at - - - { - const parsed = parseTimeInputValue(event.target.value); - if (parsed) update(parsed); - }} - /> - - )} - - {/* Read-only: the zone is captured from the browser when the trigger - is created. Rebinding it to whoever is looking would silently - move when the automation fires — create it in Los Angeles, open - it from London, and an 11:00 job becomes a 19:00 one. */} - - {timezoneAbbreviation(timezone)} - + <> + {state.kind === "hourly" && ( + Every hour + )} + {state.kind === "daily" && Every day} + {state.kind === "weekly" && ( + <> + Every week + on + update({ day: value as Weekday })} + /> + + )} - {nextRun && ( - {nextRun} - )} -
+ {showsTime && ( + <> + at + { + const parsed = parseTimeInputValue(value); + if (parsed) update(parsed); + }} + /> + {/* Read-only: the zone is captured from the browser when the + trigger is created. Rebinding it to whoever is looking would + silently move when the automation fires — create it in Los + Angeles, open it from London, and an 11:00 job becomes a + 19:00 one. */} + + {timezoneAbbreviation(timezone)} + + + )} {state.kind === "custom" && ( -
- + Custom schedule + update({ customRrule: event.target.value })} - onBlur={commitCustom} - onKeyDown={(event) => { - if (event.key === "Enter") commitCustom(); - }} /> - {draftEdited && customProblem && ( - - {customProblem === "exhausted" ? ( - - No upcoming runs — changes aren't saved - - ) : ( - - Invalid recurrence rule — changes aren't saved - - )} - - )} -
+ + )} + + {/* The error takes the "Next run" slot rather than a second line: a + rule that won't save has no next run, and showing both at once + reads as a contradiction. */} + {showsProblem ? ( + + {draftCleared + ? "Enter a recurrence rule — changes aren't saved" + : customProblem === "exhausted" + ? "No upcoming runs — changes aren't saved" + : "Invalid recurrence rule — changes aren't saved"} + + ) : ( + nextRun && ( + + {nextRun} + + ) )} -
+ ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/scheduleState.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/ScheduleSentence/scheduleState.ts similarity index 84% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/scheduleState.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/ScheduleSentence/scheduleState.ts index 1de432a1f5..3c62ed9b3b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/scheduleState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/ScheduleSentence/scheduleState.ts @@ -7,7 +7,7 @@ import { export type PresetKind = PresetMatch["kind"]; -export interface SchedulePickerState { +export interface ScheduleState { kind: PresetKind; hour: number; minute: number; @@ -18,25 +18,24 @@ export interface SchedulePickerState { export const PRESET_OPTIONS: { value: PresetKind; label: string }[] = [ { value: "hourly", label: "Hourly" }, { value: "daily", label: "Daily" }, - { value: "weekdays", label: "Weekdays" }, { value: "weekly", label: "Weekly" }, { value: "custom", label: "Custom" }, ]; export const DAY_OPTIONS: { value: Weekday; label: string }[] = [ + { value: "SU", label: "Sunday" }, { value: "MO", label: "Monday" }, { value: "TU", label: "Tuesday" }, { value: "WE", label: "Wednesday" }, { value: "TH", label: "Thursday" }, { value: "FR", label: "Friday" }, { value: "SA", label: "Saturday" }, - { value: "SU", label: "Sunday" }, ]; /** Derive the picker's structured state from an RRULE string. */ -export function stateFromRrule(rrule: string): SchedulePickerState { +export function stateFromRrule(rrule: string): ScheduleState { const match = matchPreset(rrule); - const base: SchedulePickerState = { + const base: ScheduleState = { kind: match.kind, hour: 9, minute: 0, @@ -45,7 +44,6 @@ export function stateFromRrule(rrule: string): SchedulePickerState { }; switch (match.kind) { case "daily": - case "weekdays": return { ...base, hour: match.hour, minute: match.minute }; case "weekly": return { @@ -62,7 +60,7 @@ export function stateFromRrule(rrule: string): SchedulePickerState { } /** Serialize the picker state back into an RRULE string. */ -export function rruleFromState(state: SchedulePickerState): string { +export function rruleFromState(state: ScheduleState): string { switch (state.kind) { case "hourly": return buildRrule({ kind: "hourly" }); @@ -72,12 +70,6 @@ export function rruleFromState(state: SchedulePickerState): string { hour: state.hour, minute: state.minute, }); - case "weekdays": - return buildRrule({ - kind: "weekdays", - hour: state.hour, - minute: state.minute, - }); case "weekly": return buildRrule({ kind: "weekly", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TemplateCard/TemplateCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TemplateCard/TemplateCard.tsx index 4670fdb513..ef5222c578 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TemplateCard/TemplateCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TemplateCard/TemplateCard.tsx @@ -1,3 +1,4 @@ +import { i18n } from "@superset/i18n"; import type { AutomationTemplate } from "../../templates"; interface TemplateCardProps { @@ -14,10 +15,10 @@ export function TemplateCard({ template, onSelect }: TemplateCardProps) { > {template.emoji} - {template.name} + {i18n._(template.name)} - {template.description} + {i18n._(template.description)} ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/TriggerSentence.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/TriggerSentence.tsx index 13bd2cfbd2..9bb9c7d325 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/TriggerSentence.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/TriggerSentence.tsx @@ -2,10 +2,14 @@ import type { DraftTrigger, TriggerProblem, } from "@superset/shared/automation-triggers"; +import { INTEGRATIONS } from "@superset/shared/integrations"; import { Button } from "@superset/ui/button"; import type { ReactNode } from "react"; -import { LuTrash2 } from "react-icons/lu"; +import { LuArrowUpRight, LuTrash2 } from "react-icons/lu"; +import { env } from "renderer/env.renderer"; import { type ProviderOptions, providerFor } from "../providers"; +import { triggerEventLabel } from "../providers/eventLabel"; +import type { OptionGroupState } from "../providers/types"; import { CHIP_INVALID } from "./chipStyles"; interface TriggerSentenceProps { @@ -13,10 +17,18 @@ interface TriggerSentenceProps { onChange: (next: DraftTrigger) => void; onRemove: () => void; options: ProviderOptions; + optionState?: Record; /** This row's problems, already filtered to it by the editor. */ problems?: TriggerProblem[]; /** Trailing "Next run ..." text for a schedule row. */ nextRun?: ReactNode; + /** + * True when this provider needs an integration nobody has connected yet. + * The row collapses to the trigger's name and the way to fix it: with no + * connection there is nothing to populate the pickers, so a sentence full + * of empty ones would only ask for choices that cannot be made. + */ + requiresConnection?: boolean; disabled?: boolean; } @@ -33,8 +45,10 @@ export function TriggerSentence({ onChange, onRemove, options, + optionState, problems, nextRun, + requiresConnection, disabled, }: TriggerSentenceProps) { const config = trigger.config; @@ -45,31 +59,80 @@ export function TriggerSentence({ // could each be the empty one. const invalid = new Set((problems ?? []).map((p) => p.field)); + // The web app owns every connect flow, because that is where the browser + // session lives; this only has to point at the right page. + const webPath = INTEGRATIONS.find( + (integration) => integration.provider === provider.connectionProvider, + )?.webPath; + + // Always the first element of the right-hand cluster, so whatever follows + // it — nothing, or a Connect button — is what sits against the row's right + // padding, mirroring the icon's inset on the left. + const removeButton = ( + + ); + return ( -
- + // select-text: the renderer body sets user-select: none, and the + // sentence is prose that opts back in. +
+ {/* mr-1.5 on top of the row's gap-1.5 puts 12px after the icon. Wider + than the row's own 8px inset on purpose: the brand glyphs do not + fill their 16px box, so a gap that measures even reads tight. */} + - {provider.renderSentence(config, { - triggerId: trigger.id, - set: (patch) => - onChange({ ...trigger, config: { ...config, ...patch } as never }), - mark: (field) => (invalid.has(field) ? CHIP_INVALID : undefined), - options, - disabled, - nextRun, - })} + {requiresConnection ? ( + <> + + {triggerEventLabel(provider, config)} + + + Requires connection + + {removeButton} + {webPath && ( + + )} + + ) : ( + provider.renderSentence(config, { + triggerId: trigger.id, + set: (patch) => + onChange({ ...trigger, config: { ...config, ...patch } as never }), + mark: (field) => (invalid.has(field) ? CHIP_INVALID : undefined), + options, + // The one place a provider's group becomes its state; sentences + // never name the group, so they cannot name the wrong one. + state: provider.optionGroup + ? optionState?.[provider.optionGroup] + : undefined, + disabled, + nextRun, + }) + )} - + {!requiresConnection && removeButton}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/EndpointChip/EndpointChip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/EndpointChip/EndpointChip.tsx index 1858d23c9d..871fc307af 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/EndpointChip/EndpointChip.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/EndpointChip/EndpointChip.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; @@ -11,12 +12,19 @@ import { CHIP, CHIP_EMPTY } from "../../chipStyles"; export function EndpointChip({ url, method = "POST", - placeholder = "Save to get URL", + placeholder, }: { url: string | null; method?: string; placeholder?: string; }) { + const { t } = useLingui(); + const resolvedPlaceholder = + placeholder ?? + t({ + id: "dashboard.automations.endpointChip.saveToGetUrl", + message: "Save to get URL", + }); if (!url) { return ( ); } @@ -35,8 +43,20 @@ export function EndpointChip({ type="button" onClick={() => navigator.clipboard.writeText(url).then( - () => toast.success("URL copied"), - () => toast.error("Copy failed"), + () => + toast.success( + t({ + id: "dashboard.automations.endpointChip.urlCopiedToast", + message: "URL copied", + }), + ), + () => + toast.error( + t({ + id: "dashboard.automations.endpointChip.copyFailedToast", + message: "Copy failed", + }), + ), ) } className={cn(CHIP, "max-w-80 font-mono text-[12px]")} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.test.tsx new file mode 100644 index 0000000000..44c5f60304 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.test.tsx @@ -0,0 +1,183 @@ +import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// happy-dom over the preloaded plain-object document — ScopeChip renders a +// Radix popover and a cmdk list, both of which need a real DOM. Bun runs test +// files sequentially in one process and happy-dom's globals are process-wide, +// so we MUST unregister in afterAll to restore the shared mock document for the +// other renderer suites. +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { act, cleanup, render, within } = await import("@testing-library/react"); +const { ScopeChip } = await import("./ScopeChip"); + +type Scope = Parameters[0]["scope"]; + +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +const OPTIONS = [ + { id: "1", label: "alpha" }, + { id: "2", label: "beta" }, +]; + +/** + * Renders the chip and opens its popover, which is where everything lives. + * + * Queries go through the render's own baseElement rather than the global + * `screen`: `screen` binds document.body when @testing-library/react is first + * imported, and another suite unregistering happy-dom leaves that binding + * pointing at a torn-down document — the module is cached, so re-importing does + * not rebind it. Everything here would then query an empty body. + */ +async function open(props: Partial[0]> = {}) { + const onChange = mock((_next: unknown) => {}); + let view!: ReturnType; + await act(async () => { + view = render( + , + ); + }); + const ui = within(view.baseElement as HTMLElement); + await act(async () => { + ui.getByRole("button").click(); + }); + return { onChange, ui }; +} + +describe("ScopeChip empty list", () => { + // The three faces of an empty list have to be distinguishable; for a long + // time they all read "Nothing to choose yet", which is how a revoked token + // looked exactly like an empty workspace. + test("says it is loading rather than empty", async () => { + const { ui } = await open({ + options: [], + state: { isLoading: true, isError: false, refetch: () => {} }, + }); + expect(ui.queryByText("Loading…")).not.toBeNull(); + expect(ui.queryByText("Nothing to choose yet")).toBeNull(); + }); + + test("offers a retry when the load failed", async () => { + const refetch = mock(() => {}); + const { ui } = await open({ + options: [], + state: { isLoading: false, isError: true, refetch }, + }); + const retry = ui.getByText("Couldn't load — retry"); + await act(async () => { + retry.click(); + }); + expect(refetch).toHaveBeenCalled(); + }); + + test("says nothing to choose when there is genuinely nothing", async () => { + const { ui } = await open({ + options: [], + state: { isLoading: false, isError: false, refetch: () => {} }, + }); + expect(ui.queryByText("Nothing to choose yet")).not.toBeNull(); + }); + + // Regression: Refresh used to be gated behind options.length > 0, so it + // disappeared exactly when the list needed reloading. + test("still offers Refresh with an empty list", async () => { + const refetch = mock(() => {}); + const { ui } = await open({ + options: [], + state: { isLoading: false, isError: false, refetch }, + }); + const refresh = ui.getByText(/Refresh/); + await act(async () => { + refresh.click(); + }); + expect(refetch).toHaveBeenCalled(); + }); + + test("shows no footer at all without option state", async () => { + const { ui } = await open({ options: [] }); + expect(ui.queryByText(/Refresh/)).toBeNull(); + }); +}); + +describe("ScopeChip selection", () => { + test("toggles ids on and off when multi-select", async () => { + const { onChange, ui } = await open({ scope: { mode: "list", ids: [] } }); + await act(async () => { + ui.getByText("alpha").click(); + }); + expect(onChange).toHaveBeenCalledWith({ mode: "list", ids: ["1"] }); + }); + + test("replaces the selection when single", async () => { + const { onChange, ui } = await open({ + single: true, + scope: { mode: "list", ids: ["1"] }, + }); + await act(async () => { + ui.getByText("beta").click(); + }); + // Replaced, not appended — the whole point of single-select. + expect(onChange).toHaveBeenCalledWith({ mode: "list", ids: ["2"] }); + }); + + test("offers Any only when allowed", async () => { + const allowed = await open({ allowAny: true }); + expect(allowed.ui.queryByText("Any thing")).not.toBeNull(); + cleanup(); + const refused = await open({ allowAny: false }); + expect(refused.ui.queryByText("Any thing")).toBeNull(); + }); + + test("offers Me only when allowed", async () => { + const allowed = await open({ allowMe: true }); + expect(allowed.ui.queryByText("Me")).not.toBeNull(); + cleanup(); + const refused = await open({ allowMe: false }); + expect(refused.ui.queryByText("Me")).toBeNull(); + }); +}); + +describe("ScopeChip rows", () => { + // The checkbox column already says these are channels; a # on every row is + // noise. The chip in the sentence still keeps it, where it identifies the + // thing rather than decorating a list. + test("drop a leading # that belongs to the sentence, not the list", async () => { + const { ui } = await open({ + options: [{ id: "C1", label: "#general" }], + }); + expect(ui.getByText("general")).toBeDefined(); + expect(ui.queryByText("#general")).toBeNull(); + }); + + test("show the group heading even for a single row", async () => { + const { ui } = await open({ + options: [{ id: "C1", label: "#general" }], + countNoun: { singular: "channel", plural: "channels" }, + }); + expect(ui.getByText("Channels")).toBeDefined(); + }); + + // Searching an owner org or a pasted id has to find the row rather than + // offering to add it a second time. + test("match on the hint as well as the label", async () => { + const { ui } = await open({ + options: [{ id: "10", label: "superset", hint: "superset-sh" }], + allowCustom: { placeholder: "Search..." }, + }); + expect(ui.getByText("superset-sh")).toBeDefined(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.tsx index bf36fab8d1..b7469431d1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/ScopeChip/ScopeChip.tsx @@ -1,15 +1,18 @@ -import { Trans } from "@lingui/react/macro"; import type { TriggerScope } from "@superset/shared/automation-triggers"; +import { Checkbox } from "@superset/ui/checkbox"; import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; -import { Input } from "@superset/ui/input"; + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { cn } from "@superset/ui/utils"; import { useState } from "react"; +import { LuCheck, LuPlus, LuRefreshCw } from "react-icons/lu"; +import type { OptionGroupState } from "../../../providers/types"; import type { ScopeOption } from "../../scopeOption"; import { ChipButton } from "../ChipButton"; @@ -18,14 +21,22 @@ function scopeLabel( options: ScopeOption[], emptyLabel: string, anyLabel: string, + countNoun: { singular: string; plural: string } | undefined, + loading: boolean, ): string { if (scope.mode === "any") return anyLabel; + if (scope.mode === "me") return "Me"; if (scope.ids.length === 0) return emptyLabel; if (scope.ids.length === 1) { const match = options.find((o) => o.id === scope.ids[0]); - return match?.label ?? scope.ids[0] ?? emptyLabel; + if (match) return match.label; + // The label list just hasn't arrived; the raw id would read as breakage. + if (loading) return "…"; + return scope.ids[0] ?? emptyLabel; } - return `${scope.ids.length} selected`; + return countNoun + ? `${scope.ids.length} ${scope.ids.length === 1 ? countNoun.singular : countNoun.plural}` + : `${scope.ids.length} selected`; } /** @@ -35,8 +46,15 @@ function scopeLabel( * selection matches nothing — that asymmetry is what stops a half-built trigger * firing on everything, so choosing "any" has to be deliberate. * - * `allowCustom` adds a field for values that are not pickable — an email - * address, a domain — which then sit in the list like any chosen option. + * A combobox, not a menu: the search stays pinned while the list scrolls, and + * what was selected when the picker opened is pulled into a "Selected" group at + * the top. The pull happens on open, not per toggle — rows that jumped between + * groups as you tick them would leave every next click aimed at a moved target. + * + * `allowCustom` folds pasted values into the same search field — a matching + * query filters the list, a foreign one (an email, a channel id) gets a row + * offering to use it verbatim. `state` distinguishes the three faces of an + * empty list: loading, provider unreachable, and genuinely nothing. */ export function ScopeChip({ scope, @@ -44,7 +62,13 @@ export function ScopeChip({ options, emptyLabel, anyLabel, + allowAny = true, + allowMe = false, + single = false, + countNoun, allowCustom, + action, + state, disabled, className, }: { @@ -53,16 +77,56 @@ export function ScopeChip({ options: ScopeOption[]; emptyLabel: string; anyLabel: string; + /** + * Whether "any" is offered in the picker. Off where it would overpromise — + * Slack channels, where events only arrive from channels the bot is in. + * `anyLabel` stays required either way: a config already saved as "any" + * still needs its chip labelled. + */ + allowAny?: boolean; + /** + * One value, not a set: picking a row replaces the selection and closes. + * For repositories, whose branches and labels can only be listed once a + * single repository is known. + */ + single?: boolean; + /** "2 channels" instead of the generic "2 selected". */ + countNoun?: { singular: string; plural: string }; + /** Search placeholder doubling as the invitation to paste/type a value. */ allowCustom?: { placeholder: string }; + /** + * An escape hatch in the footer for when the list itself is short of the + * right entry — "Add repositories" opening the GitHub App install flow. + */ + action?: { label: string; onSelect: () => void }; + /** + * Offer a pinned "Me" row under "Anyone", saved as `{mode:"me"}` and + * resolved to the automation owner's identity when each event arrives — + * never a copied id, so reconnecting a different account moves it. + */ + allowMe?: boolean; + state?: OptionGroupState; disabled?: boolean; className?: string; }) { const selected = scope.mode === "list" ? scope.ids : []; const isAny = scope.mode === "any"; + const isMe = scope.mode === "me"; const empty = scope.mode === "list" && !scope.ids.length; - const [custom, setCustom] = useState(""); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + const openChange = (next: boolean) => { + setOpen(next); + if (next) setQuery(""); + }; const toggle = (id: string) => { + if (single) { + onChange({ mode: "list", ids: [id] }); + setOpen(false); + return; + } const next = selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]; @@ -70,90 +134,225 @@ export function ScopeChip({ }; const addCustom = () => { - const value = custom.trim(); + const value = query.trim(); if (!value) return; if (!selected.includes(value)) { onChange({ mode: "list", ids: [...selected, value] }); } - setCustom(""); + setQuery(""); }; - // Typed values that no option describes still need a row to be unticked. - const customSelected = selected.filter( - (id) => !options.some((option) => option.id === id), + // Live, not snapshotted on open: ticking a row moves it into "Selected" + // right away, so the group always states the truth. + const selectedRows = selected.map((id) => { + const option = options.find((o) => o.id === id); + return { id, label: option?.label ?? id, hint: option?.hint }; + }); + const restRows = options.filter((option) => !selected.includes(option.id)); + + const trimmed = query.trim(); + const offersCustom = + allowCustom !== undefined && + trimmed !== "" && + !selected.includes(trimmed) && + !options.some((option) => option.id === trimmed); + + // Capitalized for a group heading: "channels" → "Channels". + const restHeading = countNoun + ? countNoun.plural.charAt(0).toUpperCase() + countNoun.plural.slice(1) + : "Options"; + + const row = ({ + id, + label, + hint, + }: { + id: string; + label: string; + hint?: string; + }) => ( + toggle(id)} + > + {single ? ( + + ) : ( + + )} + {/* The checkbox column already says these are channels; the # prefix + on every row is noise the sentence chip still keeps. */} + {label.replace(/^#/, "")} + {hint && {hint}} + ); return ( - - + + - - - - onChange(isAny ? { mode: "list", ids: [] } : { mode: "any" }) - } - > - {anyLabel} - - {options.map((option) => ( - toggle(option.id)} - > - {option.label} - - ))} - {allowCustom && - customSelected.map((id) => ( - toggle(id)} - > - {id} - - ))} - {options.length === 0 && !allowCustom && ( - - - Nothing to choose yet - - - )} - {allowCustom && ( - <> - -
- setCustom(event.target.value)} - // The menu owns arrow keys and typeahead; the field keeps - // what it types. - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - addCustom(); - } - }} - className="h-7 text-[13px]" - /> + + {/* Clamped to the space Radix has on screen, with the list as the part + that gives: input and refresh stay put, the rows scroll. */} + + + + + {!offersCustom && No matches} + + {allowAny && !trimmed && ( + + onChange(isAny ? { mode: "list", ids: [] } : { mode: "any" }) + } + > + + {anyLabel} + + )} + + {allowMe && !trimmed && ( + + onChange(isMe ? { mode: "list", ids: [] } : { mode: "me" }) + } + > + + Me + + )} + + {/* One list when single: with nothing to accumulate there is no + selection to gather at the top, and a row that jumped groups + on click would move the target under the next one. */} + {single ? ( + options.length > 0 && ( + + {options.map((option) => + row({ + id: option.id, + label: option.label, + hint: option.hint, + }), + )} + + ) + ) : ( + <> + {selectedRows.length > 0 && ( + + {selectedRows.map(row)} + + )} + {restRows.length > 0 && ( + + {restRows.map(row)} + + )} + + )} + + {/* Always matches — its value is the query itself. */} + {offersCustom && ( + + Use "{trimmed}" + + )} + + {options.length === 0 && + (state?.isLoading ? ( + Loading… + ) : state?.isError ? ( + state.refetch()}> + Couldn't load — retry + + ) : ( + !allowCustom && ( + Nothing to choose yet + ) + ))} + + + {/* Below the scroll, not in it: reachable however long the list. + Both show with an empty roster — that is exactly when "Add + repositories" is the way forward and when a list that failed to + load needs retrying. */} + {(action || state) && ( +
+ {action && ( + + )} + {state && ( + + )}
- - )} - - + )} +
+
+ ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/SelectChip/SelectChip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/SelectChip/SelectChip.tsx index 65ca2b51e4..5bd457dac2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/SelectChip/SelectChip.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/SelectChip/SelectChip.tsx @@ -1,10 +1,10 @@ import { DropdownMenu, DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, + DropdownMenuItem, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; +import { LuCheck } from "react-icons/lu"; import { ChipButton } from "../ChipButton"; /** @@ -12,7 +12,9 @@ import { ChipButton } from "../ChipButton"; * * A dropdown rather than a Select: Select's trigger carries its own height, * padding and font size that a chip has to fight, and the sentence already - * speaks in dropdowns everywhere else. + * speaks in dropdowns everywhere else. The selected value is marked with a + * trailing check, the way SelectItem does it — the RadioItem dot indents + * every row for a gutter only one of them uses. */ export function SelectChip({ value, @@ -41,13 +43,20 @@ export function SelectChip({ - - {options.map((option) => ( - - {option.label} - - ))} - + {options.map((option) => ( + onChange(option.value)} + > + {option.label} + {option.value === value && ( + + + + )} + + ))} ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/TextFilterChip/TextFilterChip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/TextFilterChip/TextFilterChip.tsx index 5df3da14e2..730e4b3170 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/TextFilterChip/TextFilterChip.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/components/TextFilterChip/TextFilterChip.tsx @@ -33,11 +33,16 @@ export function TextFilterChip({ !disabled && setOpen(next)}> + {/* No `empty` styling: a null filter IS the value — "Any message" + is a deliberate wide-open match, not a blank to fill in, and + muting it made a configured chip read as disabled. */} + {/* "Matching" carries the verb the sentence around it does not: + a bare quoted string beside "Comment added" reads as the + comment, not as the filter applied to it. */} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/scopeOption.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/scopeOption.ts index 2a0c0eeb4d..c191da0121 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/scopeOption.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggerSentence/scopeOption.ts @@ -1,2 +1,12 @@ -/** One selectable value in a scope or actor chip. */ -export type ScopeOption = { id: string; label: string }; +/** + * One selectable value in a scope or actor chip. `botMember: false` marks a + * Slack channel the bot cannot read yet — pickable, but the trigger stays + * silent until someone invites the bot, which the editor warns about. + */ +export type ScopeOption = { + id: string; + label: string; + /** Muted context beside the label in picker rows — a repo's owner org. */ + hint?: string; + botMember?: boolean; +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggerMenuItems/TriggerMenuItems.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggerMenuItems/TriggerMenuItems.test.tsx new file mode 100644 index 0000000000..910588bb4e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggerMenuItems/TriggerMenuItems.test.tsx @@ -0,0 +1,100 @@ +import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// happy-dom over the preloaded plain-object document. Process-wide, so this +// unregisters in afterAll to leave the other renderer suites their document. +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { act, cleanup, render, within } = await import("@testing-library/react"); +const { DropdownMenu, DropdownMenuContent } = await import( + "@superset/ui/dropdown-menu" +); +const { TriggerMenuItems } = await import("./TriggerMenuItems"); +const { TRIGGER_PROVIDERS } = await import("../../providers"); + +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +/** + * The Add Trigger menu, for a plan that cannot add everything: + * + * ⏰ Scheduled ▸ + * ⌥ GitHub ▸ + * ⌗ Slack ▸ + * ⧉ Microsoft Teams [Enterprise] ← disabled, badged with the tier + * + * A badge rather than a sentence: the row is a choice being refused, and the + * tier is the reason, so it belongs on the row rather than under the menu. + */ +async function menu( + lockedLabel?: (provider: { kind: string }) => string | null, +) { + const onPick = mock(() => {}); + let view!: ReturnType; + await act(async () => { + view = render( + + + + + , + ); + }); + return { ui: within(view.baseElement as HTMLElement), onPick }; +} + +describe("a provider the plan cannot add", () => { + const lockTeams = (provider: { kind: string }) => + provider.kind === "microsoft_teams" ? "Enterprise" : null; + + test("is badged with the tier it needs", async () => { + const { ui } = await menu(lockTeams); + expect(ui.getByText("Enterprise")).toBeDefined(); + }); + + // Badged, not hidden: a provider that vanished would read as unsupported + // rather than as something a plan unlocks. + test("is still listed", async () => { + const { ui } = await menu(lockTeams); + expect(ui.getByText("Microsoft Teams")).toBeDefined(); + }); + + test("cannot be chosen", async () => { + const { ui } = await menu(lockTeams); + const item = ui.getByText("Microsoft Teams").closest("[role=menuitem]"); + expect(item?.getAttribute("data-disabled")).not.toBeNull(); + }); + + test("leaves the providers the plan allows alone", async () => { + const { ui } = await menu(lockTeams); + expect(ui.getByText("Slack")).toBeDefined(); + expect(ui.queryByText("Pro")).toBeNull(); + }); +}); + +describe("a menu with nothing locked", () => { + test("badges nothing", async () => { + const { ui } = await menu(() => null); + expect(ui.queryByText("Enterprise")).toBeNull(); + expect(ui.queryByText("Pro")).toBeNull(); + }); + + // A provider with one trigger is the trigger — no submenu to open first. + test("puts a single-trigger provider straight on the row", async () => { + const { ui } = await menu(() => null); + const webhook = ui + .getByText("Webhook triggered") + .closest("[role=menuitem]"); + expect(webhook).not.toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggerMenuItems/TriggerMenuItems.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggerMenuItems/TriggerMenuItems.tsx index e07dbea08f..57c694f4f9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggerMenuItems/TriggerMenuItems.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggerMenuItems/TriggerMenuItems.tsx @@ -1,4 +1,5 @@ import type { TriggerConfigInput } from "@superset/shared/automation-triggers"; +import { Badge } from "@superset/ui/badge"; import { DropdownMenuItem, DropdownMenuPortal, @@ -7,6 +8,7 @@ import { DropdownMenuSubTrigger, } from "@superset/ui/dropdown-menu"; import type { TriggerMenuEntry, TriggerProvider } from "../../providers"; +import { providerLabelText } from "../triggerMenu"; /** * The top level of the Add Trigger menu: one row per provider. @@ -23,14 +25,29 @@ import type { TriggerMenuEntry, TriggerProvider } from "../../providers"; export function TriggerMenuItems({ providers, onPick, + lockedLabel, }: { providers: TriggerProvider[]; onPick: (config: TriggerConfigInput) => void; + /** Tier badge ("Pro", "Enterprise") for a provider the plan can't add. */ + lockedLabel?: (provider: TriggerProvider) => string | null; }) { return ( <> {providers.map((provider) => { const Icon = provider.icon; + const badge = lockedLabel?.(provider); + if (badge) { + return ( + + + {providerLabelText(provider.label)} + + {badge} + + + ); + } const only = provider.menu.length === 1 ? provider.menu[0] : undefined; if (only && "create" in only) { @@ -40,7 +57,7 @@ export function TriggerMenuItems({ onSelect={() => onPick(only.create())} > - {provider.label} + {providerLabelText(provider.label)} ); } @@ -49,7 +66,7 @@ export function TriggerMenuItems({ - {provider.label} + {providerLabelText(provider.label)} @@ -79,8 +96,10 @@ function MenuEntries({ <> {entries.map((entry) => "children" in entry ? ( - - {entry.label} + + + {providerLabelText(entry.label)} + @@ -89,10 +108,10 @@ function MenuEntries({ ) : ( onPick(entry.create())} > - {entry.label} + {providerLabelText(entry.label)} ), )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx index 4acc4e6f17..1ff5dad143 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/TriggersEditor.tsx @@ -1,9 +1,6 @@ -import { Trans } from "@lingui/react/macro"; import { type DraftTrigger, - describeTriggerProblems, enabledTriggerKinds, - summarizeTriggerProblems, } from "@superset/shared/automation-triggers"; import { FEATURE_FLAGS } from "@superset/shared/constants"; import { Button } from "@superset/ui/button"; @@ -16,23 +13,50 @@ import { import { Input } from "@superset/ui/input"; import { Separator } from "@superset/ui/separator"; import { useFeatureFlagPayload } from "posthog-js/react"; -import { type ReactNode, useMemo, useState } from "react"; -import { LuCirclePlus, LuTriangleAlert } from "react-icons/lu"; -import { TRIGGER_PROVIDERS } from "../providers"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { LuPlus, LuTriangleAlert } from "react-icons/lu"; +import { useCurrentPlan } from "renderer/hooks/useCurrentPlan"; +import { providerFor, TRIGGER_PROVIDERS } from "../providers"; +import type { OptionGroupState } from "../providers/types"; +import { useProviderConnections } from "../providers/useProviderConnections"; import { useProviderOptions } from "../providers/useProviderOptions"; import { TriggerSentence } from "../TriggerSentence"; +import { RuntimeWarnings } from "./components/RuntimeWarnings"; +import { useTriggerDrafts } from "./hooks/useTriggerDrafts"; +import { collectRuntimeWarnings, lockedTierFor } from "./runtimeWarnings"; import { TriggerMenuItems } from "./TriggerMenuItems"; import { flattenTriggerMenu, matchesQuery } from "./triggerMenu"; +type ScheduleTriggerConfig = Extract< + DraftTrigger["config"], + { kind: "schedule" } +>; + interface TriggersEditorProps { triggers: DraftTrigger[]; /** Resolves once the set is written; rejects if it was refused. */ onChange: (next: DraftTrigger[]) => undefined | Promise; /** Whose integrations the pickable lists come from. */ organizationId: string; - /** Trailing "Next run ..." text for one schedule row, by trigger id. */ - renderNextRun?: (triggerId?: string) => ReactNode; + /** + * Trailing "Next run ..." text for a schedule row, computed from the draft + * config on screen — so unsaved rows have one too, and edits move it. + */ + renderNextRun?: (config: ScheduleTriggerConfig) => ReactNode; readOnly?: boolean; + /** + * Rendered between the trigger surface and the runtime warnings — the scope + * line ("in X on Y using Z"), so warnings read as footnotes to the whole + * setup rather than wedging into the middle of it. + */ + children?: ReactNode; } /** @@ -49,6 +73,7 @@ export function TriggersEditor({ organizationId, renderNextRun, readOnly, + children, }: TriggersEditorProps) { // Edited locally and saved on request, unlike the rest of this page. // @@ -57,28 +82,59 @@ export function TriggersEditor({ // meant a new row was saved, refused, and dropped on the next render. Saving // silently once it happened to become valid is no better: nothing tells you // which edit crossed the line, or that anything was written at all. - const [drafts, setDrafts] = useState(triggers); - const options = useProviderOptions(organizationId, drafts); - const [dirty, setDirty] = useState(false); - const savedKey = JSON.stringify(triggers); - const [prevSavedKey, setPrevSavedKey] = useState(savedKey); - if (savedKey !== prevSavedKey) { - setPrevSavedKey(savedKey); - // Adopt what was saved — it carries the ids the server assigned — unless - // there are edits here, which by definition were never sent. - if (!dirty) setDrafts(triggers); - } + const optionStateRef = useRef>({}); + // Saving joins the bot to the public channels a Slack trigger watches, which + // flips `botMember` on the cached channel list. Without a refetch the + // membership warning outlives the save that fixed it. + const saveTriggers = useCallback( + async (next: DraftTrigger[]) => { + const result = await onChange(next); + optionStateRef.current.slack?.refetch(); + return result; + }, + [onChange], + ); - const problems = useMemo(() => describeTriggerProblems(drafts), [drafts]); + const { + drafts, + dirty, + saving, + shownProblems, + banner, + edit, + add, + save, + discard, + } = useTriggerDrafts(triggers, saveTriggers); + const { options, state: optionState } = useProviderOptions( + organizationId, + drafts, + ); + // Read through a ref: the save handler is defined before this hook runs, and + // it only needs whichever refetch exists by the time a save resolves. + useEffect(() => { + optionStateRef.current = optionState; + }, [optionState]); + + // Unlike problems, these show without waiting for a save attempt: they + // describe the world (a channel the bot is not in), not an unfinished edit, + // and the person who can fix them may not be the one editing. + const { plan } = useCurrentPlan(); + const { connected, isPending: connectionsPending } = + useProviderConnections(organizationId); - // Nothing is wrong until someone says they are done. Every trigger is - // incomplete the instant it is added, so validating as you type marks a row - // before anyone has had the chance to fill it in — the complaint lands - // before the work. After a rejected save the problems stay live, so they - // clear as each one is fixed rather than only on the next attempt. - const [submitted, setSubmitted] = useState(false); - const shownProblems = submitted ? problems : []; - const banner = submitted ? summarizeTriggerProblems(problems) : null; + // Unknown is not disconnected: until the first answer lands, a row must not + // accuse a perfectly good integration of being missing. + const missingConnection = (config: DraftTrigger["config"]) => { + if (connectionsPending) return false; + const required = providerFor(config).connectionProvider; + return required !== undefined && !connected[required]; + }; + + const runtimeWarnings = useMemo( + () => collectRuntimeWarnings(drafts, options, plan), + [drafts, options, plan], + ); const enabledKinds = useFeatureFlagPayload( FEATURE_FLAGS.AUTOMATION_EVENT_TRIGGERS, @@ -91,45 +147,19 @@ export function TriggersEditor({ }, [enabledKinds]); const [query, setQuery] = useState(""); - const leaves = useMemo(() => flattenTriggerMenu(providers), [providers]); + // Locked providers stay in the menu — the tier badge answers "where's + // Slack?" — but can't be added, so search doesn't index them. + const leaves = useMemo( + () => + flattenTriggerMenu( + providers.filter((provider) => !lockedTierFor(provider, plan)), + ), + [providers, plan], + ); const results = query ? leaves.filter((leaf) => matchesQuery(leaf, query)) : []; - const edit = (next: DraftTrigger[]) => { - setDrafts(next); - setDirty(true); - }; - - const [saving, setSaving] = useState(false); - - const save = async () => { - // Always clickable: the button is what asks for validation, so disabling - // it while the set is invalid would leave no way to find out why. - setSubmitted(true); - if (problems.length > 0) return; - - setSaving(true); - try { - await onChange(drafts); - setDirty(false); - setSubmitted(false); - } catch { - // Stay dirty and keep the edits: this editor holds the only copy of - // them, and the mutation has already reported why it failed. - } finally { - setSaving(false); - } - }; - - const discard = () => { - setDrafts(triggers); - setDirty(false); - setSubmitted(false); - }; - - const add = (config: DraftTrigger["config"]) => edit([...drafts, { config }]); - return (
{/* The section label lives here rather than in the page, so the actions @@ -137,11 +167,7 @@ export function TriggersEditor({ Save that trails the rows drifts down the page as triggers are added, and takes the reason it was refused with it. */}
- - - Triggers - - + Triggers {banner && (

@@ -160,9 +186,7 @@ export function TriggersEditor({ disabled={saving} className="h-7 text-[13px]" > - - Discard - + Discard

)} @@ -197,12 +213,14 @@ export function TriggersEditor({ } onRemove={() => edit(drafts.filter((_, i) => i !== index))} options={options} + optionState={optionState} problems={shownProblems.filter((p) => p.index === index)} nextRun={ trigger.config.kind === "schedule" - ? renderNextRun?.(trigger.id) + ? renderNextRun?.(trigger.config) : undefined } + requiresConnection={missingConnection(trigger.config)} disabled={readOnly} /> ))} @@ -221,12 +239,10 @@ export function TriggersEditor({ type="button" variant="ghost" size="sm" - className="mb-1.5 h-10 w-full justify-start gap-2 rounded-[8px] px-2 font-normal text-[13px] text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground" + className="h-10 w-full justify-start gap-1.5 rounded-[8px] px-2 font-normal text-[13px] text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground" > - - - Add Trigger - + + Add Trigger @@ -273,18 +289,27 @@ export function TriggersEditor({ })} {results.length === 0 && ( - - No matching trigger - + No matching trigger )} ) : ( - + lockedTierFor(provider, plan)} + /> )}
+ + {children} + + {/* Below the surface and the scope line, like the save banner is above + them: these outlive any save, so they cannot live in the + submit-gated banner. */} +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/components/RuntimeWarnings/RuntimeWarnings.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/components/RuntimeWarnings/RuntimeWarnings.test.tsx new file mode 100644 index 0000000000..a9f8b5c604 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/components/RuntimeWarnings/RuntimeWarnings.test.tsx @@ -0,0 +1,82 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// happy-dom over the preloaded plain-object document. Process-wide, so this +// unregisters in afterAll to leave the other renderer suites their document. +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { act, cleanup, render, within } = await import("@testing-library/react"); +const { RuntimeWarnings } = await import("./RuntimeWarnings"); + +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +/** + * How a standing warning presents, below the rows and the scope line: + * + * ⚠ This trigger will not run for messages in #access-requests + * until @Superset is invited. + * + * Amber rather than destructive, and no banner: the config is valid and saves + * fine. These describe the world, not an unfinished edit, so they must not + * read as an error the editor is refusing over. + */ +async function warnings(list: string[]) { + let view!: ReturnType; + await act(async () => { + view = render(); + }); + return { + ui: within(view.baseElement as HTMLElement), + container: view.container, + }; +} + +describe("standing warnings", () => { + test("say what will not run, and what to do about it", async () => { + const text = + "This trigger will not run for messages in #access-requests until @Superset is invited."; + const { ui } = await warnings([text]); + expect(ui.getByText(text)).toBeDefined(); + }); + + test("show one line per warning", async () => { + const { container } = await warnings(["first warning", "second warning"]); + expect(container.querySelectorAll("p")).toHaveLength(2); + }); + + // Deduplication is not done here — two rows watching the same channel earn + // the same sentence, and TriggersEditor collapses them through a Set before + // this ever sees them. Asserting it here would only restate its input. + + test("carry the warning icon", async () => { + const { container } = await warnings(["a warning"]); + expect(container.querySelector("svg")).not.toBeNull(); + }); + + // Amber, not destructive: nothing is being refused. + test("read as a caution rather than an error", async () => { + const { container } = await warnings(["a warning"]); + const line = container.querySelector("p"); + expect(line?.className).toContain("amber"); + expect(line?.className).not.toContain("destructive"); + }); + + // The renderer body sets user-select: none, and a warning naming a channel + // is something people copy out. + test("can be selected and copied", async () => { + const { container } = await warnings(["a warning"]); + expect(container.querySelector("p")?.className).toContain("select-text"); + }); + + test("take up no space when there are none", async () => { + const { container } = await warnings([]); + expect(container.innerHTML).toBe(""); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/components/RuntimeWarnings/RuntimeWarnings.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/components/RuntimeWarnings/RuntimeWarnings.tsx new file mode 100644 index 0000000000..c4bcac9c2d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/components/RuntimeWarnings/RuntimeWarnings.tsx @@ -0,0 +1,28 @@ +import { LuTriangleAlert } from "react-icons/lu"; + +/** + * Facts about the outside world that stop a valid-looking trigger from firing + * — a Slack channel the bot is not in, a "Me" filter with no account behind it. + * + * Not problems: the config is fine and saves, so these are standing statements + * rather than anything blocking, and they show without waiting for a save + * attempt. The person who can fix one is often not the person editing. + */ +export function RuntimeWarnings({ warnings }: { warnings: string[] }) { + if (warnings.length === 0) return null; + return ( +
+ {warnings.map((warning) => ( +

+ + {warning} +

+ ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/components/RuntimeWarnings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/components/RuntimeWarnings/index.ts new file mode 100644 index 0000000000..03eabfda7f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/components/RuntimeWarnings/index.ts @@ -0,0 +1 @@ +export { RuntimeWarnings } from "./RuntimeWarnings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/hooks/useTriggerDrafts/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/hooks/useTriggerDrafts/index.ts new file mode 100644 index 0000000000..19d11266b4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/hooks/useTriggerDrafts/index.ts @@ -0,0 +1 @@ +export { useTriggerDrafts } from "./useTriggerDrafts"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/hooks/useTriggerDrafts/useTriggerDrafts.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/hooks/useTriggerDrafts/useTriggerDrafts.test.ts new file mode 100644 index 0000000000..a618921eac --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/hooks/useTriggerDrafts/useTriggerDrafts.test.ts @@ -0,0 +1,219 @@ +import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// happy-dom over the preloaded plain-object document. Process-wide, so this +// unregisters in afterAll to leave the other renderer suites their document. +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { act, cleanup, renderHook } = await import("@testing-library/react"); +const { useTriggerDrafts } = await import("./useTriggerDrafts"); + +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +/** + * When the editor may write, and what it says while it may not. + * + * ┌ Triggers ─────────────────── Discard Save triggers ┐ + * │ rows… │ + * └──────────────────────────────────────────────────────┘ + * + * The set saves on request rather than as you type, because a trigger is + * invalid the moment it is added and the API rejects the whole set: autosaving + * meant a new row was saved, refused, and dropped on the next render. + */ + +/** Valid: an hourly schedule needs nothing filled in. */ +const schedule = (id?: string) => ({ + ...(id ? { id } : {}), + config: { + kind: "schedule" as const, + rrule: "FREQ=HOURLY", + dtstart: "2026-01-01T00:00:00.000Z", + timezone: "UTC", + }, +}); + +/** Invalid until a repository is chosen, which is how every GitHub row starts. */ +const unfinishedGithub = () => ({ + config: { + kind: "github" as const, + event: "pull_request.opened", + repositories: { mode: "list" as const, ids: [] as string[] }, + branches: { mode: "any" as const }, + labels: { mode: "any" as const }, + actor: { mode: "any" as const }, + includeForks: false, + }, +}); + +const setup = ( + triggers: unknown[] = [], + onChange: (next: never) => undefined | Promise = () => undefined, +) => + renderHook( + ({ saved }: { saved: unknown[] }) => + useTriggerDrafts(saved as never, onChange as never), + { initialProps: { saved: triggers } }, + ); + +describe("an editor nobody has touched", () => { + test("is not dirty", () => { + const { result } = setup([schedule("t1")]); + expect(result.current.dirty).toBe(false); + }); + + test("holds the saved rows", () => { + const { result } = setup([schedule("t1")]); + expect(result.current.drafts).toHaveLength(1); + }); + + // The complaint must not land before the work: every trigger is incomplete + // the instant it is added. + test("says nothing is wrong, even about a row that could not save", () => { + const { result } = setup([unfinishedGithub()]); + expect(result.current.shownProblems).toEqual([]); + expect(result.current.banner).toBeNull(); + }); +}); + +describe("adding a row", () => { + test("makes the editor dirty", () => { + const { result } = setup([]); + act(() => { + result.current.add(schedule().config as never); + }); + expect(result.current.dirty).toBe(true); + expect(result.current.drafts).toHaveLength(1); + }); + + test("still says nothing is wrong until a save is attempted", () => { + const { result } = setup([]); + act(() => { + result.current.add(unfinishedGithub().config as never); + }); + expect(result.current.shownProblems).toEqual([]); + }); +}); + +describe("saving a valid set", () => { + test("writes it and comes back clean", async () => { + const onChange = mock(() => Promise.resolve()); + const { result } = setup([], onChange); + act(() => { + result.current.add(schedule().config as never); + }); + await act(async () => { + await result.current.save(); + }); + expect(onChange).toHaveBeenCalled(); + expect(result.current.dirty).toBe(false); + expect(result.current.shownProblems).toEqual([]); + }); +}); + +describe("saving a set that cannot be written", () => { + // The button is what asks for validation, so refusing to act while the set + // is invalid would leave no way to find out why. + test("does not write, and says what is wrong", async () => { + const onChange = mock(() => Promise.resolve()); + const { result } = setup([unfinishedGithub()], onChange); + await act(async () => { + await result.current.save(); + }); + expect(onChange).not.toHaveBeenCalled(); + expect(result.current.shownProblems.length).toBeGreaterThan(0); + expect(result.current.banner).not.toBeNull(); + }); + + // They clear as each one is fixed, rather than only on the next attempt. + test("keeps the complaints live as they are fixed", async () => { + const { result } = setup([unfinishedGithub()]); + await act(async () => { + await result.current.save(); + }); + expect(result.current.shownProblems.length).toBeGreaterThan(0); + + act(() => { + result.current.edit([schedule()] as never); + }); + expect(result.current.shownProblems).toEqual([]); + expect(result.current.banner).toBeNull(); + }); +}); + +describe("a save the server refuses", () => { + // This editor holds the only copy of the edits, and the mutation has + // already reported why it failed. + test("keeps the edits and stays dirty", async () => { + const onChange = mock(() => Promise.reject(new Error("refused"))); + const { result } = setup([], onChange); + act(() => { + result.current.add(schedule().config as never); + }); + await act(async () => { + await result.current.save(); + }); + expect(result.current.drafts).toHaveLength(1); + expect(result.current.dirty).toBe(true); + expect(result.current.saving).toBe(false); + }); +}); + +describe("discarding", () => { + test("puts the saved rows back", () => { + const { result } = setup([schedule("t1")]); + act(() => { + result.current.add(schedule().config as never); + }); + expect(result.current.drafts).toHaveLength(2); + + act(() => { + result.current.discard(); + }); + expect(result.current.drafts).toHaveLength(1); + expect(result.current.dirty).toBe(false); + }); + + test("clears complaints from an earlier attempt", async () => { + const { result } = setup([schedule("t1")]); + act(() => { + result.current.add(unfinishedGithub().config as never); + }); + await act(async () => { + await result.current.save(); + }); + expect(result.current.shownProblems.length).toBeGreaterThan(0); + + act(() => { + result.current.discard(); + }); + expect(result.current.shownProblems).toEqual([]); + }); +}); + +describe("the saved set changing underneath", () => { + // It carries the ids the server assigned, so an untouched editor adopts it. + test("is adopted when there is nothing to lose", () => { + const { result, rerender } = setup([schedule("t1")]); + rerender({ saved: [schedule("t1"), schedule("t2")] }); + expect(result.current.drafts).toHaveLength(2); + }); + + // Edits here were by definition never sent, so adopting would discard them. + test("is ignored while there are unsaved edits", () => { + const { result, rerender } = setup([schedule("t1")]); + act(() => { + result.current.add(schedule().config as never); + }); + rerender({ saved: [schedule("t1"), schedule("t2"), schedule("t3")] }); + expect(result.current.drafts).toHaveLength(2); + expect(result.current.dirty).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/hooks/useTriggerDrafts/useTriggerDrafts.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/hooks/useTriggerDrafts/useTriggerDrafts.ts new file mode 100644 index 0000000000..49d9f20d8c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/hooks/useTriggerDrafts/useTriggerDrafts.ts @@ -0,0 +1,89 @@ +import { + type DraftTrigger, + describeTriggerProblems, + summarizeTriggerProblems, +} from "@superset/shared/automation-triggers"; +import { useMemo, useState } from "react"; + +/** + * The editor's drafts, and the rules about when they may be written. + * + * Held locally and saved on request, unlike the rest of the page. A trigger is + * invalid the moment it is added — "Comment added" with no repository chosen + * yet — and the API rejects the whole set, so autosaving meant a new row was + * saved, refused, and dropped on the next render. Saving silently once it + * happened to become valid is no better: nothing tells you which edit crossed + * the line, or that anything was written at all. + * + * Separate from the component because none of this needs a query, a plan or a + * feature flag to be true, and all of it is worth being sure about. + */ +export function useTriggerDrafts( + triggers: DraftTrigger[], + onChange: (next: DraftTrigger[]) => undefined | Promise, +) { + const [drafts, setDrafts] = useState(triggers); + const [dirty, setDirty] = useState(false); + + const savedKey = JSON.stringify(triggers); + const [prevSavedKey, setPrevSavedKey] = useState(savedKey); + if (savedKey !== prevSavedKey) { + setPrevSavedKey(savedKey); + // Adopt what was saved — it carries the ids the server assigned — unless + // there are edits here, which by definition were never sent. + if (!dirty) setDrafts(triggers); + } + + const problems = useMemo(() => describeTriggerProblems(drafts), [drafts]); + + // Nothing is wrong until someone says they are done. Every trigger is + // incomplete the instant it is added, so validating as you type marks a row + // before anyone has had the chance to fill it in — the complaint lands + // before the work. After a rejected save the problems stay live, so they + // clear as each one is fixed rather than only on the next attempt. + const [submitted, setSubmitted] = useState(false); + const [saving, setSaving] = useState(false); + + const edit = (next: DraftTrigger[]) => { + setDrafts(next); + setDirty(true); + }; + + const save = async () => { + // Always allowed: the button is what asks for validation, so refusing to + // act while the set is invalid would leave no way to find out why. + setSubmitted(true); + if (problems.length > 0) return; + + setSaving(true); + try { + await onChange(drafts); + setDirty(false); + setSubmitted(false); + } catch { + // Stay dirty and keep the edits: this editor holds the only copy of + // them, and the mutation has already reported why it failed. + } finally { + setSaving(false); + } + }; + + const discard = () => { + setDrafts(triggers); + setDirty(false); + setSubmitted(false); + }; + + return { + drafts, + dirty, + saving, + /** Marks on the rows — empty until a save has been attempted. */ + shownProblems: submitted ? problems : [], + banner: submitted ? summarizeTriggerProblems(problems) : null, + edit, + add: (config: DraftTrigger["config"]) => edit([...drafts, { config }]), + save, + discard, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/runtimeWarnings.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/runtimeWarnings.test.ts new file mode 100644 index 0000000000..64d0f9f9f9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/runtimeWarnings.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import { collectRuntimeWarnings } from "./runtimeWarnings"; + +/** + * Everything the editor says about the world beneath the rows: + * + * ⚠ Microsoft Teams triggers require the Enterprise plan. + * ⚠ This trigger will not run for messages in #secret until @Superset is + * invited. + * + * A set of rows goes in, the lines under them come out. Kept pure so the + * combinations can be varied here — the editor itself needs a plan and two + * live queries before it draws anything. + */ + +const CHANNELS = { + slack: { + channels: [ + { id: "C1", label: "#general", botMember: true }, + { id: "C2", label: "#secret", botMember: false }, + ], + }, +}; + +const slackRow = (ids: string[], id = "t1") => ({ + id, + config: { + kind: "slack" as const, + event: "message_in_channel", + messageFilter: null, + actor: { mode: "any" }, + channels: { mode: "list", ids }, + completionReaction: "white_check_mark", + }, +}); + +const teamsRow = { + id: "t9", + config: { + kind: "microsoft_teams" as const, + event: "channel_message", + teams: { mode: "any" }, + channels: { mode: "any" }, + actor: { mode: "any" }, + messageFilter: null, + }, +}; + +const scheduleRow = { + id: "t8", + config: { + kind: "schedule" as const, + rrule: "FREQ=HOURLY", + dtstart: "2026-01-01T00:00:00.000Z", + timezone: "UTC", + }, +}; + +describe("a row nothing is wrong with", () => { + test("earns no warning", () => { + expect( + collectRuntimeWarnings([slackRow(["C1"])] as never, CHANNELS, "pro"), + ).toEqual([]); + }); + + test("a schedule needs no plan at all", () => { + expect(collectRuntimeWarnings([scheduleRow] as never, {}, "free")).toEqual( + [], + ); + }); +}); + +describe("a row the plan will not run", () => { + // A downgraded organization keeps its rows rather than losing them, so the + // warning is the only thing saying why nothing fires. + test("names the tier a Pro trigger came from", () => { + expect( + collectRuntimeWarnings([slackRow(["C1"])] as never, CHANNELS, "free"), + ).toEqual(["Slack triggers require the Pro plan."]); + }); + + test("names Enterprise for a Teams trigger", () => { + expect(collectRuntimeWarnings([teamsRow] as never, {}, "pro")).toEqual([ + "Microsoft Teams triggers require the Enterprise plan.", + ]); + }); + + test("says nothing once the plan covers it", () => { + expect( + collectRuntimeWarnings([teamsRow] as never, {}, "enterprise"), + ).toEqual([]); + }); +}); + +describe("a row that will run but stay silent", () => { + test("says which channel it will miss", () => { + expect( + collectRuntimeWarnings( + [slackRow(["C1", "C2"])] as never, + CHANNELS, + "pro", + ), + ).toEqual([ + "This trigger will not run for messages in #secret until @Superset is invited.", + ]); + }); +}); + +describe("several rows at once", () => { + // Two rows watching the same channel earn the same sentence; saying it + // twice would read as two different problems. + test("say a shared warning once", () => { + expect( + collectRuntimeWarnings( + [slackRow(["C2"], "t1"), slackRow(["C2"], "t2")] as never, + CHANNELS, + "pro", + ), + ).toHaveLength(1); + }); + + test("keep warnings that differ", () => { + const warnings = collectRuntimeWarnings( + [slackRow(["C2"]), teamsRow] as never, + CHANNELS, + "pro", + ); + expect(warnings).toHaveLength(2); + }); + + // Both reasons hold at once, and hiding either would leave the other + // looking like the whole story. + test("say both when a row is above tier and silent", () => { + const warnings = collectRuntimeWarnings( + [slackRow(["C2"])] as never, + CHANNELS, + "free", + ); + expect(warnings).toEqual([ + "Slack triggers require the Pro plan.", + "This trigger will not run for messages in #secret until @Superset is invited.", + ]); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/runtimeWarnings.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/runtimeWarnings.ts new file mode 100644 index 0000000000..78ac90ab06 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/runtimeWarnings.ts @@ -0,0 +1,52 @@ +import type { DraftTrigger } from "@superset/shared/automation-triggers"; +import type { PlanTier } from "@superset/shared/billing"; +import { + planAllowsTriggerKind, + requiredPlanForTriggerKind, +} from "@superset/shared/billing"; +import { providerFor } from "../providers"; +import type { ProviderOptions, TriggerProvider } from "../providers/types"; + +/** + * The tier badge for a provider the viewer's plan can't add, or null when it + * can. The dispatcher enforces the same shared map — an above-tier trigger + * stays editable but never fires. + */ +export function lockedTierFor( + provider: TriggerProvider, + plan: PlanTier, +): string | null { + const required = requiredPlanForTriggerKind(provider.kind); + if (!required || planAllowsTriggerKind(plan, provider.kind)) return null; + return required === "enterprise" ? "Enterprise" : "Pro"; +} + +/** + * Everything the editor has to say about the world the rows point at: a plan + * that will not run them, and each provider's own reasons a valid trigger + * stays silent. + * + * Deduplicated, because two rows watching the same channel earn the same + * sentence and saying it twice reads as two different problems. Pure, so the + * cases can be varied without standing up the queries the editor needs to + * render — which is the only reason this is not inline where it is used. + */ +export function collectRuntimeWarnings( + drafts: DraftTrigger[], + options: ProviderOptions, + plan: PlanTier, +): string[] { + const seen = new Set(); + for (const draft of drafts) { + const provider = providerFor(draft.config); + // A downgraded organization keeps its rows; the warning names the tier + // they came from rather than hiding them. + const tier = lockedTierFor(provider, plan); + if (tier) seen.add(`${provider.label} triggers require the ${tier} plan.`); + for (const warning of provider.runtimeWarnings?.(draft.config, options) ?? + []) { + seen.add(warning); + } + } + return [...seen]; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/triggerMenu.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/triggerMenu.ts index c7dc62330e..67ef411f5b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/triggerMenu.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TriggersEditor/triggerMenu.ts @@ -1,3 +1,4 @@ +import { i18n } from "@superset/i18n"; import type { TriggerConfigInput } from "@superset/shared/automation-triggers"; import type { IconType } from "react-icons"; import { @@ -6,6 +7,11 @@ import { type TriggerProvider, } from "../providers"; +/** A provider label is a raw brand string or a msg() descriptor — render both. */ +export function providerLabelText(label: TriggerProvider["label"]): string { + return typeof label === "string" ? label : i18n._(label); +} + /** * A leaf of the Add Trigger menu, carrying the trail that leads to it so search * can show the path — "GitHub › PR review submitted › Approved" is what tells @@ -33,7 +39,7 @@ export function flattenTriggerMenu( provider.menu.length === 1 && "create" in (provider.menu[0] ?? {}); return flattenEntries( provider.menu, - single ? [] : [provider.label], + single ? [] : [providerLabelText(provider.label)], provider.icon, ); }); @@ -45,7 +51,7 @@ function flattenEntries( icon: IconType, ): TriggerMenuLeaf[] { return entries.flatMap((entry) => { - const path = [...trail, entry.label.replace(/…$/, "")]; + const path = [...trail, providerLabelText(entry.label).replace(/…$/, "")]; if ("children" in entry) return flattenEntries(entry.children, path, icon); return [{ path, icon, create: entry.create }]; }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/WorkspacePicker/WorkspacePicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/WorkspacePicker/WorkspacePicker.tsx index 767f79de89..27a29d68e0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/WorkspacePicker/WorkspacePicker.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/WorkspacePicker/WorkspacePicker.tsx @@ -92,12 +92,24 @@ export function WorkspacePicker({ const label = selected ? selected.name : resolving - ? "Loading…" + ? t({ + id: "dashboard.automations.workspacePicker.loading", + message: "Loading…", + }) : missing - ? "Workspace not found" + ? t({ + id: "dashboard.automations.workspacePicker.notFoundLabel", + message: "Workspace not found", + }) : projectId === null - ? "New session" - : "New workspace"; + ? t({ + id: "dashboard.automations.workspacePicker.newSessionLabel", + message: "New session", + }) + : t({ + id: "dashboard.automations.workspacePicker.newWorkspaceLabel", + message: "New workspace", + }); return ( // Guard the open state, not just the trigger: Radix opens on pointerdown, @@ -126,7 +138,12 @@ export function WorkspacePicker({ className="w-60 p-0" > - + set({ tags: isEmptyScope(v) ? { mode: "any" } : v })} - options={[]} - emptyLabel="Any tag" - anyLabel="Any tag" - allowCustom={{ placeholder: "Type a tag, press Enter" }} - disabled={disabled} - /> - ); - case "attendees": - return ( - - set({ attendees: isEmptyScope(v) ? { mode: "any" } : v }) - } - options={[]} - emptyLabel="Any attendee" - anyLabel="Any attendee" - allowCustom={{ placeholder: "Type an email, press Enter" }} - disabled={disabled} - /> - ); - case "nameFilter": - return ( - set({ nameFilter: v })} - emptyLabel="Any name" - placeholder="Contains this text..." - disabled={disabled} - /> - ); - case "endpoint": - // The URL carries the saved row's id, so a row that has not been - // saved yet has nothing to paste into Circleback. - return ( - - ); - case "signingSecret": - return ( - - ); - } -} - -export const circlebackProvider: TriggerProvider = { - kind: "circleback", - label: "Circleback", - icon: LuMic, - menu: CIRCLEBACK_MENU, - renderSentence: (config, ctx) => ( - renderSlot(config, slot, index, ctx)} - /> - ), -}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/components/SigningSecretChip/SigningSecretChip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/components/SigningSecretChip/SigningSecretChip.tsx deleted file mode 100644 index e5375e0a0a..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/components/SigningSecretChip/SigningSecretChip.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { Trans } from "@lingui/react/macro"; -import { errorMessage } from "@superset/i18n/errors"; -import { Button } from "@superset/ui/button"; -import { Input } from "@superset/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; -import { toast } from "@superset/ui/sonner"; -import { cn } from "@superset/ui/utils"; -import { useMutation } from "@tanstack/react-query"; -import { useParams } from "@tanstack/react-router"; -import { useState } from "react"; -import { LuKeyRound } from "react-icons/lu"; -import { apiTrpcClient } from "renderer/lib/api-trpc-client"; -import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import { CHIP, CHIP_EMPTY } from "../../../../TriggerSentence/chipStyles"; - -/** - * Circleback issues the signing secret when the URL is pasted into its - * automation editor, so this takes it back rather than revealing one of ours. - * The value goes straight to the trigger row's secret column — never through - * the config, which every member of the organization can read — and the chip - * shows only its prefix afterwards. - */ -export function SigningSecretChip({ - triggerId, - disabled, -}: { - triggerId?: string; - disabled?: boolean; -}) { - const { automationId } = useParams({ strict: false }); - const automation = cloudTrpc.automation.get.useQuery( - { id: automationId ?? "" }, - { enabled: Boolean(automationId) }, - ); - const utils = cloudTrpc.useUtils(); - const secretPrefix = automation.data?.triggers.find( - (t) => t.id === triggerId, - )?.secretPrefix; - - const [open, setOpen] = useState(false); - const [draft, setDraft] = useState(""); - - const save = useMutation({ - mutationFn: (secret: string) => - apiTrpcClient.automation.setTriggerSecret.mutate({ - triggerId: triggerId ?? "", - secret, - }), - onSuccess: () => { - setDraft(""); - setOpen(false); - toast.success("Signing secret saved"); - if (automationId) { - void utils.automation.get.invalidate({ id: automationId }); - } - }, - onError: (error) => - toast.error(errorMessage(error, "Failed to save secret")), - }); - - const submit = () => { - const secret = draft.trim(); - if (!secret || save.isPending) return; - save.mutate(secret); - }; - - return ( - !disabled && setOpen(next)}> - - - - - - -
- setDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") submit(); - }} - className="h-8 font-mono text-[13px]" - /> - -
-

- {secretPrefix ? ( - - {secretPrefix}… is set. Saving a new one replaces it. - - ) : ( - - Circleback shows this when you paste the URL into its automation. - Deliveries are rejected until it is set. - - )} -

-
-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/components/SigningSecretChip/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/components/SigningSecretChip/index.ts deleted file mode 100644 index 522892c3f1..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/components/SigningSecretChip/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SigningSecretChip } from "./SigningSecretChip"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/grammar.ts deleted file mode 100644 index cd423c535f..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/circleback/grammar.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { TriggerConfigInput } from "@superset/shared/automation-triggers"; -import type { TriggerMenuEntry } from "../types"; - -export type CirclebackConfig = Extract< - TriggerConfigInput, - { kind: "circleback" } ->; - -/** - * The one sentence a Circleback trigger reads as. Circleback sends one webhook - * per finished meeting, so there is one event and three optional narrowings — - * then the two chips Circleback's side needs: where to post, and what it signs - * with. - */ -export type Slot = - | "tags" - | "attendees" - | "nameFilter" - | "endpoint" - | "signingSecret"; - -export type SentencePart = { text: string } | { slot: Slot }; - -export const CIRCLEBACK_SENTENCE: SentencePart[] = [ - { text: "Meeting ended tagged" }, - { slot: "tags" }, - { text: "with attendee" }, - { slot: "attendees" }, - { text: "named like" }, - { slot: "nameFilter" }, - { slot: "endpoint" }, - { slot: "signingSecret" }, -]; - -export const CIRCLEBACK_MENU: TriggerMenuEntry[] = [ - { label: "Meeting ended", create: createCirclebackConfig }, -]; - -/** A new trigger: every narrowing wide open. */ -export function createCirclebackConfig(): CirclebackConfig { - return { - kind: "circleback", - event: "meeting.completed", - tags: { mode: "any" }, - attendees: { mode: "any" }, - nameFilter: null, - }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/BrandIcon/BrandIcon.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/BrandIcon/BrandIcon.tsx new file mode 100644 index 0000000000..312c1b58ab --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/BrandIcon/BrandIcon.tsx @@ -0,0 +1,28 @@ +import { cn } from "@superset/ui/utils"; +import type { IconType } from "react-icons"; + +/** + * A provider's real logo, for brands react-icons does not carry. + * + * `TriggerProvider.icon` is an `IconType`, so this returns a component with + * that shape rather than an element — call sites keep passing `className` and + * never learn whether they are rendering an svg or an image. The art comes + * from `renderer/assets/icons`, the same files the plugin catalog uses, so a + * brand has one set of marks in the app rather than one per surface. + * + * Unlike a glyph these do not inherit `currentColor`, which is correct for a + * full-colour mark and is why a generic stand-in was never the right answer. + */ +export function brandIcon(source: string, label: string): IconType { + return function BrandIcon({ className }) { + return ( + {label} + ); + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/BrandIcon/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/BrandIcon/index.ts new file mode 100644 index 0000000000..edec021b8d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/components/BrandIcon/index.ts @@ -0,0 +1 @@ +export { brandIcon } from "./BrandIcon"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/eventLabel.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/eventLabel.test.ts new file mode 100644 index 0000000000..a5664e90a8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/eventLabel.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { labelText, triggerEventLabel } from "./eventLabel"; +import { githubProvider } from "./github/github"; +import { slackProvider } from "./slack/slack"; + +/** + * What a row calls itself when its sentence is not worth showing — a trigger + * whose integration nobody has connected has nothing selectable in it. + * + * The name is read off the provider's own Add Trigger menu rather than + * declared a second time, so these assert that the menu remains the single + * source: a renamed menu entry renames the row with it. + */ +describe("triggerEventLabel", () => { + test("reads a nested event's name off the menu", () => { + const label = triggerEventLabel( + githubProvider as never, + { + kind: "github", + event: "pull_request.opened", + } as never, + ); + expect(label.length).toBeGreaterThan(0); + expect(label).not.toBe(labelText(githubProvider.label)); + }); + + test("falls back to the provider for an event the menu no longer names", () => { + expect( + triggerEventLabel( + githubProvider as never, + { + kind: "github", + event: "an_event_that_was_removed", + } as never, + ), + ).toBe(labelText(githubProvider.label)); + }); + + test("falls back to the provider for a config with no event at all", () => { + expect( + triggerEventLabel(slackProvider as never, { kind: "slack" } as never), + ).toBe(labelText(slackProvider.label)); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/eventLabel.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/eventLabel.ts new file mode 100644 index 0000000000..a13cbe0492 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/eventLabel.ts @@ -0,0 +1,52 @@ +import { i18n } from "@superset/i18n"; +import type { TriggerConfigInput } from "@superset/shared/automation-triggers"; +import type { TriggerMenuEntry, TriggerProvider } from "./types"; + +// Labels are plain strings in this branch's grammars and msg() descriptors in +// the converted ones; `i18n._` renders a descriptor and echoes a bare string. +export function labelText(label: string | { id: string }): string { + return typeof label === "string" ? label : i18n._(label); +} + +/** + * The short name of the trigger this config is — "Issue created", "Channel + * created", "Issue Created" for a nested one. + * + * Read off the provider's own Add Trigger menu rather than declared a second + * time: the menu already names every event, and a label that lived in two + * places would drift the moment someone renamed one. The provider's own name + * is left out because the row's icon already says whose trigger this is. + * + * Used where the editable sentence would be noise — a row whose integration + * is not connected has nothing selectable worth showing. + */ +export function triggerEventLabel( + provider: TriggerProvider, + config: TriggerConfigInput, +): string { + const event = (config as { event?: unknown }).event; + if (typeof event !== "string") return labelText(provider.label); + + const walk = ( + entries: TriggerMenuEntry[], + trail: string[], + ): string | null => { + for (const entry of entries) { + // The ellipsis is a menu affordance ("Issue…"), not part of a name. + const path = [...trail, labelText(entry.label).replace(/…$/, "")]; + if ("children" in entry) { + const found = walk(entry.children, path); + if (found) return found; + continue; + } + if ((entry.create() as { event?: unknown }).event === event) { + return path.join(" "); + } + } + return null; + }; + + return ( + walk(provider.menu as TriggerMenuEntry[], []) ?? labelText(provider.label) + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TokenField/TokenField.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TokenField/TokenField.test.tsx new file mode 100644 index 0000000000..a607e412ec --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TokenField/TokenField.test.tsx @@ -0,0 +1,185 @@ +import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// happy-dom over the preloaded plain-object document. Process-wide, so this +// unregisters in afterAll to leave the other renderer suites their document. +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { act, cleanup, fireEvent, render, within } = await import( + "@testing-library/react" +); +const { TokenField, SEPARATORS_WITH_SPACE } = await import("./TokenField"); + +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +/** + * Renders the field over a live `values` array, the way its parents do — the + * chip owns the scope and feeds it back, so a test that never re-renders would + * pass while a second commit silently dropped the first. + * + * Queries go through the render's baseElement, not the global `screen`, which + * binds document.body at import time and goes stale when another suite + * unregisters happy-dom. + */ +async function setup( + initial: string[] = [], + props: Partial[0]> = {}, +) { + let values = initial; + const onChange = mock((next: string[]) => { + values = next; + rerender(); + }); + let view!: ReturnType; + const element = () => ( + + ); + const rerender = () => view?.rerender(element()); + await act(async () => { + view = render(element()); + }); + const ui = within(view.baseElement as HTMLElement); + // Not by placeholder: it is deliberately blanked once the field holds a + // chip, so a placeholder query only finds an empty field. + const input = ui.getByRole("textbox"); + const typeAndCommit = async (text: string) => { + await act(async () => { + fireEvent.change(input, { target: { value: text } }); + }); + }; + return { + get values() { + return values; + }, + ui, + input, + typeAndCommit, + onChange, + }; +} + +describe("TokenField committing", () => { + test("a comma commits what precedes it", async () => { + const field = await setup(); + await field.typeAndCommit("alice,"); + expect(field.values).toEqual(["alice"]); + }); + + test("Enter commits, since there is no result list for it to pick from", async () => { + const field = await setup(); + await act(async () => { + fireEvent.change(field.input, { target: { value: "alice" } }); + fireEvent.keyDown(field.input, { key: "Enter" }); + }); + expect(field.values).toEqual(["alice"]); + }); + + // Leaving the field is the case that used to lose typed text outright. + test("leaving the field commits rather than discarding", async () => { + const field = await setup(); + await act(async () => { + fireEvent.change(field.input, { target: { value: "alice" } }); + fireEvent.blur(field.input); + }); + expect(field.values).toEqual(["alice"]); + }); + + test("one paste can carry a whole list", async () => { + const field = await setup(); + await act(async () => { + fireEvent.paste(field.input, { + clipboardData: { getData: () => "alice, bob\ncarol" }, + }); + }); + expect(field.values).toEqual(["alice", "bob", "carol"]); + }); + + test("strips a leading @ for logins, which is how people copy a handle", async () => { + const field = await setup([], { stripLeadingAt: true }); + await field.typeAndCommit("@alice,"); + expect(field.values).toEqual(["alice"]); + }); + + // Off by default: the same field takes branches and labels, and a branch + // may legitimately be called "@next" — stripping there stores a ref that + // does not exist. + test("keeps a leading @ where it can be part of the value", async () => { + const field = await setup(); + await field.typeAndCommit("@next,"); + expect(field.values).toEqual(["@next"]); + }); + + test("ignores a repeat rather than showing it twice", async () => { + const field = await setup(["alice"]); + await field.typeAndCommit("alice,"); + expect(field.values).toEqual(["alice"]); + }); + + test("commits each value as it is typed, keeping the order", async () => { + const field = await setup(); + await field.typeAndCommit("alice,"); + await field.typeAndCommit("bob,"); + expect(field.values).toEqual(["alice", "bob"]); + }); +}); + +describe("TokenField separators", () => { + // A GitHub login cannot contain a space; a label like "good first issue" + // very much can, so the space is opt-in per field. + test("a space commits when the value cannot contain one", async () => { + const field = await setup([], { separators: SEPARATORS_WITH_SPACE }); + await field.typeAndCommit("alice "); + expect(field.values).toEqual(["alice"]); + }); + + test("a space is just text by default", async () => { + const field = await setup(); + await field.typeAndCommit("good first issue"); + expect(field.values).toEqual([]); + }); + + test("a label keeps its spaces when committed", async () => { + const field = await setup(); + await field.typeAndCommit("good first issue,"); + expect(field.values).toEqual(["good first issue"]); + }); +}); + +describe("TokenField removing", () => { + test("backspace on an empty field deletes the last chip", async () => { + const field = await setup(["alice", "bob"]); + await act(async () => { + fireEvent.keyDown(field.input, { key: "Backspace" }); + }); + expect(field.values).toEqual(["alice"]); + }); + + test("backspace with text in the field leaves the chips alone", async () => { + const field = await setup(["alice"]); + await act(async () => { + fireEvent.change(field.input, { target: { value: "bo" } }); + fireEvent.keyDown(field.input, { key: "Backspace" }); + }); + expect(field.values).toEqual(["alice"]); + }); + + test("the chip's own button removes just that one", async () => { + const field = await setup(["alice", "bob"]); + await act(async () => { + field.ui.getByLabelText("Remove alice").click(); + }); + expect(field.values).toEqual(["bob"]); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TokenField/TokenField.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TokenField/TokenField.tsx new file mode 100644 index 0000000000..14131dcaa2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TokenField/TokenField.tsx @@ -0,0 +1,166 @@ +import { Input } from "@superset/ui/input"; +import { useRef, useState } from "react"; +import { LuX } from "react-icons/lu"; + +/** + * What ends a value. Commas and newlines always do, so one paste can carry a + * whole list; a space only where the value itself cannot contain one — a + * GitHub login cannot, a label like "good first issue" very much can. + */ +const SEPARATORS = /[,\n]/; +export const SEPARATORS_WITH_SPACE = /[\s,]/; + +/** + * Free-typed values as removable chips. + * + * GitHub's people, labels and branches all share a problem: the list we could + * offer is either unavailable (members needs a permission most installations + * never granted) or unbounded, so a picker over "what we managed to enumerate" + * is missing exactly the entry someone came to add. Typing is the way in, and + * a chip per value keeps a multi-value filter readable once it holds more than + * one. + * + * A comma commits, the way every recipient field works — that and pasting a + * comma-separated list are the two things people try first, and for values + * that cannot contain one, so does a space. Enter and leaving the field commit + * as well, so text that has been typed cannot be silently dropped on the way + * out. + */ +export function TokenField({ + values, + onChange, + placeholder, + valueLabel = (value) => value, + header, + onReset, + separators = SEPARATORS, + stripLeadingAt = false, +}: { + values: string[]; + onChange: (next: string[]) => void; + placeholder: string; + /** Labels values that stand for something else — a numeric GitHub user id. */ + valueLabel?: (value: string) => string; + /** Heading above the field, with `onReset` as its trailing action. */ + header?: string; + onReset?: () => void; + /** Defaults to commas and newlines; see SEPARATORS_WITH_SPACE. */ + separators?: RegExp; + /** + * Drops a leading "@" on commit. Only for logins, where the @ is display + * sugar people paste along with the name — a branch may legitimately be + * called "@next", and stripping it there stores a ref that does not exist. + */ + stripLeadingAt?: boolean; +}) { + const [draft, setDraft] = useState(""); + const inputRef = useRef(null); + + /** Adds every value in `text`, keeping the order typed and ignoring repeats. */ + const commit = (text: string) => { + const added = text + .split(separators) + .map((part) => { + const trimmed = part.trim(); + // People copy names with the @ still attached; it is not part of a + // login. Values that can legitimately start with one keep it. + return stripLeadingAt ? trimmed.replace(/^@/, "") : trimmed; + }) + .filter(Boolean); + if (added.length === 0) return false; + const next = [...values]; + for (const value of added) if (!next.includes(value)) next.push(value); + if (next.length !== values.length) onChange(next); + return true; + }; + + return ( + <> + {header !== undefined && ( +
+ {header} + {onReset && ( + + )} +
+ )} + + {/* One field: the chips sit inside it so the caret always follows the + last value added, the way a recipient field behaves. */} + {/** biome-ignore lint/a11y/noStaticElementInteractions: the click only + forwards focus to the input this wraps; every control inside is a + real button, reachable without it. */} +
{ + if (event.target !== event.currentTarget) return; + event.preventDefault(); + inputRef.current?.focus(); + }} + className="flex w-full cursor-text flex-wrap items-center gap-1 rounded-[6px] border border-input px-1.5 py-1.5 text-left focus-within:ring-1 focus-within:ring-ring" + > + {values.map((value) => ( + + {valueLabel(value)} + + + ))} + { + // A typed or auto-completed separator commits what precedes it + // rather than sitting in the field as punctuation. + if (separators.test(event.target.value)) { + commit(event.target.value); + setDraft(""); + return; + } + setDraft(event.target.value); + }} + onPaste={(event) => { + const text = event.clipboardData.getData("text"); + if (!separators.test(text)) return; + event.preventDefault(); + commit(`${draft}${text}`); + setDraft(""); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + if (commit(draft)) setDraft(""); + return; + } + // Backspace on an empty field deletes the last chip. + if (event.key === "Backspace" && !draft && values.length) { + onChange(values.slice(0, -1)); + } + }} + // Closing the popover unmounts this, so anything still in the field + // would be lost without committing it on the way out. + onBlur={() => { + if (commit(draft)) setDraft(""); + }} + className="h-6 min-w-24 flex-1 border-0 bg-transparent px-1 text-[13px] shadow-none focus-visible:ring-0" + /> +
+ + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TokenField/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TokenField/index.ts new file mode 100644 index 0000000000..f51f9722f7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TokenField/index.ts @@ -0,0 +1 @@ +export { SEPARATORS_WITH_SPACE, TokenField } from "./TokenField"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TypedScopeChip/TypedScopeChip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TypedScopeChip/TypedScopeChip.tsx new file mode 100644 index 0000000000..4d90aad2f0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TypedScopeChip/TypedScopeChip.tsx @@ -0,0 +1,69 @@ +import type { TriggerScope } from "@superset/shared/automation-triggers"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { useState } from "react"; +import { ChipButton } from "../../../../TriggerSentence/components/ChipButton"; +import { TokenField } from "../TokenField"; + +/** + * An optional filter over values only the person writing the trigger knows — + * label names, branch names. + * + * There is no list to offer: labels and branches are per-repository and the + * editor has not been told which repository yet, so a picker would show an + * empty search box. Typing is the whole interaction, and clearing the last + * value returns the filter to "any" rather than leaving an empty list, which + * would match nothing and read as the same thing. + */ +export function TypedScopeChip({ + scope, + onChange, + anyLabel, + placeholder, + countNoun, + disabled, + className, +}: { + scope: TriggerScope; + onChange: (next: TriggerScope) => void; + /** Shown when nothing is filtered — "Any label". */ + anyLabel: string; + placeholder: string; + countNoun: { singular: string; plural: string }; + disabled?: boolean; + className?: string; +}) { + const [open, setOpen] = useState(false); + const values = scope.mode === "list" ? scope.ids : []; + + const label = + values.length === 0 + ? anyLabel + : values.length === 1 + ? (values[0] ?? anyLabel) + : `${values.length} ${countNoun.plural}`; + + return ( + { + if (disabled) return; + setOpen(next); + }} + > + + + + + + + + onChange(ids.length ? { mode: "list", ids } : { mode: "any" }) + } + placeholder={placeholder} + /> + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TypedScopeChip/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TypedScopeChip/index.ts new file mode 100644 index 0000000000..f114e86158 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/TypedScopeChip/index.ts @@ -0,0 +1 @@ +export { TypedScopeChip } from "./TypedScopeChip"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/UserScopeChip/UserScopeChip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/UserScopeChip/UserScopeChip.tsx new file mode 100644 index 0000000000..0bb6de69dd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/UserScopeChip/UserScopeChip.tsx @@ -0,0 +1,130 @@ +import type { TriggerScope } from "@superset/shared/automation-triggers"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { useState } from "react"; +import { LuCheck } from "react-icons/lu"; +import { ChipButton } from "../../../../TriggerSentence/components/ChipButton"; +import type { ScopeOption } from "../../../../TriggerSentence/scopeOption"; +import { SEPARATORS_WITH_SPACE, TokenField } from "../TokenField"; + +const ROW = + "flex w-full items-center justify-between rounded-[6px] px-2 py-1.5 text-left text-sm hover:bg-accent"; + +/** + * Who a GitHub trigger filters on: anyone, the automation's owner, or a named + * set. + * + * A mode menu rather than a multi-select, because the roster this could offer + * is usually empty — listing members needs a permission most installations + * never granted, so the picker's own list is no help for exactly the people + * most likely to be missing from it. "Specific People" therefore takes typed + * GitHub usernames instead of only offering what we could enumerate. + * + * Typed names are stored as the login, roster picks as GitHub's numeric id, + * and the matcher accepts either. The difference matters: an id survives a + * rename and a login does not, so anything picked from the roster keeps the + * stronger guarantee and only what someone typed carries the weaker one. + */ +export function UserScopeChip({ + scope, + onChange, + options, + disabled, + className, +}: { + scope: TriggerScope; + onChange: (next: TriggerScope) => void; + /** Labels the ids saved by earlier roster picks; typed logins label themselves. */ + options: ScopeOption[]; + disabled?: boolean; + className?: string; +}) { + const [open, setOpen] = useState(false); + + const values = scope.mode === "list" ? scope.ids : []; + const isList = scope.mode === "list"; + const valueLabel = (value: string) => + options.find((option) => option.id === value)?.label ?? value; + + const label = + scope.mode === "any" + ? "Anyone" + : scope.mode === "me" + ? "Me" + : values.length === 0 + ? "Specific People" + : values.length === 1 + ? valueLabel(values[0] ?? "") + : `${values.length} people`; + + const pick = (next: TriggerScope) => { + onChange(next); + setOpen(false); + }; + + return ( + { + if (disabled) return; + setOpen(next); + }} + > + + + + + + + {isList ? ( + + onChange({ mode: "list", ids })} + placeholder="GitHub username..." + valueLabel={valueLabel} + // A login has no spaces in it, so a space ends one. + separators={SEPARATORS_WITH_SPACE} + header="Users" + // The only way back to Anyone and Me once this mode is on. + onReset={() => pick({ mode: "any" })} + /> + + ) : ( + + + + {/* Stays open: the users field is the point of choosing this. */} + + + )} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/UserScopeChip/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/UserScopeChip/index.ts new file mode 100644 index 0000000000..abd1c2dd3f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/components/UserScopeChip/index.ts @@ -0,0 +1 @@ +export { UserScopeChip } from "./UserScopeChip"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.test.tsx new file mode 100644 index 0000000000..b7e2da841d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.test.tsx @@ -0,0 +1,406 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// happy-dom over the preloaded plain-object document. Process-wide, so this +// unregisters in afterAll to leave the other renderer suites their document. +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { act, cleanup, render, within } = await import("@testing-library/react"); +const { TriggerSentence } = await import("../../TriggerSentence"); +const { CHIP_INVALID } = await import("../../TriggerSentence/chipStyles"); +const { githubProvider } = await import("./github"); +const { createGithubConfig } = await import("./grammar"); + +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +/** + * What a GitHub row looks like, and must keep looking like. + * + * The diagrams over each block are the point of this file: the components + * underneath are expected to be generalized, and these say what the result has + * to render regardless of how it is built. + * + * [word ▾] a chip — a button you can open + * word fixed wording, not a control + * (!) marked as blocking the save + * [🗑] remove, always last + */ + +const REPOS = [ + { id: "10", label: "superset", hint: "superset-sh" }, + { id: "20", label: "domains", hint: "superset-sh" }, +]; + +/** + * Renders one GitHub row in a given state and hands back its parts. + * + * `chip` finds a word of the sentence by the text on it, which is how a person + * finds it too. `sentence` joins the row's own children rather than reading + * textContent, because the spaces a reader sees are flex gaps — textContent + * runs the words together. + */ +async function row( + config: Record, + props: Record = {}, +) { + let view!: ReturnType; + await act(async () => { + view = render( + {}} + onRemove={() => {}} + options={{ github: { repositories: REPOS, people: [], viewer: [] } }} + {...props} + />, + ); + }); + const ui = within(view.baseElement as HTMLElement); + return { + ui, + chip: (name: string | RegExp) => ui.getByRole("button", { name }), + queryChip: (name: string | RegExp) => ui.queryByRole("button", { name }), + open: async (name: string | RegExp) => { + await act(async () => { + ui.getByRole("button", { name }).click(); + }); + return ui; + }, + chips: () => + ui + .getAllByRole("button") + .map((button) => (button.textContent ?? "").trim()), + sentence: [...(view.container.firstElementChild?.children ?? [])] + .map((child) => (child.textContent ?? "").replace(/\s+/g, " ").trim()) + .filter(Boolean) + .join(" "), + }; +} + +const config = (event: string, over: Record = {}) => ({ + ...createGithubConfig(event as never), + ...over, +}); + +/** + * As added — the repository is the one thing with no default: + * + * ⌥ PR opened in [Select repo ▾] by [Anyone ▾] [🗑] + */ +describe("a GitHub row that was just added", () => { + test("asks for a repository, because it has no default", async () => { + const { chip } = await row(config("pull_request.opened")); + expect(chip("Select repo")).toBeDefined(); + }); + + test("watches anyone until told otherwise", async () => { + const { chip } = await row(config("pull_request.opened")); + expect(chip("Anyone")).toBeDefined(); + }); + + test("can be removed", async () => { + const { ui } = await row(config("pull_request.opened")); + expect(ui.getByLabelText("Remove trigger")).toBeDefined(); + }); + + // An empty repository list matches nothing, so an unfinished trigger cannot + // fire on every repository. The offer has to be absent from inside the + // picker — on the closed chip "Any repo" is only ever a label for a scope + // that is already "any", so asserting there proves nothing. + test("does not offer to watch every repository", async () => { + const { open } = await row(config("pull_request.opened")); + const picker = await open("Select repo"); + expect(picker.queryByText("Any repo")).toBeNull(); + }); + + // One repository, because branches and labels belong to one and cannot be + // listed until it is known. + test("takes one repository, not a set", async () => { + const { open } = await row(config("pull_request.opened")); + const picker = await open("Select repo"); + expect(picker.queryByRole("checkbox")).toBeNull(); + }); +}); + +/** + * ⌥ PR opened in [superset ▾] by [Anyone ▾] + */ +describe("a GitHub row watching one repository", () => { + const chosen = () => + config("pull_request.opened", { + repositories: { mode: "list", ids: ["10"] }, + }); + + test("names the repository rather than counting it", async () => { + const { chip } = await row(chosen()); + expect(chip("superset")).toBeDefined(); + }); + + test("no longer asks for one", async () => { + const { queryChip } = await row(chosen()); + expect(queryChip("Select repo")).toBeNull(); + }); +}); + +/** + * … by [Anyone ▾] [Me ▾] [saddlepaddle ▾] [2 people ▾] + * [Specific People ▾] ← chosen, none named + */ +describe("a GitHub row filtering by person", () => { + const withActor = (actor: unknown) => + config("pull_request.opened", { + repositories: { mode: "list", ids: ["10"] }, + actor, + }); + + test('resolves "Me" at delivery, so the row simply says Me', async () => { + const { chip } = await row(withActor({ mode: "me" })); + expect(chip("Me")).toBeDefined(); + }); + + // A typed login labels itself; there is no roster to look it up in, which + // is the whole reason the field takes typed names. + test("shows a named person by the name that was typed", async () => { + const { chip } = await row( + withActor({ mode: "list", ids: ["saddlepaddle"] }), + ); + expect(chip("saddlepaddle")).toBeDefined(); + }); + + test("counts several named people", async () => { + const { chip } = await row( + withActor({ mode: "list", ids: ["alice", "bob"] }), + ); + expect(chip("2 people")).toBeDefined(); + }); + + // An empty set matches nobody and blocks saving, so it reads as unset even + // though a mode was deliberately chosen. + test("reads as unset when specific people are chosen but none named", async () => { + const { chip } = await row(withActor({ mode: "list", ids: [] })); + expect(chip("Specific People")).toBeDefined(); + }); +}); + +/** + * Not connected — the sentence collapses, because nothing in it could be + * filled in: + * + * ⌥ Pull request Opened Requires connection [🗑] [Connect ↗] + */ +describe("a GitHub row whose integration is not connected", () => { + const disconnected = () => + row(config("pull_request.opened"), { requiresConnection: true }); + + // With no connection there is nothing to populate the pickers, so a + // sentence full of empty ones would only ask for choices nobody can make. + // The name comes off the Add Trigger menu, nested path and all. + test("collapses to the name of the trigger", async () => { + const { ui } = await disconnected(); + expect(ui.getByText("Pull request Opened")).toBeDefined(); + }); + + test("says a connection is required", async () => { + const { ui } = await disconnected(); + expect(ui.getByText("Requires connection")).toBeDefined(); + }); + + test("offers the way to fix it", async () => { + const { chip } = await disconnected(); + expect(chip(/Connect/)).toBeDefined(); + }); + + test("hides the pickers entirely", async () => { + const { queryChip } = await disconnected(); + expect(queryChip("Select repo")).toBeNull(); + expect(queryChip("Anyone")).toBeNull(); + }); + + test("can still be removed", async () => { + const { ui } = await disconnected(); + expect(ui.getByLabelText("Remove trigger")).toBeDefined(); + }); +}); + +/** + * ⌥ PR opened in [Select repo ▾](!) by [Anyone ▾] + */ +describe("a GitHub row a save was refused on", () => { + // There is no Save button — the set saves itself once valid — so marking + // the chip is the only thing that says which word is holding it back. + const refused = () => + row(config("pull_request.opened"), { + problems: [ + { index: 0, field: "repositories", message: "Choose a repository" }, + ], + }); + + const MARK = "ring-amber-500/50"; + + test("marks the chip the save is blocked on", async () => { + const { chip } = await refused(); + expect(CHIP_INVALID).toContain(MARK); + expect(chip("Select repo").className).toContain(MARK); + }); + + test("leaves the chips that are fine alone", async () => { + const { chip } = await refused(); + expect(chip("Anyone").className).not.toContain(MARK); + }); +}); + +describe("a GitHub row that cannot be edited", () => { + test("disables every word of the sentence", async () => { + const { ui } = await row(config("pull_request.opened"), { + disabled: true, + }); + for (const button of ui.getAllByRole("button")) { + expect((button as HTMLButtonElement).disabled).toBe(true); + } + }); +}); + +/** + * The order and wording of the parts, which per-element queries cannot see: + * `getByRole` finds a chip wherever it sits, so only the whole sentence catches + * a slot moving or a connecting word changing. + */ +describe("the wording of a GitHub row", () => { + test("a pull request trigger names where, then who", async () => { + const { sentence } = await row( + config("pull_request.opened", { + repositories: { mode: "list", ids: ["10"] }, + }), + ); + expect(sentence).toBe("PR opened in superset by Anyone"); + }); + + // The comment events carry two different people — whoever wrote the + // comment, and whoever opened the thing commented on. + test("a comment trigger keeps the commenter and the author apart", async () => { + const { sentence } = await row( + config("comment_added", { + repositories: { mode: "list", ids: ["10"] }, + }), + ); + expect(sentence).toBe( + "Any comment by Anyone on a PR by Anyone in superset", + ); + }); + + test("a push trigger names the branch before the repository", async () => { + const { sentence } = await row( + config("push_to_branch", { + repositories: { mode: "list", ids: ["10"] }, + }), + ); + expect(sentence).toBe("Push to Any branch in superset by Anyone"); + }); +}); + +describe('a GitHub row that filters by "Me" with no account connected', () => { + // Configured fine, permanently silent: "Me" resolves against the owner's + // GitHub identity when each event arrives, and there isn't one. + const warningsFor = (over: Record, viewer: unknown[] = []) => + githubProvider.runtimeWarnings?.( + config("pull_request.opened", over) as never, + { github: { viewer: viewer as never } }, + ) ?? []; + + test("warns that it will not fire", () => { + const warnings = warningsFor({ actor: { mode: "me" } }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("no GitHub account is connected"); + }); + + test("stays quiet once an account resolves", () => { + expect( + warningsFor({ actor: { mode: "me" } }, [{ id: "42", label: "satya" }]), + ).toEqual([]); + }); + + test("stays quiet when no scope asks for Me", () => { + expect(warningsFor({})).toEqual([]); + }); +}); + +/** + * The optional filters, which start wide open rather than empty — clearing the + * last value returns them to "any" rather than leaving a list that matches + * nothing: + * + * ⌥ Label [Any label ▾] changed in [superset ▾] by [Anyone ▾] + * ⌥ Push to [Any branch ▾] in [superset ▾] by [Anyone ▾] + */ +describe("a GitHub row's optional filters", () => { + const inRepo = (event: string, over: Record = {}) => + config(event, { repositories: { mode: "list", ids: ["10"] }, ...over }); + + test("labels start wide open", async () => { + const { chip } = await row(inRepo("label_change")); + expect(chip("Any label")).toBeDefined(); + }); + + test("one label is named", async () => { + const { chip } = await row( + inRepo("label_change", { labels: { mode: "list", ids: ["bug"] } }), + ); + expect(chip("bug")).toBeDefined(); + }); + + // Labels are typed, not picked, so a label containing spaces has to survive + // as one value. + test("a label with spaces stays one label", async () => { + const { chip } = await row( + inRepo("label_change", { + labels: { mode: "list", ids: ["good first issue"] }, + }), + ); + expect(chip("good first issue")).toBeDefined(); + }); + + test("several labels are counted", async () => { + const { chip } = await row( + inRepo("label_change", { + labels: { mode: "list", ids: ["bug", "urgent"] }, + }), + ); + expect(chip("2 labels")).toBeDefined(); + }); + + test("branches start wide open", async () => { + const { chip } = await row(inRepo("push_to_branch")); + expect(chip("Any branch")).toBeDefined(); + }); +}); + +/** + * The comment body filter, which carries the verb the sentence does not: + * + * ⌥ [Matching "LGTM" ▾] by [Anyone ▾] on a PR by [Anyone ▾] in [superset ▾] + */ +describe("a GitHub row filtering a comment body", () => { + test("starts matching any comment", async () => { + const { chip } = await row( + config("comment_added", { repositories: { mode: "list", ids: ["10"] } }), + ); + expect(chip("Any comment")).toBeDefined(); + }); + + test("says what it is matching, in quotes", async () => { + const { chip } = await row( + config("comment_added", { + repositories: { mode: "list", ids: ["10"] }, + commentFilter: { pattern: "LGTM", isRegex: false }, + }), + ); + expect(chip('Matching "LGTM"')).toBeDefined(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx index d23e29d3d7..477342430f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/github.tsx @@ -1,9 +1,13 @@ +import { configHasMeScope } from "@superset/shared/automation-matching"; import { isEmptyScope } from "@superset/shared/automation-triggers"; import { FaGithub } from "react-icons/fa"; +import { env } from "renderer/env.renderer"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { TextFilterChip } from "../../TriggerSentence/components/TextFilterChip"; import { Sentence } from "../components/Sentence"; import type { SentenceContext, TriggerProvider } from "../types"; +import { TypedScopeChip } from "./components/TypedScopeChip"; +import { UserScopeChip } from "./components/UserScopeChip"; import { GITHUB_MENU, GITHUB_SENTENCES, @@ -19,7 +23,7 @@ function renderSlot( config: GithubConfig, slot: Slot, index: number, - { set, mark, options, disabled }: SentenceContext, + { set, mark, options, state, disabled }: SentenceContext, ) { // The slot list is derived from this event, so the fields it names are // present on this config member even where the union type cannot say so. @@ -33,8 +37,28 @@ function renderSlot( onChange={(v) => set({ repositories: v })} className={mark("repositories")} options={options.github?.repositories ?? []} - emptyLabel="Select repos" + emptyLabel="Select repo" anyLabel="Any repo" + // Saving already requires one of these, and the default is an empty + // list so a half-built trigger matches nothing. Offering "any" + // would undo both — it saves cleanly and fires on everything. + allowAny={false} + // One repository, because the branches and labels of a trigger are + // only listable once it is known which repository they belong to. + single + countNoun={{ singular: "repository", plural: "repositories" }} + // A repo missing from the roster means the GitHub App was never + // granted it; the fix is the install flow, which lives in the + // web app because that's where the browser session is. + action={{ + label: "Add repositories", + onSelect: () => + window.open( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/github`, + "_blank", + ), + }} + state={state} disabled={disabled} /> ); @@ -57,41 +81,35 @@ function renderSlot( ); case "labels": return ( - - set({ labels: isEmptyScope(v) ? { mode: "any" } : v }) - } - options={[]} - emptyLabel="Any label" + onChange={(v) => set({ labels: v })} anyLabel="Any label" + placeholder="Label name..." + countNoun={{ singular: "label", plural: "labels" }} disabled={disabled} /> ); case "actor": return ( - set({ actor: v })} className={mark("actor")} options={options.github?.people ?? []} - emptyLabel="Select people" - anyLabel="Anyone" disabled={disabled} /> ); case "subjectAuthor": return ( - set({ subjectAuthor: v })} className={mark("subjectAuthor")} options={options.github?.people ?? []} - emptyLabel="Select people" - anyLabel="Anyone" disabled={disabled} /> ); @@ -111,6 +129,7 @@ function renderSlot( export const githubProvider: TriggerProvider = { kind: "github", + connectionProvider: "github", optionGroup: "github", label: "GitHub", icon: FaGithub, @@ -122,4 +141,16 @@ export const githubProvider: TriggerProvider = { renderSlot={(slot, index) => renderSlot(config, slot, index, ctx)} /> ), + // "Me" resolves against the owner's GitHub identity when each event + // arrives; with no identity connected it resolves to nobody and the + // trigger is configured fine but permanently silent. The check reads the + // viewer's identity — edits are owner-gated, so for the person who can + // act on this they are the same account. + runtimeWarnings: (config, options) => { + if (!configHasMeScope(config)) return []; + if ((options.github?.viewer ?? []).length > 0) return []; + return [ + 'This trigger filters by "Me", but no GitHub account is connected for you — it will not fire until one is.', + ]; + }, }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/grammar.ts index 4547a4d355..56a6a45a53 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/grammar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/github/grammar.ts @@ -1,3 +1,5 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; import type { GithubTriggerEvent, TriggerConfigInput, @@ -152,50 +154,188 @@ export const GITHUB_SENTENCES: Record = { * GitHub event is. */ export const GITHUB_MENU: TriggerMenuEntry[] = [ - leaf("Draft opened", "draft_opened"), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuDraftOpened", + message: "Draft opened", + }), + "draft_opened", + ), { - label: "Pull request…", + label: msg({ + id: "dashboard.automations.providers.github.menuPullRequest", + message: "Pull request…", + }), children: [ - leaf("Opened", "pull_request.opened"), - leaf("Pushed", "pull_request.pushed"), - leaf("Merged", "pull_request.merged"), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuPrOpened", + message: "Opened", + }), + "pull_request.opened", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuPrPushed", + message: "Pushed", + }), + "pull_request.pushed", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuPrMerged", + message: "Merged", + }), + "pull_request.merged", + ), ], }, - leaf("Comment added", "comment_added"), - leaf("New push to branch", "push_to_branch"), - leaf("Label change", "label_change"), - leaf("Checks completed", "checks_completed"), - leaf("Issue comment", "issue_comment"), - leaf("PR review comment", "pr_review_comment"), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuCommentAdded", + message: "Comment added", + }), + "comment_added", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuPushToBranch", + message: "New push to branch", + }), + "push_to_branch", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuLabelChange", + message: "Label change", + }), + "label_change", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuChecksCompleted", + message: "Checks completed", + }), + "checks_completed", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuIssueComment", + message: "Issue comment", + }), + "issue_comment", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuPrReviewComment", + message: "PR review comment", + }), + "pr_review_comment", + ), { - label: "PR review submitted…", + label: msg({ + id: "dashboard.automations.providers.github.menuPrReviewSubmitted", + message: "PR review submitted…", + }), children: [ - leaf("Approved", "pr_review_submitted.approved"), - leaf("Changes requested", "pr_review_submitted.changes_requested"), - leaf("Commented", "pr_review_submitted.commented"), - leaf("Any review", "pr_review_submitted.any"), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuReviewApproved", + message: "Approved", + }), + "pr_review_submitted.approved", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuReviewChangesRequested", + message: "Changes requested", + }), + "pr_review_submitted.changes_requested", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuReviewCommented", + message: "Commented", + }), + "pr_review_submitted.commented", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuReviewAny", + message: "Any review", + }), + "pr_review_submitted.any", + ), ], }, { - label: "Review thread…", + label: msg({ + id: "dashboard.automations.providers.github.menuReviewThread", + message: "Review thread…", + }), children: [ - leaf("Resolved", "review_thread.resolved"), - leaf("Unresolved", "review_thread.unresolved"), - leaf("Any thread event", "review_thread.any"), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuThreadResolved", + message: "Resolved", + }), + "review_thread.resolved", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuThreadUnresolved", + message: "Unresolved", + }), + "review_thread.unresolved", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuThreadAny", + message: "Any thread event", + }), + "review_thread.any", + ), ], }, { - label: "Workflow run completed…", + label: msg({ + id: "dashboard.automations.providers.github.menuWorkflowRun", + message: "Workflow run completed…", + }), children: [ - leaf("Success", "workflow_run.success"), - leaf("Failure", "workflow_run.failure"), - leaf("Cancelled", "workflow_run.cancelled"), - leaf("Any conclusion", "workflow_run.any"), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuWorkflowSuccess", + message: "Success", + }), + "workflow_run.success", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuWorkflowFailure", + message: "Failure", + }), + "workflow_run.failure", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuWorkflowCancelled", + message: "Cancelled", + }), + "workflow_run.cancelled", + ), + leaf( + msg({ + id: "dashboard.automations.providers.github.menuWorkflowAny", + message: "Any conclusion", + }), + "workflow_run.any", + ), ], }, ]; -function leaf(label: string, event: GithubTriggerEvent) { +function leaf(label: MessageDescriptor, event: GithubTriggerEvent) { return { label, create: () => createGithubConfig(event) }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx index cc19b3d7d6..30915f1c78 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/gmail.tsx @@ -1,3 +1,5 @@ +import { msg } from "@lingui/core/macro"; +import { i18n } from "@superset/i18n"; import { isEmptyScope } from "@superset/shared/automation-triggers"; import { SiGmail } from "react-icons/si"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; @@ -17,7 +19,7 @@ function renderSlot( config: GmailConfig, slot: GmailSlot, index: number, - { set, mark, options, disabled }: SentenceContext, + { set, mark, options, state, disabled }: SentenceContext, ) { switch (slot) { case "from": @@ -28,9 +30,26 @@ function renderSlot( onChange={(v) => set({ from: v })} className={mark("from")} options={[]} - emptyLabel="Select senders" - anyLabel="Any sender" - allowCustom={{ placeholder: "Add address or domain…" }} + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.gmail.selectSenders", + message: "Select senders", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.gmail.anySender", + message: "Any sender", + }), + )} + allowCustom={{ + placeholder: i18n._( + msg({ + id: "dashboard.automations.providers.gmail.fromCustomPlaceholder", + message: "Add address or domain…", + }), + ), + }} disabled={disabled} /> ); @@ -42,9 +61,26 @@ function renderSlot( // Clearing an optional filter means "any", not "none". onChange={(v) => set({ to: isEmptyScope(v) ? { mode: "any" } : v })} options={[]} - emptyLabel="Any recipient" - anyLabel="Any recipient" - allowCustom={{ placeholder: "Add address or domain…" }} + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.gmail.anyRecipientEmpty", + message: "Any recipient", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.gmail.anyRecipient", + message: "Any recipient", + }), + )} + allowCustom={{ + placeholder: i18n._( + msg({ + id: "dashboard.automations.providers.gmail.toCustomPlaceholder", + message: "Add address or domain…", + }), + ), + }} disabled={disabled} /> ); @@ -54,8 +90,18 @@ function renderSlot( key={index} value={config.subjectFilter} onChange={(v) => set({ subjectFilter: v })} - emptyLabel="anything" - placeholder="Subject contains..." + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.gmail.subjectAnything", + message: "anything", + }), + )} + placeholder={i18n._( + msg({ + id: "dashboard.automations.providers.gmail.subjectFilterPlaceholder", + message: "Subject contains...", + }), + )} disabled={disabled} /> ); @@ -68,8 +114,19 @@ function renderSlot( set({ labels: isEmptyScope(v) ? { mode: "any" } : v }) } options={options.google?.labels ?? []} - emptyLabel="Any label" - anyLabel="Any label" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.gmail.anyLabelEmpty", + message: "Any label", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.gmail.anyLabel", + message: "Any label", + }), + )} + state={state} disabled={disabled} /> ); @@ -79,7 +136,10 @@ function renderSlot( key={index} value={config.hasAttachment ? "attachment" : "any"} onChange={(v) => set({ hasAttachment: v === "attachment" })} - options={ATTACHMENT_OPTIONS} + options={ATTACHMENT_OPTIONS.map((option) => ({ + value: option.value, + label: i18n._(option.label), + }))} disabled={disabled} /> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx index 70e936be35..7390202586 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/googleCalendar.tsx @@ -1,3 +1,5 @@ +import { msg } from "@lingui/core/macro"; +import { i18n } from "@superset/i18n"; import { SiGooglecalendar } from "react-icons/si"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { SelectChip } from "../../TriggerSentence/components/SelectChip"; @@ -17,7 +19,7 @@ function renderSlot( config: GoogleCalendarConfig, slot: CalendarSlot, index: number, - { set, mark, options, disabled }: SentenceContext, + { set, mark, options, state, disabled }: SentenceContext, ) { // The slot list is derived from this event, so the fields it names are // present on this config member even where the union type cannot say so. @@ -31,8 +33,19 @@ function renderSlot( onChange={(v) => set({ calendars: v })} className={mark("calendars")} options={options.google?.calendars ?? []} - emptyLabel="Select calendars" - anyLabel="Any calendar" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.googleCalendar.selectCalendars", + message: "Select calendars", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.googleCalendar.anyCalendar", + message: "Any calendar", + }), + )} + state={state} disabled={disabled} /> ); @@ -44,9 +57,27 @@ function renderSlot( onChange={(v) => set({ attendee: v })} className={mark("attendee")} options={options.google?.people ?? []} - emptyLabel="Select people" - anyLabel="Anyone" - allowCustom={{ placeholder: "Type an email, press Enter" }} + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.googleCalendar.selectPeople", + message: "Select people", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.googleCalendar.anyone", + message: "Anyone", + }), + )} + allowCustom={{ + placeholder: i18n._( + msg({ + id: "dashboard.automations.providers.googleCalendar.emailPlaceholder", + message: "Type an email, press Enter", + }), + ), + }} + state={state} disabled={disabled} /> ); @@ -56,8 +87,18 @@ function renderSlot( key={index} value={c.titleFilter} onChange={(v) => set({ titleFilter: v })} - emptyLabel="anything" - placeholder="Title contains..." + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.googleCalendar.titleAnything", + message: "anything", + }), + )} + placeholder={i18n._( + msg({ + id: "dashboard.automations.providers.googleCalendar.titleFilterPlaceholder", + message: "Title contains...", + }), + )} disabled={disabled} /> ); @@ -67,7 +108,10 @@ function renderSlot( key={index} value={c.hasExternalAttendee ? "external" : "any"} onChange={(v) => set({ hasExternalAttendee: v === "external" })} - options={EXTERNAL_ATTENDEE_OPTIONS} + options={EXTERNAL_ATTENDEE_OPTIONS.map((option) => ({ + value: option.value, + label: i18n._(option.label), + }))} disabled={disabled} /> ); @@ -77,7 +121,10 @@ function renderSlot( key={index} value={String(c.minutesBefore)} onChange={(v) => set({ minutesBefore: Number(v) })} - options={MINUTES_BEFORE_OPTIONS} + options={MINUTES_BEFORE_OPTIONS.map((option) => ({ + value: option.value, + label: i18n._(option.label), + }))} disabled={disabled} /> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/grammar.ts index 0ce0e76482..ae5f2798f0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/grammar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/google/grammar.ts @@ -1,3 +1,5 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; import type { GoogleCalendarTriggerEvent, TriggerConfigInput, @@ -98,39 +100,135 @@ export const GMAIL_SENTENCE: SentencePart[] = [ ]; export const MINUTES_BEFORE_OPTIONS = [ - { value: "5", label: "5 minutes" }, - { value: "10", label: "10 minutes" }, - { value: "15", label: "15 minutes" }, - { value: "30", label: "30 minutes" }, - { value: "60", label: "1 hour" }, - { value: "120", label: "2 hours" }, + { + value: "5", + label: msg({ + id: "dashboard.automations.providers.google.minutes5", + message: "5 minutes", + }), + }, + { + value: "10", + label: msg({ + id: "dashboard.automations.providers.google.minutes10", + message: "10 minutes", + }), + }, + { + value: "15", + label: msg({ + id: "dashboard.automations.providers.google.minutes15", + message: "15 minutes", + }), + }, + { + value: "30", + label: msg({ + id: "dashboard.automations.providers.google.minutes30", + message: "30 minutes", + }), + }, + { + value: "60", + label: msg({ + id: "dashboard.automations.providers.google.hour1", + message: "1 hour", + }), + }, + { + value: "120", + label: msg({ + id: "dashboard.automations.providers.google.hours2", + message: "2 hours", + }), + }, ] as const; export const EXTERNAL_ATTENDEE_OPTIONS = [ - { value: "any", label: "anyone" }, - { value: "external", label: "someone external" }, + { + value: "any", + label: msg({ + id: "dashboard.automations.providers.google.attendeeAnyone", + message: "anyone", + }), + }, + { + value: "external", + label: msg({ + id: "dashboard.automations.providers.google.attendeeExternal", + message: "someone external", + }), + }, ] as const; export const ATTACHMENT_OPTIONS = [ - { value: "any", label: "with or without attachments" }, - { value: "attachment", label: "with an attachment" }, + { + value: "any", + label: msg({ + id: "dashboard.automations.providers.google.attachmentAny", + message: "with or without attachments", + }), + }, + { + value: "attachment", + label: msg({ + id: "dashboard.automations.providers.google.attachmentWith", + message: "with an attachment", + }), + }, ] as const; export const CALENDAR_MENU: TriggerMenuEntry[] = [ - leaf("Event created", "event.created"), - leaf("Event updated", "event.updated"), - leaf("Event cancelled", "event.cancelled"), - leaf("Event starting soon", "event.starting_soon"), - leaf("Event ended", "event.ended"), + leaf( + msg({ + id: "dashboard.automations.providers.google.menuEventCreated", + message: "Event created", + }), + "event.created", + ), + leaf( + msg({ + id: "dashboard.automations.providers.google.menuEventUpdated", + message: "Event updated", + }), + "event.updated", + ), + leaf( + msg({ + id: "dashboard.automations.providers.google.menuEventCancelled", + message: "Event cancelled", + }), + "event.cancelled", + ), + leaf( + msg({ + id: "dashboard.automations.providers.google.menuEventStartingSoon", + message: "Event starting soon", + }), + "event.starting_soon", + ), + leaf( + msg({ + id: "dashboard.automations.providers.google.menuEventEnded", + message: "Event ended", + }), + "event.ended", + ), ]; // One leaf, so the Add Trigger menu shows the provider row itself; the label // only surfaces in search, where "gmail" has to find it. export const GMAIL_MENU: TriggerMenuEntry[] = [ - { label: "Email received in Gmail", create: createGmailConfig }, + { + label: msg({ + id: "dashboard.automations.providers.google.menuEmailReceived", + message: "Email received in Gmail", + }), + create: createGmailConfig, + }, ]; -function leaf(label: string, event: GoogleCalendarTriggerEvent) { +function leaf(label: MessageDescriptor, event: GoogleCalendarTriggerEvent) { return { label, create: () => createCalendarConfig(event) }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/index.ts index d88b7b41d7..2667a4da94 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/index.ts @@ -1,5 +1,4 @@ import type { TriggerConfigInput } from "@superset/shared/automation-triggers"; -import { circlebackProvider } from "./circleback/circleback"; import { githubProvider } from "./github/github"; import { gmailProvider } from "./google/gmail"; import { googleCalendarProvider } from "./google/googleCalendar"; @@ -13,6 +12,7 @@ import type { TriggerProvider } from "./types"; import { webhookProvider } from "./webhook/webhook"; export type { + OptionGroupState, ProviderOptions, SentenceContext, TriggerMenuEntry, @@ -20,21 +20,21 @@ export type { } from "./types"; /** - * Every provider the editor knows, in Add Trigger menu order. + * Every provider the editor knows, in Add Trigger menu order — most used to + * least, not alphabetical. * - * To add one: write a `TriggerProvider` under `providers//` and append it - * here. Nothing else in the editor should need to change. + * To add one: write a `TriggerProvider` under `providers//` and slot it + * here by importance. Nothing else in the editor should need to change. */ export const TRIGGER_PROVIDERS: TriggerProvider[] = [ scheduleProvider as TriggerProvider, githubProvider as TriggerProvider, + slackProvider as TriggerProvider, + microsoftTeamsProvider as TriggerProvider, sentryProvider as TriggerProvider, linearProvider as TriggerProvider, - notionProvider as TriggerProvider, - slackProvider as TriggerProvider, webhookProvider as TriggerProvider, - circlebackProvider as TriggerProvider, - microsoftTeamsProvider as TriggerProvider, + notionProvider as TriggerProvider, googleCalendarProvider as TriggerProvider, gmailProvider as TriggerProvider, ]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/grammar.ts index b4b7213dc3..f7a8df1b23 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/grammar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/grammar.ts @@ -1,3 +1,5 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; import type { LinearTriggerEvent, TriggerConfigInput, @@ -52,17 +54,44 @@ export const LINEAR_SENTENCES: Record = { export const LINEAR_MENU: TriggerMenuEntry[] = [ { - label: "Issue…", + label: msg({ + id: "dashboard.automations.providers.linear.menuIssue", + message: "Issue…", + }), children: [ - leaf("Created", "issue.created"), - leaf("Status changed", "issue.status_changed"), - leaf("Assigned", "issue.assigned"), + leaf( + msg({ + id: "dashboard.automations.providers.linear.menuIssueCreated", + message: "Created", + }), + "issue.created", + ), + leaf( + msg({ + id: "dashboard.automations.providers.linear.menuIssueStatusChanged", + message: "Status changed", + }), + "issue.status_changed", + ), + leaf( + msg({ + id: "dashboard.automations.providers.linear.menuIssueAssigned", + message: "Assigned", + }), + "issue.assigned", + ), ], }, - leaf("Cycle ended", "cycle.ended"), + leaf( + msg({ + id: "dashboard.automations.providers.linear.menuCycleEnded", + message: "Cycle ended", + }), + "cycle.ended", + ), ]; -function leaf(label: string, event: LinearTriggerEvent) { +function leaf(label: MessageDescriptor, event: LinearTriggerEvent) { return { label, create: () => createLinearConfig(event) }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx index 87ce550501..b53d523679 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/linear/linear.tsx @@ -1,3 +1,5 @@ +import { msg } from "@lingui/core/macro"; +import { i18n } from "@superset/i18n"; import { isEmptyScope } from "@superset/shared/automation-triggers"; import { SiLinear } from "react-icons/si"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; @@ -15,7 +17,7 @@ function renderSlot( config: LinearConfig, slot: Slot, index: number, - { set, mark, options, disabled }: SentenceContext, + { set, mark, options, state, disabled }: SentenceContext, ) { switch (slot) { case "teams": @@ -26,8 +28,19 @@ function renderSlot( onChange={(v) => set({ teams: v })} className={mark("teams")} options={options.linear?.teams ?? []} - emptyLabel="Select teams" - anyLabel="Any team" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.linear.selectTeams", + message: "Select teams", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.linear.anyTeam", + message: "Any team", + }), + )} + state={state} disabled={disabled} /> ); @@ -43,8 +56,19 @@ function renderSlot( set({ projects: isEmptyScope(v) ? { mode: "any" } : v }) } options={options.linear?.projects ?? []} - emptyLabel="Any project" - anyLabel="Any project" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.linear.anyProjectEmpty", + message: "Any project", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.linear.anyProject", + message: "Any project", + }), + )} + state={state} disabled={disabled} /> ); @@ -57,8 +81,19 @@ function renderSlot( set({ labels: isEmptyScope(v) ? { mode: "any" } : v }) } options={options.linear?.labels ?? []} - emptyLabel="Any label" - anyLabel="Any label" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.linear.anyLabelEmpty", + message: "Any label", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.linear.anyLabel", + message: "Any label", + }), + )} + state={state} disabled={disabled} /> ); @@ -71,8 +106,19 @@ function renderSlot( set({ toStatus: isEmptyScope(v) ? { mode: "any" } : v }) } options={options.linear?.statuses ?? []} - emptyLabel="Any status" - anyLabel="Any status" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.linear.anyStatusEmpty", + message: "Any status", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.linear.anyStatus", + message: "Any status", + }), + )} + state={state} disabled={disabled} /> ); @@ -84,8 +130,19 @@ function renderSlot( onChange={(v) => set({ assignee: v })} className={mark("assignee")} options={options.linear?.people ?? []} - emptyLabel="Select people" - anyLabel="Anyone" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.linear.selectPeople", + message: "Select people", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.linear.anyone", + message: "Anyone", + }), + )} + state={state} disabled={disabled} /> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/grammar.ts index b4006852dc..d1f2a8426c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/grammar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/grammar.ts @@ -1,3 +1,5 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; import type { MicrosoftTeamsTriggerEvent, TriggerConfigInput, @@ -43,11 +45,23 @@ export const TEAMS_SENTENCES: Record< }; export const TEAMS_MENU: TriggerMenuEntry[] = [ - leaf("Message in channel", "message_in_channel"), - leaf("Channel created", "channel_created"), + leaf( + msg({ + id: "dashboard.automations.providers.microsoftTeams.menuMessageInChannel", + message: "Message in channel", + }), + "message_in_channel", + ), + leaf( + msg({ + id: "dashboard.automations.providers.microsoftTeams.menuChannelCreated", + message: "Channel created", + }), + "channel_created", + ), ]; -function leaf(label: string, event: MicrosoftTeamsTriggerEvent) { +function leaf(label: MessageDescriptor, event: MicrosoftTeamsTriggerEvent) { return { label, create: () => createTeamsConfig(event) }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx index a1abc0a2f7..26c0554d6f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/microsoftTeams/microsoftTeams.tsx @@ -1,3 +1,5 @@ +import { msg } from "@lingui/core/macro"; +import { i18n } from "@superset/i18n"; import { BsMicrosoftTeams } from "react-icons/bs"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; import { TextFilterChip } from "../../TriggerSentence/components/TextFilterChip"; @@ -14,7 +16,7 @@ function renderSlot( config: MicrosoftTeamsConfig, slot: Slot, index: number, - { set, mark, options, disabled }: SentenceContext, + { set, mark, options, state, disabled }: SentenceContext, ) { switch (slot) { case "teams": @@ -25,8 +27,19 @@ function renderSlot( onChange={(v) => set({ teams: v })} className={mark("teams")} options={options.microsoftTeams?.teams ?? []} - emptyLabel="Select teams" - anyLabel="Any team" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.microsoftTeams.selectTeams", + message: "Select teams", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.microsoftTeams.anyTeam", + message: "Any team", + }), + )} + state={state} disabled={disabled} /> ); @@ -38,8 +51,19 @@ function renderSlot( onChange={(v) => set({ channels: v })} className={mark("channels")} options={options.microsoftTeams?.channels ?? []} - emptyLabel="Select channels" - anyLabel="Any channel" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.microsoftTeams.selectChannels", + message: "Select channels", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.microsoftTeams.anyChannel", + message: "Any channel", + }), + )} + state={state} disabled={disabled} /> ); @@ -51,8 +75,19 @@ function renderSlot( onChange={(v) => set({ actor: v })} className={mark("actor")} options={options.microsoftTeams?.people ?? []} - emptyLabel="Select people" - anyLabel="Anyone" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.microsoftTeams.selectPeople", + message: "Select people", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.microsoftTeams.anyone", + message: "Anyone", + }), + )} + state={state} disabled={disabled} /> ); @@ -62,8 +97,18 @@ function renderSlot( key={index} value={config.messageFilter} onChange={(v) => set({ messageFilter: v })} - emptyLabel="Any message" - placeholder="Contains this text..." + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.microsoftTeams.anyMessage", + message: "Any message", + }), + )} + placeholder={i18n._( + msg({ + id: "dashboard.automations.providers.microsoftTeams.messageFilterPlaceholder", + message: "Contains this text...", + }), + )} disabled={disabled} /> ); @@ -73,8 +118,18 @@ function renderSlot( key={index} value={config.messageFilter} onChange={(v) => set({ messageFilter: v })} - emptyLabel="Any name" - placeholder="Name contains..." + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.microsoftTeams.anyName", + message: "Any name", + }), + )} + placeholder={i18n._( + msg({ + id: "dashboard.automations.providers.microsoftTeams.nameFilterPlaceholder", + message: "Name contains...", + }), + )} disabled={disabled} /> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/grammar.ts index d4c7cb77a6..b6f7ef69f5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/grammar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/grammar.ts @@ -1,3 +1,5 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; import type { NotionTriggerEvent, TriggerConfigInput, @@ -39,12 +41,30 @@ export const NOTION_SENTENCES: Record = { }; export const NOTION_MENU: TriggerMenuEntry[] = [ - leaf("Rows changed", "data_source.content_updated"), - leaf("Comment added", "comment.created"), - leaf("Comment mentions user", "comment.mentioned"), + leaf( + msg({ + id: "dashboard.automations.providers.notion.menuRowsChanged", + message: "Rows changed", + }), + "data_source.content_updated", + ), + leaf( + msg({ + id: "dashboard.automations.providers.notion.menuCommentAdded", + message: "Comment added", + }), + "comment.created", + ), + leaf( + msg({ + id: "dashboard.automations.providers.notion.menuCommentMentions", + message: "Comment mentions user", + }), + "comment.mentioned", + ), ]; -function leaf(label: string, event: NotionTriggerEvent) { +function leaf(label: MessageDescriptor, event: NotionTriggerEvent) { return { label, create: () => createNotionConfig(event) }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx index 7dca9aa794..1cc9a2b317 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/notion/notion.tsx @@ -1,3 +1,5 @@ +import { msg } from "@lingui/core/macro"; +import { i18n } from "@superset/i18n"; import { isEmptyScope } from "@superset/shared/automation-triggers"; import { SiNotion } from "react-icons/si"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; @@ -14,7 +16,7 @@ function renderSlot( config: NotionConfig, slot: Slot, index: number, - { set, mark, options, disabled }: SentenceContext, + { set, mark, options, state, disabled }: SentenceContext, ) { // The slot list is derived from this event, so the fields it names are // present on this config member even where the union type cannot say so. @@ -28,8 +30,19 @@ function renderSlot( onChange={(v) => set({ dataSources: v })} className={mark("dataSources")} options={options.notion?.dataSources ?? []} - emptyLabel="Select data sources" - anyLabel="Any data source" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.notion.selectDataSources", + message: "Select data sources", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.notion.anyDataSource", + message: "Any data source", + }), + )} + state={state} disabled={disabled} /> ); @@ -43,8 +56,18 @@ function renderSlot( set({ pages: isEmptyScope(v) ? { mode: "any" } : v }) } options={[]} - emptyLabel="Any page" - anyLabel="Any page" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.notion.anyPageEmpty", + message: "Any page", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.notion.anyPage", + message: "Any page", + }), + )} disabled={disabled} /> ); @@ -56,8 +79,19 @@ function renderSlot( onChange={(v) => set({ actor: v })} className={mark("actor")} options={options.notion?.people ?? []} - emptyLabel="Select people" - anyLabel="Anyone" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.notion.actorSelectPeople", + message: "Select people", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.notion.actorAnyone", + message: "Anyone", + }), + )} + state={state} disabled={disabled} /> ); @@ -69,8 +103,19 @@ function renderSlot( onChange={(v) => set({ mentionedUser: v })} className={mark("mentionedUser")} options={options.notion?.people ?? []} - emptyLabel="Select people" - anyLabel="Anyone" + emptyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.notion.mentionedSelectPeople", + message: "Select people", + }), + )} + anyLabel={i18n._( + msg({ + id: "dashboard.automations.providers.notion.mentionedAnyone", + message: "Anyone", + }), + )} + state={state} disabled={disabled} /> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/providers.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/providers.test.ts new file mode 100644 index 0000000000..703ed01c84 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/providers.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +// biome-ignore lint/style/noRestrictedImports: test file needs fs/path for source verification +import { readdirSync, readFileSync, statSync } from "node:fs"; +// biome-ignore lint/style/noRestrictedImports: test file needs fs/path for source verification +import { join } from "node:path"; + +const HERE = import.meta.dir; + +/** + * Every sentence file — `providers/slack/slack.tsx`. All of them, not one per + * directory: `google` holds both `gmail.tsx` and `googleCalendar.tsx`, and a + * check that stopped at the first file would have passed while the second was + * unwired. + */ +function sentenceFiles(): { provider: string; source: string }[] { + return readdirSync(HERE) + .filter((entry) => entry !== "components") + .filter((entry) => statSync(join(HERE, entry)).isDirectory()) + .flatMap((provider) => + readdirSync(join(HERE, provider)) + .filter((name) => name.endsWith(".tsx") && /^[a-z]/.test(name)) + .map((file) => ({ + provider: `${provider}/${file.replace(/\.tsx$/, "")}`, + source: readFileSync(join(HERE, provider, file), "utf8"), + })), + ); +} + +/** + * A chip whose options are fetched has to be handed the fetch's state, or it + * cannot tell "still loading" and "we couldn't reach the provider" apart from + * "there is genuinely nothing" — and it loses its Refresh with them. + * + * This is asserted over the source rather than a render because the failure is + * an omission: the chip renders perfectly well without the prop, just mute + * about everything that matters. Four providers shipped that way, each looking + * fine on screen. + */ +describe("provider sentences", () => { + test("every chip over a fetched list receives the option state", () => { + const missing: string[] = []; + for (const { provider, source } of sentenceFiles()) { + for (const chip of source.match(//g) ?? []) { + // `options={[]}` is a chip with nothing to fetch (a typed branch + // name); only fetched lists have a state to report. + if (!/options=\{options\./.test(chip)) continue; + if (!chip.includes("state={state}")) { + const field = chip.match(/scope=\{[\w.?]*\.(\w+)\}/)?.[1] ?? "?"; + missing.push(`${provider}.${field}`); + } + } + } + expect(missing).toEqual([]); + }); + + test("no sentence reaches for the option-state map by group name", () => { + // The context hands each provider its own resolved state; naming the + // group again is how one gets pointed at the wrong list. + const offenders = sentenceFiles() + .filter(({ source }) => source.includes("optionState")) + .map(({ provider }) => provider); + expect(offenders).toEqual([]); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/registry.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/registry.test.ts new file mode 100644 index 0000000000..b575929bce --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/registry.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { labelText } from "./eventLabel"; +import { TRIGGER_PROVIDERS } from "./index"; +import type { TriggerMenuEntry } from "./types"; + +/** + * The Add Trigger menu: + * + * Scheduled ▸ Hourly · Daily · Weekly · Custom + * GitHub ▸ … + * Slack ▸ … + * Microsoft Teams▸ … + * Sentry ▸ … + * … + * + * Order is most-used first rather than alphabetical, which is the decision + * most at risk from a tidy-up: sorting this list would look like an + * improvement and would quietly bury the triggers people reach for. + */ + +const leaves = (entries: readonly TriggerMenuEntry[]): TriggerMenuEntry[] => + entries.flatMap((entry) => + "children" in entry ? leaves(entry.children) : [entry], + ); + +describe("the Add Trigger menu", () => { + test("leads with the providers people reach for, not the alphabet", () => { + const order = TRIGGER_PROVIDERS.map((provider) => provider.label); + expect(order.slice(0, 5)).toEqual([ + "Scheduled", + "GitHub", + "Slack", + "Microsoft Teams", + "Sentry", + ]); + expect(order).not.toEqual([...order].sort()); + }); + + // Deliberately four. A cadence is chosen once, when the trigger is added, + // so this list is the entire vocabulary of a scheduled row. + test("offers exactly four cadences", () => { + const schedule = TRIGGER_PROVIDERS.find((p) => p.kind === "schedule"); + expect(schedule?.menu.map((entry) => labelText(entry.label))).toEqual([ + "Hourly", + "Daily", + "Weekly", + "Custom", + ]); + }); + + test("every entry creates a config belonging to its own provider", () => { + for (const provider of TRIGGER_PROVIDERS) { + for (const leaf of leaves(provider.menu)) { + if (!("create" in leaf)) continue; + expect(leaf.create().kind).toBe(provider.kind); + } + } + }); + + // A row is rendered by whichever provider owns its kind, so two providers + // claiming one kind would make that lookup arbitrary. + test("no two providers claim the same kind", () => { + const kinds = TRIGGER_PROVIDERS.map((provider) => provider.kind); + expect(new Set(kinds).size).toBe(kinds.length); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.test.tsx new file mode 100644 index 0000000000..9b0ea5ad89 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.test.tsx @@ -0,0 +1,269 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// happy-dom over the preloaded plain-object document. Process-wide, so this +// unregisters in afterAll to leave the other renderer suites their document. +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { act, cleanup, fireEvent, render, within } = await import( + "@testing-library/react" +); +const { TriggerSentence } = await import("../../TriggerSentence"); + +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +/** + * What a scheduled row looks like, and must keep looking like. + * + * The cadence is fixed when the trigger is added — the Add Trigger menu is + * where Hourly and Daily are chosen — so a row never offers to switch between + * them, only to adjust the parameters of the one it is. + * + * [word ▾] a chip — a button you can open + * word fixed wording, not a control + * [🗑] remove, always last + */ + +const TZ = "America/Los_Angeles"; + +async function row(rrule: string, props: Record = {}) { + let view!: ReturnType; + await act(async () => { + view = render( + {}} + onRemove={() => {}} + options={{}} + {...props} + />, + ); + }); + const ui = within(view.baseElement as HTMLElement); + return { + ui, + view, + chip: (name: string | RegExp) => ui.getByRole("button", { name }), + queryChip: (name: string | RegExp) => ui.queryByRole("button", { name }), + sentence: [...(view.container.firstElementChild?.children ?? [])] + .map((child) => (child.textContent ?? "").replace(/\s+/g, " ").trim()) + .filter(Boolean) + .join(" "), + }; +} + +/** + * Hourly — nothing to adjust, so nothing is openable: + * + * 🕐 Every hour Next run in 20 minutes [🗑] + */ +describe("an hourly row", () => { + test("says only that it runs every hour", async () => { + const { sentence } = await row("FREQ=HOURLY"); + expect(sentence).toBe("Every hour"); + }); + + // There is no hour or day to pick when it runs every hour. + test("offers no time to adjust", async () => { + const { ui } = await row("FREQ=HOURLY"); + const openable = ui + .getAllByRole("button") + .filter((b) => b.getAttribute("aria-label") !== "Remove trigger"); + expect(openable).toHaveLength(0); + }); + + test("carries the next run when the editor works one out", async () => { + const { sentence } = await row("FREQ=HOURLY", { + nextRun: "Next run in 20 minutes", + }); + expect(sentence).toBe("Every hour Next run in 20 minutes"); + }); +}); + +/** + * Daily — one thing to adjust, and the zone it is fixed to: + * + * 🕐 Every day at [09:00 ▾] PST [🗑] + */ +describe("a daily row", () => { + // P[DS]T rather than either one: the abbreviation follows the current date, + // so pinning it would pass all summer and fail every November. + test("names the hour it runs at, and the zone", async () => { + const { sentence } = await row("FREQ=DAILY;BYHOUR=9;BYMINUTE=0"); + expect(sentence).toMatch(/^Every day at 09:00 P[DS]T$/); + }); + + test("lets the hour be changed", async () => { + const { chip } = await row("FREQ=DAILY;BYHOUR=9;BYMINUTE=0"); + expect(chip("09:00")).toBeDefined(); + }); + + // Read-only on purpose: the zone is captured when the trigger is created, + // and rebinding it to whoever is looking would silently move the run. + test("does not offer to change the zone", async () => { + const { queryChip } = await row("FREQ=DAILY;BYHOUR=9;BYMINUTE=0"); + expect(queryChip(/^P[DS]T$/)).toBeNull(); + }); + + // The list offers whole hours, but a rule written elsewhere (CLI, MCP) can + // carry minutes, and that value has to stay selectable. + test("keeps a non-whole hour written elsewhere", async () => { + const { chip } = await row("FREQ=DAILY;BYHOUR=9;BYMINUTE=30"); + expect(chip("09:30")).toBeDefined(); + }); +}); + +/** + * Weekly — a day as well as an hour: + * + * 🕐 Every week on [Monday ▾] at [09:00 ▾] PST [🗑] + */ +describe("a weekly row", () => { + test("names the day and the hour", async () => { + const { sentence } = await row("FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0"); + expect(sentence).toMatch(/^Every week on Monday at 09:00 P[DS]T$/); + }); + + test("lets the day be changed", async () => { + const { chip } = await row("FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0"); + expect(chip("Monday")).toBeDefined(); + }); +}); + +/** + * Custom — the rule itself, typed: + * + * 🕐 Custom schedule [FREQ=WEEKLY;BYDAY=MO,TU… ] [🗑] + */ +describe("a custom row", () => { + const CUSTOM = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0"; + + test("shows the rule for editing rather than a sentence", async () => { + const { ui } = await row(CUSTOM); + expect(ui.getByText("Custom schedule")).toBeDefined(); + expect((ui.getByRole("textbox") as HTMLInputElement).value).toBe(CUSTOM); + }); + + // The error takes the "Next run" slot rather than a second line: a rule + // that will not save has no next run, and showing both reads as a + // contradiction. + test("replaces the next run with the reason it will not save", async () => { + const { ui, view } = await row(CUSTOM, { nextRun: "Next run Friday" }); + await act(async () => { + fireEvent.change(ui.getByRole("textbox"), { + target: { value: "FREQ=NONSENSE" }, + }); + }); + const text = view.container.textContent ?? ""; + expect(text).toContain("Invalid recurrence rule — changes aren't saved"); + expect(text).not.toContain("Next run Friday"); + }); + + // Clearing the field is an edit that cannot save, but an empty rule has no + // parse error to report — without this it sat blank under the old next run. + test("says so when the rule is cleared rather than left invalid", async () => { + const { ui, view } = await row(CUSTOM, { nextRun: "Next run Friday" }); + await act(async () => { + fireEvent.change(ui.getByRole("textbox"), { target: { value: "" } }); + }); + const text = view.container.textContent ?? ""; + expect(text).toContain("Enter a recurrence rule — changes aren't saved"); + expect(text).not.toContain("Next run Friday"); + }); + + // A saved rule can be exhausted (a run-once schedule that already ran); + // that is history, not an edit gone wrong, so it is not complained about. + test("does not complain about the saved rule until it is edited", async () => { + const { view } = await row(CUSTOM, { nextRun: "Next run Friday" }); + const text = view.container.textContent ?? ""; + expect(text).toContain("Next run Friday"); + expect(text).not.toContain("aren't saved"); + }); +}); + +describe("a scheduled row that cannot be edited", () => { + test("disables every control", async () => { + const { ui } = await row("FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0", { + disabled: true, + }); + for (const button of ui.getAllByRole("button")) { + expect((button as HTMLButtonElement).disabled).toBe(true); + } + }); +}); + +/** + * A schedule needs no integration, so it never collapses the way a provider + * row does — there is nothing to connect. + */ +describe("a scheduled row never asks for a connection", () => { + test("keeps its sentence and its remove button", async () => { + const { sentence, ui } = await row("FREQ=HOURLY"); + expect(sentence).toBe("Every hour"); + expect(ui.getByLabelText("Remove trigger")).toBeDefined(); + }); +}); + +/** + * The row is one line, not a stack: + * + * 🕐 Every week on [Monday ▾] at [09:00 ▾] PDT Next run … [🗑] + * + * Every other provider's sentence renders as a fragment, so its words are + * siblings of the row's icon and wrap with it. A schedule that wrapped itself + * in a div became one flex item that could not share a line with the icon, and + * the row stacked into four. + */ +describe("a scheduled row's parts sit on the row itself", () => { + test("the sentence is not boxed away from the icon", async () => { + const { view } = await row("FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0"); + const parts = [...(view.container.firstElementChild?.children ?? [])]; + // icon + "Every week" + "on" + day + "at" + time + zone + remove + expect(parts.length).toBeGreaterThanOrEqual(7); + }); + + test("the icon leads the row rather than owning a line", async () => { + const { view } = await row("FREQ=HOURLY"); + expect(view.container.firstElementChild?.firstElementChild?.tagName).toBe( + "svg", + ); + }); +}); + +/** + * The cadence is chosen once, in the Add Trigger menu. A row never offers to + * become a different one — switching Weekly to Hourly would silently discard + * the day and hour it was carrying. + */ +describe("a scheduled row cannot change cadence", () => { + test("offers no way to become hourly or daily", async () => { + const { queryChip } = await row("FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0"); + expect(queryChip(/Hourly|Daily|Weekly|Custom/)).toBeNull(); + }); + + // "Would run" was dropped: the row says when it runs next, not what it + // would hypothetically do. + test("does not hedge about whether it runs", async () => { + const { view } = await row("FREQ=HOURLY", { + nextRun: "Next run in 20 minutes", + }); + expect(view.container.textContent ?? "").not.toContain("Would run"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.tsx index 5692b92fbf..0b122994fa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/schedule/schedule.tsx @@ -10,10 +10,10 @@ type ScheduleConfig = Extract; * rather than when this module loads — otherwise every schedule created in a * long-lived window shares the timestamp the app booted at. */ -function createScheduleConfig(): ScheduleConfig { +function createScheduleConfig(rrule: string): ScheduleConfig { return { kind: "schedule", - rrule: "FREQ=DAILY;BYHOUR=9;BYMINUTE=0", + rrule, dtstart: new Date().toISOString(), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", }; @@ -23,7 +23,30 @@ export const scheduleProvider: TriggerProvider = { kind: "schedule", label: "Scheduled", icon: LuClock, - menu: [{ label: "Scheduled", create: createScheduleConfig }], + menu: [ + { + label: "Hourly", + create: () => createScheduleConfig("FREQ=HOURLY"), + }, + { + label: "Daily", + create: () => createScheduleConfig("FREQ=DAILY;BYHOUR=9;BYMINUTE=0"), + }, + { + label: "Weekly", + create: () => + createScheduleConfig("FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0"), + }, + { + // No preset matches this rule, so the row opens in raw-RRULE editing + // with a valid weekdays template already in the box. + label: "Custom", + create: () => + createScheduleConfig( + "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0", + ), + }, + ], renderSentence: (config, { set, nextRun, disabled }) => ( = { }; export const SENTRY_MENU: TriggerMenuEntry[] = [ - leaf("Issue created", "issue.created"), - leaf("Issue resolved", "issue.resolved"), - leaf("Issue assigned", "issue.assigned"), - leaf("Issue archived", "issue.archived"), - leaf("Issue unresolved", "issue.unresolved"), - leaf("Any issue event", "issue.any"), + leaf( + msg({ + id: "dashboard.automations.providers.sentry.menuIssueCreated", + message: "Issue created", + }), + "issue.created", + ), + leaf( + msg({ + id: "dashboard.automations.providers.sentry.menuIssueResolved", + message: "Issue resolved", + }), + "issue.resolved", + ), + leaf( + msg({ + id: "dashboard.automations.providers.sentry.menuIssueAssigned", + message: "Issue assigned", + }), + "issue.assigned", + ), + leaf( + msg({ + id: "dashboard.automations.providers.sentry.menuIssueArchived", + message: "Issue archived", + }), + "issue.archived", + ), + leaf( + msg({ + id: "dashboard.automations.providers.sentry.menuIssueUnresolved", + message: "Issue unresolved", + }), + "issue.unresolved", + ), + leaf( + msg({ + id: "dashboard.automations.providers.sentry.menuIssueAny", + message: "Any issue event", + }), + "issue.any", + ), ]; -function leaf(label: string, event: SentryTriggerEvent) { +function leaf(label: MessageDescriptor, event: SentryTriggerEvent) { return { label, create: () => createSentryConfig(event) }; } /** Sentry's fixed severity levels; the ids are what the webhook payload carries. */ -export const SENTRY_LEVELS = [ - { id: "fatal", label: "Fatal" }, - { id: "error", label: "Error" }, - { id: "warning", label: "Warning" }, - { id: "info", label: "Info" }, - { id: "debug", label: "Debug" }, +export const SENTRY_LEVELS: { id: string; label: MessageDescriptor }[] = [ + { + id: "fatal", + label: msg({ + id: "dashboard.automations.providers.sentry.levelFatal", + message: "Fatal", + }), + }, + { + id: "error", + label: msg({ + id: "dashboard.automations.providers.sentry.levelError", + message: "Error", + }), + }, + { + id: "warning", + label: msg({ + id: "dashboard.automations.providers.sentry.levelWarning", + message: "Warning", + }), + }, + { + id: "info", + label: msg({ + id: "dashboard.automations.providers.sentry.levelInfo", + message: "Info", + }), + }, + { + id: "debug", + label: msg({ + id: "dashboard.automations.providers.sentry.levelDebug", + message: "Debug", + }), + }, ]; /** diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx index e50dc4de6a..576f78ed38 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/sentry/sentry.tsx @@ -10,11 +10,24 @@ import { type Slot, } from "./grammar"; +/** + * Sentry's severity levels. A fixed enum on Sentry's side, so it is listed here + * rather than fetched — there is no per-organization variation to discover, and + * a round trip would make the chip depend on a live connection to offer them. + */ +const SENTRY_LEVELS = [ + { id: "fatal", label: "Fatal" }, + { id: "error", label: "Error" }, + { id: "warning", label: "Warning" }, + { id: "info", label: "Info" }, + { id: "debug", label: "Debug" }, +]; + function renderSlot( config: SentryConfig, slot: Slot, index: number, - { set, mark, options, disabled }: SentenceContext, + { set, mark, options, state, disabled }: SentenceContext, ) { switch (slot) { case "projects": @@ -27,6 +40,7 @@ function renderSlot( options={options.sentry?.projects ?? []} emptyLabel="Select projects" anyLabel="Any project" + state={state} disabled={disabled} /> ); @@ -41,7 +55,7 @@ function renderSlot( onChange={(v) => set({ level: isEmptyScope(v) ? { mode: "any" } : v }) } - options={options.sentry?.levels ?? []} + options={SENTRY_LEVELS} emptyLabel="Any level" anyLabel="Any level" disabled={disabled} @@ -52,6 +66,7 @@ function renderSlot( export const sentryProvider: TriggerProvider = { kind: "sentry", + connectionProvider: "sentry", optionGroup: "sentry", label: "Sentry", icon: SiSentry, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/components/EmojiNameChip/EmojiNameChip.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/components/EmojiNameChip/EmojiNameChip.test.tsx new file mode 100644 index 0000000000..fe75d2ba37 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/components/EmojiNameChip/EmojiNameChip.test.tsx @@ -0,0 +1,191 @@ +import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// happy-dom over the preloaded plain-object document. Process-wide, so this +// unregisters in afterAll to leave the other renderer suites their document. +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { act, cleanup, fireEvent, render, within } = await import( + "@testing-library/react" +); +const { EmojiNameChip } = await import("./EmojiNameChip"); + +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +/** + * The reaction picker, open: + * + * [✅ ▾] + * ┌──────────────────────┐ + * │ No reaction │ ← only where clearing is a choice + * │ ✅ │ ← the canonical default + * │ [:custom_emoji_name:]│ ← anything else, typed + * └──────────────────────┘ + * + * Slack has no API listing standard emoji, and a workspace's custom ones are + * the ones people most want to react with, so a full picker would always be + * missing the one that matters. The rows cover none and the default; typing + * takes over the selection. + */ +async function chip(props: Partial[0]> = {}) { + const onChange = mock((_next: string[]) => {}); + let view!: ReturnType; + await act(async () => { + view = render( + , + ); + }); + const ui = within(view.baseElement as HTMLElement); + const label = () => ui.getAllByRole("button")[0]?.textContent ?? ""; + const open = async () => { + await act(async () => { + ui.getAllByRole("button")[0]?.click(); + }); + return ui; + }; + return { ui, onChange, label, open }; +} + +describe("the reaction chip, closed", () => { + test("shows the glyph of a known reaction", async () => { + const { label } = await chip({ names: ["white_check_mark"] }); + expect(label()).toContain("✅"); + }); + + // A workspace's custom emoji have no glyph and read as :name:. + test("shows a custom reaction by name", async () => { + const { label } = await chip({ names: ["shipit"] }); + expect(label()).toContain(":shipit:"); + }); + + test("counts several", async () => { + const { label } = await chip({ names: ["eyes", "bug"] }); + expect(label()).toContain("2 reactions"); + }); + + test("says so when there is none", async () => { + const { label } = await chip({ names: [] }); + expect(label()).toContain("No reaction"); + }); +}); + +describe("the reaction chip, open", () => { + test("offers to clear the reaction", async () => { + const { open } = await chip({ names: ["white_check_mark"] }); + const ui = await open(); + expect(ui.getAllByText("No reaction").length).toBeGreaterThan(0); + }); + + test("offers the canonical default", async () => { + const { open } = await chip({ names: [] }); + const ui = await open(); + expect(ui.getByText("✅")).toBeDefined(); + }); + + test("invites anything else to be typed", async () => { + const { open } = await chip({ names: [] }); + const ui = await open(); + expect(ui.getByPlaceholderText(":custom_emoji_name:")).toBeDefined(); + }); + + test("clearing selects nothing", async () => { + const { open, onChange, ui } = await chip({ names: ["white_check_mark"] }); + await open(); + const none = ui + .getAllByText("No reaction") + .find((el) => el.tagName === "BUTTON" || el.closest("button")); + await act(async () => { + (none?.closest("button") ?? none)?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + expect(onChange).toHaveBeenCalledWith([]); + }); +}); + +describe("typing a reaction", () => { + // Live, not on close: the chip has to follow what is being typed, which is + // the only feedback that the name was understood. + test("updates the selection as it is typed", async () => { + const { open, onChange, ui } = await chip({ names: [] }); + await open(); + await act(async () => { + fireEvent.change(ui.getByPlaceholderText(":custom_emoji_name:"), { + target: { value: "af" }, + }); + }); + expect(onChange).toHaveBeenCalledWith(["af"]); + }); + + // Colons and commas are optional on the way in — people paste ":bug:" as + // readily as they type "bug". + test("accepts a name with colons around it", async () => { + const { open, onChange, ui } = await chip({ names: [] }); + await open(); + await act(async () => { + fireEvent.change(ui.getByPlaceholderText(":custom_emoji_name:"), { + target: { value: ":bug:" }, + }); + }); + expect(onChange).toHaveBeenCalledWith(["bug"]); + }); + + test("accepts several at once", async () => { + const { open, onChange, ui } = await chip({ names: [] }); + await open(); + await act(async () => { + fireEvent.change(ui.getByPlaceholderText(":custom_emoji_name:"), { + target: { value: "bug, eyes" }, + }); + }); + expect(onChange).toHaveBeenCalledWith(["bug", "eyes"]); + }); +}); + +/** + * The emoji slot of a reaction trigger has no "none" and no default — a + * reaction trigger has to name its reaction, so there is nothing to clear it + * to, only the field. + */ +describe("the reaction chip where a reaction is required", () => { + const required = () => + chip({ + names: [], + emptyLabel: "Select emoji", + placeholder: ":bug: or bug, eyes", + noneLabel: undefined, + defaultName: undefined, + }); + + test("asks for one", async () => { + const { label } = await required(); + expect(label()).toContain("Select emoji"); + }); + + test("offers no way to clear it", async () => { + const { open } = await required(); + const ui = await open(); + expect(ui.queryByText("No reaction")).toBeNull(); + }); + + test("is nothing but the field", async () => { + const { open } = await required(); + const ui = await open(); + expect(ui.getByPlaceholderText(":bug: or bug, eyes")).toBeDefined(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/components/EmojiNameChip/EmojiNameChip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/components/EmojiNameChip/EmojiNameChip.tsx index 16c63ef657..9d0129adb0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/components/EmojiNameChip/EmojiNameChip.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/components/EmojiNameChip/EmojiNameChip.tsx @@ -1,22 +1,29 @@ import { Input } from "@superset/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { useState } from "react"; +import { LuCheck } from "react-icons/lu"; import { ChipButton } from "../../../../TriggerSentence/components/ChipButton"; import { emojiLabel, parseEmojiNames } from "../../emoji"; +const ROW = + "flex w-full items-center justify-between rounded-[6px] px-2 py-1.5 text-left text-sm hover:bg-accent"; + /** - * One or more emoji names, typed rather than picked. + * One or more emoji names: the obvious choices as rows, anything else typed. * * Slack has no API that lists standard emoji, and a workspace's custom emoji - * are the ones people most want to react with, so a picker would always be - * missing the one that matters. Typing ":deployparrot:" or "bug, eyes" is the - * whole interaction; the chip normalizes it to the bare names Slack sends. + * are the ones people most want to react with, so a full picker would always + * be missing the one that matters. The rows cover "none" and the default; + * typing in the field below takes over the selection, and the chip shows the + * typed name back as ":name:" — colons and commas optional on the way in. */ export function EmojiNameChip({ names, onChange, emptyLabel, placeholder, + noneLabel, + defaultName, disabled, className, }: { @@ -24,6 +31,14 @@ export function EmojiNameChip({ onChange: (names: string[]) => void; emptyLabel: string; placeholder: string; + /** + * Label for an explicit "none" row. Absent where empty is not a choice — + * a reaction trigger has to name its reaction, so there is no row for + * clearing it, only the field. + */ + noneLabel?: string; + /** A canonical choice worth its own row — the ✅ a completion defaults to. */ + defaultName?: string; disabled?: boolean; className?: string; }) { @@ -32,12 +47,24 @@ export function EmojiNameChip({ // trailing comma or colon does not vanish under the person's cursor. const [draft, setDraft] = useState(null); - const label = - names.length === 0 - ? emptyLabel - : names.length === 1 - ? emojiLabel(names[0] ?? "") - : `${names.length} reactions`; + const isNone = names.length === 0; + const isDefault = + defaultName !== undefined && names.length === 1 && names[0] === defaultName; + // Only a typed selection belongs in the field — echoing the default row's + // name into it would read as two selections at once. + const isTyped = !isNone && !isDefault; + + const label = isNone + ? emptyLabel + : names.length === 1 + ? emojiLabel(names[0] ?? "") + : `${names.length} reactions`; + + const pick = (next: string[]) => { + onChange(next); + setDraft(null); + setOpen(false); + }; return ( - + + {noneLabel !== undefined && ( + + )} + {defaultName !== undefined && ( + + )} `:${n}:`).join(" ")} + value={draft ?? (isTyped ? names.map((n) => `:${n}:`).join(" ") : "")} placeholder={placeholder} disabled={disabled} onChange={(event) => { @@ -71,7 +113,11 @@ export function EmojiNameChip({ onKeyDown={(event) => { if (event.key === "Enter") setOpen(false); }} - className="h-8 text-[13px]" + className={ + noneLabel !== undefined || defaultName !== undefined + ? "mt-1.5 h-8 text-[13px]" + : "h-8 text-[13px]" + } /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/emoji.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/emoji.ts index a8ae8e81c2..e44c7b7a27 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/emoji.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/emoji.ts @@ -42,10 +42,9 @@ const SLACK_EMOJI_GLYPHS: Record = { ship: "🚢", }; -/** How one emoji name reads in a chip: "🐛 bug", or ":deployparrot:" for a custom one. */ +/** How one emoji name reads in a chip: "🐛", or ":deployparrot:" for a custom one. */ export function emojiLabel(name: string): string { - const glyph = SLACK_EMOJI_GLYPHS[name]; - return glyph ? `${glyph} ${name}` : `:${name}:`; + return SLACK_EMOJI_GLYPHS[name] ?? `:${name}:`; } /** diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/grammar.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/grammar.ts index 2e71a59843..be11a5010c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/grammar.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/grammar.ts @@ -1,3 +1,5 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; import type { SlackTriggerEvent, TriggerConfigInput, @@ -17,42 +19,64 @@ export type Slot = | "emoji" | "actor" | "messageFilter" - | "topLevelOnly" | "completionReaction"; export type SentencePart = { text: string } | { slot: Slot }; export const SLACK_SENTENCES: Record = { + // The filter chip is the subject — "[Any message] from [Anyone] in [#x]" — + // rather than trailing a "Message" label it would collide with. message_in_channel: [ - { text: "Message" }, { slot: "messageFilter" }, + { text: "from" }, + { slot: "actor" }, { text: "in" }, { slot: "channels" }, - { text: "by" }, - { slot: "actor" }, - { slot: "topLevelOnly" }, { text: "; react with" }, { slot: "completionReaction" }, { text: "upon completion" }, ], + // Actor beside its verb: "added by" — at the end it read as the message's + // author rather than the reactor's. reaction_added: [ { text: "Reaction" }, { slot: "emoji" }, - { text: "on a message in" }, - { slot: "channels" }, - { text: "by" }, + { text: "added by" }, { slot: "actor" }, + { text: "to a message in" }, + { slot: "channels" }, + ], + channel_created: [ + { text: "Channel created matching" }, + { slot: "messageFilter" }, ], - channel_created: [{ text: "Channel created" }, { slot: "messageFilter" }], }; export const SLACK_MENU: TriggerMenuEntry[] = [ - leaf("Message in channel", "message_in_channel"), - leaf("Reaction added", "reaction_added"), - leaf("Channel created", "channel_created"), + leaf( + msg({ + id: "dashboard.automations.providers.slack.menuMessageInChannel", + message: "Message in channel", + }), + "message_in_channel", + ), + leaf( + msg({ + id: "dashboard.automations.providers.slack.menuReactionAdded", + message: "Reaction added", + }), + "reaction_added", + ), + leaf( + msg({ + id: "dashboard.automations.providers.slack.menuChannelCreated", + message: "Channel created", + }), + "channel_created", + ), ]; -function leaf(label: string, event: SlackTriggerEvent) { +function leaf(label: MessageDescriptor, event: SlackTriggerEvent) { return { label, create: () => createSlackConfig(event) }; } @@ -70,13 +94,13 @@ export function createSlackConfig(event: SlackTriggerEvent): SlackConfig { // "in" one, so that event has no channel to choose and stays wide open. channels: event === "channel_created" ? { mode: "any" } : { mode: "list", ids: [] }, - // The reaction is an optional narrowing, so it starts at "any" — an - // empty list would render as "Any reaction" while matching nothing. - emoji: { mode: "any" }, + // A reaction trigger names its reaction — the empty list refuses to save + // until one is typed, same as channels. Elsewhere the field is unused + // and stays wide open. + emoji: + event === "reaction_added" ? { mode: "list", ids: [] } : { mode: "any" }, actor: { mode: "any" }, messageFilter: null, - // A busy thread would otherwise fire once per reply. - topLevelOnly: true, // The message trigger acknowledges the post it ran for; the others have // no single message to react to. completionReaction: diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.test.tsx new file mode 100644 index 0000000000..5a1fdef9e5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.test.tsx @@ -0,0 +1,424 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// happy-dom over the preloaded plain-object document. Process-wide, so this +// unregisters in afterAll to leave the other renderer suites their document. +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { act, cleanup, render, within } = await import("@testing-library/react"); +const { TriggerSentence } = await import("../../TriggerSentence"); +const { slackProvider } = await import("./slack"); + +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +/** + * What a Slack row looks like, and must keep looking like. + * + * The diagrams over each block are the point of this file: the components + * underneath are expected to be generalized, and these say what the result has + * to render regardless of how it is built. Copy, order and which words are + * openable are all fixed here on purpose. + * + * [word ▾] a chip — a button you can open + * word fixed wording, not a control + * (!) marked as blocking the save + * [🗑] remove, always last + */ + +const CHANNELS = [ + { id: "C1", label: "#general", botMember: true }, + { id: "C2", label: "#secret", botMember: false }, +]; + +async function row( + config: Record, + props: Record = {}, +) { + let view!: ReturnType; + await act(async () => { + view = render( + {}} + onRemove={() => {}} + options={{ slack: { channels: CHANNELS, people: [] } }} + {...props} + />, + ); + }); + const ui = within(view.baseElement as HTMLElement); + return { + ui, + chip: (name: string | RegExp) => ui.getByRole("button", { name }), + queryChip: (name: string | RegExp) => ui.queryByRole("button", { name }), + open: async (name: string | RegExp) => { + await act(async () => { + ui.getByRole("button", { name }).click(); + }); + return ui; + }, + // Joined from the row's own parts, not textContent: the spaces a reader + // sees are flex gaps, so textContent runs the words together. + sentence: [...(view.container.firstElementChild?.children ?? [])] + .map((child) => (child.textContent ?? "").replace(/\s+/g, " ").trim()) + .filter(Boolean) + .join(" "), + }; +} + +const message = (over: Record = {}) => ({ + kind: "slack", + event: "message_in_channel", + messageFilter: null, + actor: { mode: "any" }, + channels: { mode: "list", ids: [] }, + completionReaction: "white_check_mark", + ...over, +}); + +const reaction = (over: Record = {}) => ({ + kind: "slack", + event: "reaction_added", + emoji: { mode: "list", ids: [] }, + actor: { mode: "any" }, + channels: { mode: "list", ids: ["C1"] }, + ...over, +}); + +/** + * A message row, as added: + * + * ⌗ [Any message ▾] from Anyone in [Select channels ▾] + * ; react with [✅ ▾] upon completion [🗑] + */ +describe("a Slack message row that was just added", () => { + test("asks for a channel", async () => { + const { chip } = await row(message()); + expect(chip("Select channels")).toBeDefined(); + }); + + test("matches any message by default", async () => { + const { chip } = await row(message()); + expect(chip("Any message")).toBeDefined(); + }); + + // Ahead-of-time people filters were removed from Slack rows: every new + // trigger listens to anyone, so this is a statement rather than a choice. + test("listens to anyone, and does not offer to narrow it", async () => { + const { ui, queryChip } = await row(message()); + expect(ui.getByText("Anyone")).toBeDefined(); + expect(queryChip("Anyone")).toBeNull(); + }); + + test("a row saved with a people filter keeps its chip", async () => { + const { chip } = await row( + message({ actor: { mode: "list", ids: ["U1"] } }), + ); + expect(chip("U1")).toBeDefined(); + }); + + // A row saved before this field existed has no key at all, and the schema + // defaults it on save, so the chip has to show the same default. + test("acknowledges with a check mark by default", async () => { + const { chip } = await row(message()); + expect(chip("✅")).toBeDefined(); + }); + + test("can be removed", async () => { + const { ui } = await row(message()); + expect(ui.getByLabelText("Remove trigger")).toBeDefined(); + }); +}); + +/** + * The message filter, once it has something in it: + * + * ⌗ [Matching "deploy" ▾] from Anyone in [#general ▾] … + */ +describe("a Slack row filtering the message body", () => { + // The verb matters: a bare quoted string beside the sentence reads as the + // message itself rather than as a filter over it. + test("says what it is matching, in quotes", async () => { + const { chip } = await row( + message({ messageFilter: { pattern: "deploy", isRegex: false } }), + ); + expect(chip('Matching "deploy"')).toBeDefined(); + }); + + test("says Any message when there is no filter", async () => { + const { chip } = await row(message()); + expect(chip("Any message")).toBeDefined(); + }); +}); + +/** + * Channels, one and many: + * + * … in [#general ▾] … in [2 channels ▾] + */ +describe("a Slack row that names its channels", () => { + test("names one channel", async () => { + const { chip } = await row( + message({ channels: { mode: "list", ids: ["C1"] } }), + ); + expect(chip("#general")).toBeDefined(); + }); + + test("counts several", async () => { + const { chip } = await row( + message({ channels: { mode: "list", ids: ["C1", "C2"] } }), + ); + expect(chip("2 channels")).toBeDefined(); + }); + + // Slack only delivers events from channels the bot is in, so "any channel" + // would promise more than it can watch. + test("does not offer to watch every channel", async () => { + const { open } = await row(message()); + const picker = await open("Select channels"); + expect(picker.queryByText("Any channel")).toBeNull(); + }); + + // The roster lists only channels the bot can see plus public ones; a pasted + // id is the way in for anything else. + test("invites a channel id to be pasted", async () => { + const { open } = await row(message()); + const picker = await open("Select channels"); + expect( + picker.getByPlaceholderText("Search channels or paste channel ID..."), + ).toBeDefined(); + }); +}); + +/** + * A reaction row: + * + * ⌗ Reaction [Select emoji ▾] added by Anyone to a message in [#general ▾] + */ +describe("a Slack reaction row", () => { + test("asks which reaction, since it has no default", async () => { + const { chip } = await row(reaction()); + expect(chip("Select emoji")).toBeDefined(); + }); + + test("shows a known reaction as its glyph", async () => { + const { chip } = await row( + reaction({ emoji: { mode: "list", ids: ["eyes"] } }), + ); + expect(chip("👀")).toBeDefined(); + }); + + // A workspace's custom emoji have no glyph here and read as :name:. + test("shows a custom reaction by name", async () => { + const { chip } = await row( + reaction({ emoji: { mode: "list", ids: ["shipit"] } }), + ); + expect(chip(":shipit:")).toBeDefined(); + }); + + test("counts several reactions", async () => { + const { chip } = await row( + reaction({ emoji: { mode: "list", ids: ["eyes", "bug"] } }), + ); + expect(chip("2 reactions")).toBeDefined(); + }); +}); + +/** + * The completion reaction, which can be turned off: + * + * … ; react with [No reaction ▾] upon completion + */ +describe("a Slack row's completion reaction", () => { + test("can be set to none", async () => { + const { chip } = await row(message({ completionReaction: null })); + expect(chip("No reaction")).toBeDefined(); + }); + + test("shows a custom reaction by name", async () => { + const { chip } = await row(message({ completionReaction: "shipit" })); + expect(chip(":shipit:")).toBeDefined(); + }); +}); + +/** + * A channel-created row — no channel to pick, because it is workspace-wide: + * + * ⌗ Channel created matching [Any name ▾] [🗑] + */ +describe("a Slack channel-created row", () => { + const created = (over: Record = {}) => ({ + kind: "slack", + event: "channel_created", + messageFilter: null, + actor: { mode: "any" }, + channels: { mode: "any" }, + ...over, + }); + + test("filters on the name and nothing else", async () => { + const { chip, queryChip } = await row(created()); + expect(chip("Any name")).toBeDefined(); + expect(queryChip("Select channels")).toBeNull(); + }); + + test("says what name it is matching", async () => { + const { chip } = await row( + created({ messageFilter: { pattern: "incident-", isRegex: false } }), + ); + expect(chip('Matching "incident-"')).toBeDefined(); + }); +}); + +/** + * A row watching a channel the bot is not in — configured fine, silent + * forever. The warning is the only thing that says so. + */ +describe("a Slack row watching a channel the bot is not in", () => { + const warningsFor = (config: Record) => + slackProvider.runtimeWarnings?.(config as never, { + slack: { channels: CHANNELS }, + }) ?? []; + + test("names the channel and says who to invite", () => { + expect( + warningsFor(message({ channels: { mode: "list", ids: ["C1", "C2"] } })), + ).toEqual([ + "This trigger will not run for messages in #secret until @Superset is invited.", + ]); + }); + + test("stays quiet when the bot is in every channel it watches", () => { + expect( + warningsFor(message({ channels: { mode: "list", ids: ["C1"] } })), + ).toEqual([]); + }); + + // A new channel is announced workspace-wide, so membership is irrelevant + // and warning about it would be noise on a trigger that works. + test("stays quiet for channel_created", () => { + expect( + warningsFor({ + kind: "slack", + event: "channel_created", + channels: { mode: "list", ids: ["C2"] }, + }), + ).toEqual([]); + }); + + test("does not accuse a channel before the roster has arrived", () => { + expect( + slackProvider.runtimeWarnings?.( + message({ channels: { mode: "list", ids: ["C2"] } }) as never, + {}, + ), + ).toEqual([]); + }); +}); + +/** + * Not connected — the sentence collapses, because nothing in it could be + * filled in: + * + * ⌗ Message in channel Requires connection [🗑] [Connect ↗] + */ +describe("a Slack row whose integration is not connected", () => { + const disconnected = () => row(message(), { requiresConnection: true }); + + test("collapses to the name of the trigger", async () => { + const { ui } = await disconnected(); + expect(ui.getByText("Message in channel")).toBeDefined(); + }); + + test("says a connection is required and offers the fix", async () => { + const { ui, chip } = await disconnected(); + expect(ui.getByText("Requires connection")).toBeDefined(); + expect(chip(/Connect/)).toBeDefined(); + }); + + test("hides the pickers entirely", async () => { + const { queryChip } = await disconnected(); + expect(queryChip("Select channels")).toBeNull(); + expect(queryChip("Any message")).toBeNull(); + }); + + test("can still be removed", async () => { + const { ui } = await disconnected(); + expect(ui.getByLabelText("Remove trigger")).toBeDefined(); + }); +}); + +/** + * A refused save marks the word it is blocked on, and only that one: + * + * ⌗ [Any message ▾] from Anyone in [Select channels ▾](!) + */ +describe("a Slack row a save was refused on", () => { + const MARK = "ring-amber-500/50"; + const refused = () => + row(message(), { + problems: [{ index: 0, field: "channels", message: "Choose a channel" }], + }); + + test("marks the chip the save is blocked on", async () => { + const { chip } = await refused(); + expect(chip("Select channels").className).toContain(MARK); + }); + + test("leaves the chips that are fine alone", async () => { + const { chip } = await refused(); + expect(chip("Any message").className).not.toContain(MARK); + }); +}); + +describe("a Slack row that cannot be edited", () => { + test("disables every word of the sentence", async () => { + const { ui } = await row(message(), { disabled: true }); + for (const button of ui.getAllByRole("button")) { + expect((button as HTMLButtonElement).disabled).toBe(true); + } + }); +}); + +/** + * The order and wording, which per-element queries cannot see: getByRole finds + * a chip wherever it sits, so only the whole sentence catches a slot moving or + * a connecting word changing. + */ +describe("the wording of a Slack row", () => { + // The filter chip is the subject rather than trailing a "Message" label it + // would collide with. + test("a message trigger leads with the filter, then who, then where", async () => { + const { sentence } = await row(message()); + expect(sentence).toBe( + "Any message from Anyone in Select channels ; react with ✅ upon completion", + ); + }); + + // Actor beside its verb: at the end it read as the message's author rather + // than the reactor's. + test("a reaction trigger puts the actor beside its verb", async () => { + const { sentence } = await row(reaction()); + expect(sentence).toBe( + "Reaction Select emoji added by Anyone to a message in #general", + ); + }); + + test("a channel-created trigger filters on the name and nothing else", async () => { + const { sentence } = await row({ + kind: "slack", + event: "channel_created", + messageFilter: null, + actor: { mode: "any" }, + channels: { mode: "any" }, + }); + expect(sentence).toBe("Channel created matching Any name"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx index f75961b3b9..b69fd04b0c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/slack/slack.tsx @@ -1,6 +1,5 @@ import { FaSlack } from "react-icons/fa"; import { ScopeChip } from "../../TriggerSentence/components/ScopeChip"; -import { SelectChip } from "../../TriggerSentence/components/SelectChip"; import { TextFilterChip } from "../../TriggerSentence/components/TextFilterChip"; import { Sentence } from "../components/Sentence"; import type { SentenceContext, TriggerProvider } from "../types"; @@ -12,11 +11,6 @@ import { type Slot, } from "./grammar"; -const THREAD_OPTIONS = [ - { value: "top", label: "top-level only" }, - { value: "replies", label: "including replies" }, -] as const; - /** * Renders one slot of a Slack sentence. Each slot names the config field it * edits, so `set` patches by that name and `mark` finds it in the problems. @@ -25,7 +19,7 @@ function renderSlot( config: SlackConfig, slot: Slot, index: number, - { set, mark, options, disabled }: SentenceContext, + { set, mark, options, state, disabled }: SentenceContext, ) { switch (slot) { case "channels": @@ -38,6 +32,16 @@ function renderSlot( options={options.slack?.channels ?? []} emptyLabel="Select channels" anyLabel="Any channel" + // Slack only delivers events from channels the bot is in, so "any + // channel" would promise more than it can watch. + allowAny={false} + countNoun={{ singular: "channel", plural: "channels" }} + // The roster only lists channels the bot can see plus public ones; + // a pasted id is the way in for anything else. + allowCustom={{ + placeholder: "Search channels or paste channel ID...", + }} + state={state} disabled={disabled} /> ); @@ -46,23 +50,26 @@ function renderSlot( - set({ - emoji: names.length - ? { mode: "list", ids: names } - : { mode: "any" }, - }) - } + onChange={(names) => set({ emoji: { mode: "list", ids: names } })} className={mark("emoji")} - emptyLabel="Any reaction" + emptyLabel="Select emoji" placeholder=":bug: or bug, eyes" disabled={disabled} /> ); - case "actor": + case "actor": { + // Ahead-of-time people filters are gone from the editor — every new + // trigger listens to anyone. A legacy row that still carries a list + // keeps its chip, so the filter stays visible and removable. + const legacyList = + config.actor.mode === "list" && config.actor.ids.length > 0; + if (!legacyList) { + return ( + + Anyone + + ); + } return ( ); + } case "messageFilter": { // The same field filters a message's text or a new channel's name; // only the words around it change. @@ -94,16 +104,6 @@ function renderSlot( /> ); } - case "topLevelOnly": - return ( - set({ topLevelOnly: v === "top" })} - options={THREAD_OPTIONS} - disabled={disabled} - /> - ); case "completionReaction": { // A row saved before this field existed has no key at all; the schema // defaults it on save, so the chip must show the same default rather @@ -122,7 +122,9 @@ function renderSlot( set({ completionReaction: names[names.length - 1] ?? null }) } emptyLabel="No reaction" - placeholder=":white_check_mark:" + placeholder=":custom_emoji_name:" + noneLabel="No reaction" + defaultName="white_check_mark" disabled={disabled} /> ); @@ -132,6 +134,7 @@ function renderSlot( export const slackProvider: TriggerProvider = { kind: "slack", + connectionProvider: "slack", optionGroup: "slack", label: "Slack", icon: FaSlack, @@ -143,4 +146,23 @@ export const slackProvider: TriggerProvider = { renderSlot={(slot, index) => renderSlot(config, slot, index, ctx)} /> ), + // Slack only delivers message events for channels the bot is in, so a + // trigger watching a channel it hasn't joined is configured fine and + // permanently silent. Saving auto-joins public channels; what this warns + // about is the rest — private channels, and installs without the join + // scope yet — where a human invite is the only fix. + runtimeWarnings: (config, options) => { + if (config.event === "channel_created") return []; + if (config.channels.mode !== "list") return []; + const roster = options.slack?.channels ?? []; + const outside = config.channels.ids.flatMap((id) => { + const option = roster.find((o) => o.id === id); + return option?.botMember === false ? [option.label] : []; + }); + if (outside.length === 0) return []; + const list = outside.join(", "); + return [ + `This trigger will not run for messages in ${list} until @Superset is invited.`, + ]; + }, }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/types.ts index a2313a931f..eaf8e5cb39 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/types.ts @@ -1,3 +1,4 @@ +import type { MessageDescriptor } from "@lingui/core"; import type { TriggerConfigInput } from "@superset/shared/automation-triggers"; import type { ReactNode } from "react"; import type { IconType } from "react-icons"; @@ -17,9 +18,24 @@ export type TriggerProvider< > = { /** The discriminant on the trigger config. */ kind: Config["kind"]; - /** How the provider appears in the Add Trigger menu and at the row start. */ - label: string; + /** + * How the provider appears in the Add Trigger menu and at the row start. + * A plain string for brand names (GitHub, Slack, …), which never + * translate; a `msg()` descriptor for translatable phrases (Scheduled). + */ + label: string | MessageDescriptor; icon: IconType; + /** + * The `integration_provider` this trigger needs connected before it can + * fire, if any. Absent for providers that carry their own credentials — a + * schedule needs nothing, and a webhook trigger carries its own URL. + * + * Deliberately not derived from `optionGroup`: the two disagree (Teams is + * `microsoftTeams` there and `microsoft_teams` here) and they answer + * different questions — one names a list to fetch, this names a connection + * to check. + */ + connectionProvider?: string; /** * The Add Trigger subtree. A single leaf for providers with one trigger * (Scheduled, Webhook); nested for those with many (GitHub). @@ -34,6 +50,13 @@ export type TriggerProvider< * Two kinds can share one group — Calendar and Gmail both read `google`. */ optionGroup?: string; + /** + * Facts about the outside world that stop a valid-looking trigger from + * firing — a Slack channel the bot is not in. Not problems: the config is + * fine and saves, so these render as standing warnings rather than blocking + * anything, and they show without waiting for a save attempt. + */ + runtimeWarnings?: (config: Config, options: ProviderOptions) => string[]; }; /** @@ -44,8 +67,8 @@ export type TriggerProvider< export type TriggerMenuEntry< Config extends TriggerConfigInput = TriggerConfigInput, > = - | { label: string; create: () => Config } - | { label: string; children: TriggerMenuEntry[] }; + | { label: string | MessageDescriptor; create: () => Config } + | { label: string | MessageDescriptor; children: TriggerMenuEntry[] }; /** * What a sentence renderer is handed. It never touches row state directly: @@ -63,11 +86,29 @@ export type SentenceContext = { set: (patch: Record) => void; mark: (field: string) => string | undefined; options: ProviderOptions; + /** + * How THIS provider's option fetch is going — already resolved from its + * `optionGroup`, so a sentence never names the group again. An empty list + * means nothing on its own; chips read this to say "loading" or "couldn't + * reach the provider" instead of showing a false "nothing to choose". + * + * Resolved here rather than handed over as the whole map because the map + * made every chip repeat the lookup, and a provider that got the key wrong + * — or never passed one — lost loading, errors and Refresh silently. + */ + state?: OptionGroupState; disabled?: boolean; /** Trailing text for a schedule row ("Next run …"); other providers ignore it. */ nextRun?: ReactNode; }; +/** How one option group's fetch is going, from `useProviderOptions`. */ +export type OptionGroupState = { + isLoading: boolean; + isError: boolean; + refetch: () => void; +}; + /** * Pickable values, namespaced by provider: `options.github.repositories`, * `options.slack.channels`. Each provider declares its own keys and never diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderConnections.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderConnections.ts new file mode 100644 index 0000000000..9f06564705 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderConnections.ts @@ -0,0 +1,41 @@ +import { useMemo } from "react"; +import { cloudTrpc } from "renderer/lib/cloud-trpc"; + +/** How often to re-ask while someone is looking at the editor. */ +const POLL_MS = 10_000; + +/** + * Which integrations are connected, so a row can say it will never fire. + * + * Polled rather than fetched once, because connecting happens *outside this + * window*: the Connect button opens the web app, the person authorizes there, + * and comes back. Without a refetch the row would still claim the integration + * is missing until the page was reopened. + * + * Cheap by construction — one procedure answers every provider, and both + * refetch paths are idle when the app is not in front of someone. + * `refetchIntervalInBackground` stays false, so a window left open overnight + * asks for nothing. + */ +export function useProviderConnections(organizationId: string): { + connected: Record; + isPending: boolean; +} { + const query = cloudTrpc.integration.connectionStatus.useQuery( + { organizationId }, + { + enabled: Boolean(organizationId), + refetchInterval: POLL_MS, + refetchIntervalInBackground: false, + refetchOnWindowFocus: true, + // The answer is only interesting when it changes, and it changes + // elsewhere — so never serve a cached one to a returning window. + staleTime: 0, + }, + ); + + return useMemo( + () => ({ connected: query.data ?? {}, isPending: query.isPending }), + [query.data, query.isPending], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderOptions.ts index caf9c32737..1583879def 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderOptions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/useProviderOptions.ts @@ -3,7 +3,7 @@ import { useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; import { providerFor } from "./index"; -import type { ProviderOptions } from "./types"; +import type { OptionGroupState, ProviderOptions } from "./types"; const STALE_MS = 5 * 60_000; @@ -11,11 +11,16 @@ const STALE_MS = 5 * 60_000; * The pickable values for the providers that are actually on screen, one * round trip per option group. A page holding a single schedule trigger asks * for nothing; adding a Slack row asks for Slack's lists and nothing else. + * + * `state` travels beside the lists rather than inside them because the lists + * are indistinguishable on their own: an empty array is what loading, a + * revoked token, and a genuinely empty workspace all look like, and the chips + * need to say which one is happening. */ export function useProviderOptions( organizationId: string, drafts: DraftTrigger[], -): ProviderOptions { +): { options: ProviderOptions; state: Record } { const groups = useMemo(() => { const seen = new Set(); for (const draft of drafts) { @@ -38,9 +43,18 @@ export function useProviderOptions( return useMemo(() => { const options: ProviderOptions = {}; + const state: Record = {}; groups.forEach((group, index) => { - options[group] = results[index]?.data ?? {}; + const result = results[index]; + options[group] = result?.data ?? {}; + state[group] = { + // isFetching, not isLoading: a Refresh with data already on screen + // must still read as in-flight, or the button gives no feedback. + isLoading: result?.isFetching ?? false, + isError: result?.isError ?? false, + refetch: () => void result?.refetch(), + }; }); - return options; + return { options, state }; }, [groups, results]); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/webhook/WebhookSentence/WebhookSentence.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/webhook/WebhookSentence/WebhookSentence.tsx index 6ee800b95f..daa7c4b18b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/webhook/WebhookSentence/WebhookSentence.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/webhook/WebhookSentence/WebhookSentence.tsx @@ -1,4 +1,3 @@ -import { Trans, useLingui } from "@lingui/react/macro"; import { errorMessage } from "@superset/i18n/errors"; import { alert } from "@superset/ui/atoms/Alert"; import { toast } from "@superset/ui/sonner"; @@ -21,7 +20,6 @@ interface WebhookSentenceProps { /** "Webhook triggered" + inbound URL + auth header button. The token is shown once. */ export function WebhookSentence({ triggerId, disabled }: WebhookSentenceProps) { - const { t } = useLingui(); const { automationId } = useParams({ strict: false }); const url = automationId ? `${env.NEXT_PUBLIC_API_URL}/api/automations/webhook/${automationId}` @@ -56,6 +54,15 @@ export function WebhookSentence({ triggerId, disabled }: WebhookSentenceProps) { () => toast.error("Copy failed"), ); + // Most webhook settings pages accept nothing but a URL, so the URL that + // carries the token is the copy that makes those work; the header stays for + // producers that can send one, since URLs end up in intermediary logs. + const copyTokenUrl = () => + navigator.clipboard.writeText(`${url}?token=${token}`).then( + () => toast.success("Webhook URL copied"), + () => toast.error("Copy failed"), + ); + const generate = () => { if (!triggerId) return; if (!secretPrefix) { @@ -76,27 +83,16 @@ export function WebhookSentence({ triggerId, disabled }: WebhookSentenceProps) { }); }; - const headerLabel = token - ? t({ - id: "dashboard.automations.webhookSentence.copyAuthHeader", - message: "Copy auth header", - }) + const keyLabel = token + ? "Copy URL with token" : secretPrefix - ? t({ - id: "dashboard.automations.webhookSentence.regenerateAuthHeader", - message: "Regenerate auth header", - }) - : t({ - id: "dashboard.automations.webhookSentence.generateAuthHeader", - message: "Generate auth header", - }); + ? "Regenerate token" + : "Generate token"; return ( <> - - Webhook triggered - + Webhook triggered @@ -105,42 +101,44 @@ export function WebhookSentence({ triggerId, disabled }: WebhookSentenceProps) { - {!triggerId ? ( - - Save triggers first - - ) : token ? ( - - Copies the Authorization header. It is only shown now. - - ) : secretPrefix ? ( - - Token {secretPrefix}… is set. Regenerating replaces it. - - ) : ( - - Issues a bearer token for this trigger. - - )} + {!triggerId + ? "Save triggers first" + : token + ? "The URL alone triggers the run — paste it where only a URL fits. Shown once." + : secretPrefix + ? `Token ${secretPrefix}… is set. Regenerating replaces it.` + : "Issues the token this URL authenticates with."} + {token && ( + + + + + + The same token as an Authorization header, for senders that can set + one — headers stay out of URL logs. + + + )} ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/webhook/webhook.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/webhook/webhook.tsx index ef2d8a83e1..621ab19d0a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/webhook/webhook.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/providers/webhook/webhook.tsx @@ -1,3 +1,4 @@ +import { msg } from "@lingui/core/macro"; import type { TriggerConfigInput } from "@superset/shared/automation-triggers"; import { LuWebhook } from "react-icons/lu"; import type { TriggerProvider } from "../types"; @@ -7,9 +8,20 @@ type WebhookConfig = Extract; export const webhookProvider: TriggerProvider = { kind: "webhook", - label: "Webhook triggered", + label: msg({ + id: "dashboard.automations.providers.webhook.label", + message: "Webhook triggered", + }), icon: LuWebhook, - menu: [{ label: "Webhook triggered", create: () => ({ kind: "webhook" }) }], + menu: [ + { + label: msg({ + id: "dashboard.automations.providers.webhook.menuWebhookTriggered", + message: "Webhook triggered", + }), + create: () => ({ kind: "webhook" }), + }, + ], renderSentence: (_config, { triggerId, disabled }) => ( ), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useEnableRelayAccess/useEnableRelayAccess.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useEnableRelayAccess/useEnableRelayAccess.ts index 8d75985e4c..61de3e3923 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useEnableRelayAccess/useEnableRelayAccess.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useEnableRelayAccess/useEnableRelayAccess.ts @@ -1,7 +1,9 @@ +import { msg } from "@lingui/core/macro"; +import { i18n } from "@superset/i18n"; import { toast } from "@superset/ui/sonner"; import { electronTrpc } from "renderer/lib/electron-trpc"; -/** Enable relay access with the same mutation + feedback as Settings > Remote Workspaces. */ +/** Enable relay access with the same mutation + feedback as Settings > Remote Access. */ export function useEnableRelayAccess() { const utils = electronTrpc.useUtils(); const setExpose = @@ -13,9 +15,26 @@ export function useEnableRelayAccess() { const enableRelay = () => { toast.promise(setExpose.mutateAsync({ enabled: true }), { - loading: "Restarting host services…", - success: "Relay access enabled, connecting to the relay…", - error: (err: Error) => err.message ?? "Failed to enable relay access", + loading: i18n._( + msg({ + id: "dashboard.automations.enableRelay.loadingToast", + message: "Restarting host services…", + }), + ), + success: i18n._( + msg({ + id: "dashboard.automations.enableRelay.successToast", + message: "Relay access enabled, connecting to the relay…", + }), + ), + error: (err: Error) => + err.message ?? + i18n._( + msg({ + id: "dashboard.automations.enableRelay.failedToast", + message: "Failed to enable relay access", + }), + ), }); }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/page.tsx index 8dab65a6cb..701938af9c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/page.tsx @@ -1,4 +1,6 @@ +import { plural } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; +import { i18n } from "@superset/i18n"; import { errorMessage } from "@superset/i18n/errors"; import { COMPANY } from "@superset/shared/constants"; import { describeSchedule } from "@superset/shared/rrule"; @@ -141,7 +143,13 @@ function AutomationsPage() { }) => apiTrpcClient.automation.runNow.mutate({ id }), onMutate: ({ id }) => addRetrying([id]), onSettled: (_data, _error, { id }) => removeRetrying([id]), - onSuccess: (_, { name }) => toast.success(`Running "${name}" now`), + onSuccess: (_, { name }) => + toast.success( + t({ + id: "dashboard.automations.page.runNowToast", + message: `Running "${name}" now`, + }), + ), onError: (error, { targetHostId }) => { const message = error instanceof Error ? error.message : null; if (isHostOfflineError(message)) { @@ -149,10 +157,16 @@ function AutomationsPage() { return; } if (isStaleAgentError(message)) { - toast.error(STALE_AGENT_HELP); + toast.error(i18n._(STALE_AGENT_HELP)); return; } - toast.error(message ?? "Failed to trigger run"); + toast.error( + message ?? + t({ + id: "dashboard.automations.page.runNowFailedToast", + message: "Failed to trigger run", + }), + ); }, }); @@ -176,9 +190,13 @@ function AutomationsPage() { const retried = outcomes.length - failed.length; if (retried > 0) { toast.success( - retried === 1 - ? "Retrying 1 automation" - : `Retrying ${retried} automations`, + t({ + id: "dashboard.automations.page.retryingToast", + message: plural(retried, { + one: "Retrying # automation", + other: "Retrying # automations", + }), + }), ); } if (failed.length === 0) return; @@ -194,12 +212,21 @@ function AutomationsPage() { ); if (other.length === 0) return; const message = settledErrorMessage(other[0].result); + const failedCount = other.length; + const totalCount = outcomes.length; toast.error( other.length === 1 ? isStaleAgentError(message) - ? STALE_AGENT_HELP - : (message ?? "Failed to retry automation") - : `Failed to retry ${other.length} of ${outcomes.length} automations`, + ? i18n._(STALE_AGENT_HELP) + : (message ?? + t({ + id: "dashboard.automations.page.retryFailedToast", + message: "Failed to retry automation", + })) + : t({ + id: "dashboard.automations.page.retryFailedCountToast", + message: `Failed to retry ${failedCount} of ${totalCount} automations`, + }), ); }, }); @@ -218,10 +245,28 @@ function AutomationsPage() { onSuccess: (_, { id, enabled, name }) => { void utils.automation.list.invalidate(); void utils.automation.get.invalidate({ id }); - toast.success(enabled ? `"${name}" resumed` : `"${name}" paused`); + toast.success( + enabled + ? t({ + id: "dashboard.automations.page.resumedToast", + message: `"${name}" resumed`, + }) + : t({ + id: "dashboard.automations.page.pausedToast", + message: `"${name}" paused`, + }), + ); }, onError: (error) => - toast.error(errorMessage(error, "Failed to update automation")), + toast.error( + errorMessage( + error, + t({ + id: "dashboard.automations.page.updateFailedToast", + message: "Failed to update automation", + }), + ), + ), }); const deleteMutation = useMutation({ @@ -230,10 +275,23 @@ function AutomationsPage() { onSuccess: (_, { name }) => { void utils.automation.list.invalidate(); setPendingDelete(null); - toast.success(`"${name}" deleted`); + toast.success( + t({ + id: "dashboard.automations.page.deletedToast", + message: `"${name}" deleted`, + }), + ); }, onError: (error) => - toast.error(errorMessage(error, "Failed to delete automation")), + toast.error( + errorMessage( + error, + t({ + id: "dashboard.automations.page.deleteFailedToast", + message: "Failed to delete automation", + }), + ), + ), }); const { @@ -429,7 +487,12 @@ function AutomationsPage() { agentChoices[0]; if (!choice) throw new Error("No agent available yet"); return apiTrpcClient.automation.create.mutate({ - name: template?.name ?? "Untitled", + name: template + ? i18n._(template.name) + : t({ + id: "dashboard.automations.page.untitledName", + message: "Untitled", + }), prompt: template?.prompt ?? "", // Preset slug when unambiguous — instance UUIDs die when the host's // agent-config table is re-seeded, orphaning the automation. @@ -452,7 +515,13 @@ function AutomationsPage() { // Raw Postgres errors are multi-line SQL dumps — keep the first line. const message = error instanceof Error ? error.message.split("\n")[0]?.trim() : null; - toast.error(message || "Failed to create automation"); + toast.error( + message || + t({ + id: "dashboard.automations.page.createFailedToast", + message: "Failed to create automation", + }), + ); }, }); @@ -468,7 +537,12 @@ function AutomationsPage() { const handleCreateWithAgent = () => { if (creatingWithAgent) return; if (!machineId) { - toast.error("Host service is not running"); + toast.error( + t({ + id: "dashboard.automations.page.hostServiceNotRunningToast", + message: "Host service is not running", + }), + ); return; } const terminalAgents = agentChoices.filter((a) => a.id !== "superset"); @@ -476,7 +550,12 @@ function AutomationsPage() { const agent = terminalAgents.find((a) => a.id === stored)?.id ?? terminalAgents[0]?.id; if (!agent) { - toast.error("No terminal agent is configured on this device"); + toast.error( + t({ + id: "dashboard.automations.page.noTerminalAgentToast", + message: "No terminal agent is configured on this device", + }), + ); return; } setCreatingWithAgent(true); @@ -585,7 +664,10 @@ function AutomationsPage() { href={`${COMPANY.DOCS_URL}/automations`} target="_blank" rel="noreferrer" - aria-label="Automations docs" + aria-label={t({ + id: "dashboard.automations.page.docsAriaLabel", + message: "Automations docs", + })} >
@@ -725,8 +807,14 @@ function AutomationsPage() { setSearch(e.target.value)} - placeholder="Search" - aria-label="Search automations" + placeholder={t({ + id: "dashboard.automations.page.searchPlaceholder", + message: "Search", + })} + aria-label={t({ + id: "dashboard.automations.page.searchAriaLabel", + message: "Search automations", + })} className="h-8 w-44 pl-8" />
@@ -822,7 +910,10 @@ function AutomationsPage() { setCliHintDismissed(true)} - aria-label="Dismiss" + aria-label={t({ + id: "dashboard.automations.page.dismissAriaLabel", + message: "Dismiss", + })} className="absolute right-1.5 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground" > diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/templates/data.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/templates/data.ts index e19c15a103..f6cc75bb3d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/templates/data.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/templates/data.ts @@ -1,3 +1,6 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; + /** * A template is a partial automation + presentation metadata. Selecting a * template creates the automation with its name/prompt/agent/rrule and opens @@ -7,9 +10,9 @@ export interface AutomationTemplate { id: string; // --- presentation --- emoji: string; - description: string; + description: MessageDescriptor; // --- automation defaults --- - name: string; + name: MessageDescriptor; prompt: string; agentType?: string; rrule?: string; @@ -30,9 +33,15 @@ export const ONBOARDING_SUGGESTIONS: AutomationTemplate[] = [ { id: "onboard-fix-ci", emoji: "🔧", - description: - "Each morning at 8am, look at yesterday's failed CI runs, diagnose the most common failure, and open a fix PR.", - name: "Fix CI failures", + description: msg({ + id: "dashboard.automations.templates.fixCi.description", + message: + "Each morning at 8am, look at yesterday's failed CI runs, diagnose the most common failure, and open a fix PR.", + }), + name: msg({ + id: "dashboard.automations.templates.fixCi.name", + message: "Fix CI failures", + }), prompt: "Look at yesterday's failed CI runs on the default branch. Diagnose the most common failure, fix the root cause, run the affected checks locally, and open a PR with the fix. If nothing failed, say so and stop.", rrule: DAILY_8AM, @@ -40,9 +49,15 @@ export const ONBOARDING_SUGGESTIONS: AutomationTemplate[] = [ { id: "onboard-triage-issues", emoji: "🏷️", - description: - "Every weekday at 9am, read new GitHub issues, apply labels, and draft a first reply for my review.", - name: "Triage new issues", + description: msg({ + id: "dashboard.automations.templates.triageIssues.description", + message: + "Every weekday at 9am, read new GitHub issues, apply labels, and draft a first reply for my review.", + }), + name: msg({ + id: "dashboard.automations.templates.triageIssues.name", + message: "Triage new issues", + }), prompt: "Read GitHub issues opened since the last run. For each: apply appropriate labels, check for duplicates, and draft (do not post) a first reply for my review. Summarize what needs my attention most.", rrule: WEEKDAYS_9AM, @@ -50,9 +65,15 @@ export const ONBOARDING_SUGGESTIONS: AutomationTemplate[] = [ { id: "onboard-docs-fresh", emoji: "📚", - description: - "Every Wednesday at 9am, review this week's merged PRs and update any docs they made stale.", - name: "Keep docs fresh", + description: msg({ + id: "dashboard.automations.templates.docsFresh.description", + message: + "Every Wednesday at 9am, review this week's merged PRs and update any docs they made stale.", + }), + name: msg({ + id: "dashboard.automations.templates.docsFresh.name", + message: "Keep docs fresh", + }), prompt: "Review this week's merged PRs. Find documentation that they made stale or incomplete, update it, and open a PR. Skip trivial changes; focus on user-facing behavior.", rrule: WEEKLY_WEDNESDAY_9AM, @@ -60,9 +81,15 @@ export const ONBOARDING_SUGGESTIONS: AutomationTemplate[] = [ { id: "onboard-release-notes", emoji: "🗒️", - description: - "Every Friday at 4pm, draft release notes from this week's merged PRs.", - name: "Weekly release notes", + description: msg({ + id: "dashboard.automations.templates.releaseNotes.description", + message: + "Every Friday at 4pm, draft release notes from this week's merged PRs.", + }), + name: msg({ + id: "dashboard.automations.templates.releaseNotes.name", + message: "Weekly release notes", + }), prompt: "Draft release notes from this week's merged PRs. Group by feature/fix/internal, write one plain-language line per item, and link each PR. Leave the draft in the workspace for my review.", rrule: WEEKLY_FRIDAY_4PM, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/utils/hostOfflineError/hostOfflineError.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/utils/hostOfflineError/hostOfflineError.ts index ff78b1b180..d61e2787d7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/utils/hostOfflineError/hostOfflineError.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/utils/hostOfflineError/hostOfflineError.ts @@ -1,10 +1,15 @@ +import { msg } from "@lingui/core/macro"; + // Both strings come from dispatchAutomation's skipped_offline path: "target // host offline" for a pinned host, "no host available" for auto-routed // automations when no host of the owner's is relay-connected. const HOST_OFFLINE_ERRORS = ["target host offline", "no host available"]; -export const HOST_OFFLINE_HELP = - "The host isn't connected to the Superset relay. If it's this device, turn on \"Allow remote workspaces to access this device via relay\" in Settings > Remote Workspaces, then try again."; +export const HOST_OFFLINE_HELP = msg({ + id: "dashboard.automations.hostOfflineHelp", + message: + "The host isn't connected to the Superset relay. If it's this device, turn on \"Allow remote access to this device via relay\" in Settings > Remote Access, then try again.", +}); export function isHostOfflineError(error: string | null | undefined): boolean { return !!error && HOST_OFFLINE_ERRORS.some((e) => error.includes(e)); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/utils/staleAgentError/staleAgentError.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/utils/staleAgentError/staleAgentError.ts index ebafad2779..7890adcf5d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/utils/staleAgentError/staleAgentError.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/utils/staleAgentError/staleAgentError.ts @@ -1,9 +1,14 @@ +import { msg } from "@lingui/core/macro"; + // Stable marker in host-service's runTerminalAgent NOT_FOUND — present in // both the pre-1.17 terse message and the current user-worded one. const STALE_AGENT_ERROR_MARKER = "No host agent config matching"; -export const STALE_AGENT_HELP = - "The agent this automation was set to run no longer exists on its host — the host's agents may have been reset or removed. Re-select an agent in the automation's settings."; +export const STALE_AGENT_HELP = msg({ + id: "dashboard.automations.staleAgentHelp", + message: + "The agent this automation was set to run no longer exists on its host — the host's agents may have been reset or removed. Re-select an agent in the automation's settings.", +}); export function isStaleAgentError(error: string | null | undefined): boolean { return !!error && error.includes(STALE_AGENT_ERROR_MARKER); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx index df0df41aa2..0fc4f8ac5e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { toast } from "@superset/ui/sonner"; import { EmptyProjectModal } from "renderer/routes/_authenticated/components/EmptyProjectModal"; import { TemplateGalleryModal } from "renderer/routes/_authenticated/components/TemplateGalleryModal"; @@ -9,6 +10,7 @@ import { import { NewProjectModal } from "./components/NewProjectModal"; export function AddRepositoryModals() { + const { t } = useLingui(); const active = useAddRepositoryModalActive(); const close = useCloseAddRepositoryModal(); const resolveNewProject = useResolveNewProjectModal(); @@ -21,10 +23,22 @@ export function AddRepositoryModals() { if (!open) close(); }} onSuccess={(result) => { - toast.success("Project created."); + toast.success( + t({ + id: "dashboard.addRepositoryModals.emptyProjectCreated", + message: "Project created.", + }), + ); resolveNewProject({ projectId: result.projectId }); }} - onError={(message) => toast.error(`Create failed: ${message}`)} + onError={(message) => + toast.error( + t({ + id: "dashboard.addRepositoryModals.emptyProjectCreateFailed", + message: `Create failed: ${message}`, + }), + ) + } /> { - toast.success("Project created."); + toast.success( + t({ + id: "dashboard.addRepositoryModals.newProjectCreated", + message: "Project created.", + }), + ); resolveNewProject({ projectId: result.projectId }); }} - onError={(message) => toast.error(`Create failed: ${message}`)} + onError={(message) => + toast.error( + t({ + id: "dashboard.addRepositoryModals.newProjectCreateFailed", + message: `Create failed: ${message}`, + }), + ) + } /> { - toast.success("Project created."); + toast.success( + t({ + id: "dashboard.addRepositoryModals.templateProjectCreated", + message: "Project created.", + }), + ); resolveNewProject({ projectId: result.projectId }); }} - onError={(message) => toast.error(`Create failed: ${message}`)} + onError={(message) => + toast.error( + t({ + id: "dashboard.addRepositoryModals.templateProjectCreateFailed", + message: `Create failed: ${message}`, + }), + ) + } /> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/NewProjectModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/NewProjectModal.tsx index 2ceb13277b..51408effff 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/NewProjectModal.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/NewProjectModal.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { errorMessage, rawErrorMessage } from "@superset/i18n/errors"; import { Button } from "@superset/ui/button"; import { @@ -47,6 +47,7 @@ export function NewProjectModal({ onSuccess, onError, }: NewProjectModalProps) { + const { t } = useLingui(); const isV2CloudEnabled = useIsV2CloudEnabled(); const hostService = useLocalHostService(); const { activeHostUrl } = hostService; @@ -87,7 +88,10 @@ export function NewProjectModal({ const handleBrowse = async () => { try { const result = await selectDirectory.mutateAsync({ - title: "Select project location", + title: t({ + id: "dashboard.newProjectModal.selectLocationDialogTitle", + message: "Select project location", + }), defaultPath: parentDir || undefined, }); if (!result.canceled && result.path) { @@ -102,11 +106,21 @@ export function NewProjectModal({ const trimmedUrl = url.trim(); const trimmedParent = parentDir.trim(); if (!trimmedUrl) { - toast.error("Please enter a repository URL"); + toast.error( + t({ + id: "dashboard.newProjectModal.enterRepoUrl", + message: "Please enter a repository URL", + }), + ); return; } if (!trimmedParent) { - toast.error("Please select a project location"); + toast.error( + t({ + id: "dashboard.newProjectModal.selectProjectLocation", + message: "Please select a project location", + }), + ); return; } @@ -125,13 +139,18 @@ export function NewProjectModal({ } if (!activeHostUrl) { showHostServiceUnavailableToast(hostService, { - action: "clone the repository", + action: "cloneRepository", }); return; } const trimmedName = name.trim() || deriveProjectNameFromUrl(trimmedUrl); if (!trimmedName) { - toast.error("Please enter a project name"); + toast.error( + t({ + id: "dashboard.newProjectModal.enterProjectName", + message: "Please enter a project name", + }), + ); return; } const client = getHostServiceClientByUrl(activeHostUrl); @@ -151,9 +170,19 @@ export function NewProjectModal({ const isLeakedSql = raw.startsWith("Failed query:"); if (isLeakedSql) console.error("[NewProjectModal] create failed", err); const message = isLeakedSql - ? "Could not create project. Please try a different name or check the logs." + ? t({ + id: "dashboard.newProjectModal.createFailedGeneric", + message: + "Could not create project. Please try a different name or check the logs.", + }) : errorMessage(err); - toast.error("Could not create project", { description: message }); + toast.error( + t({ + id: "dashboard.newProjectModal.createFailedTitle", + message: "Could not create project", + }), + { description: message }, + ); onError?.(message); } finally { setWorking(false); @@ -187,7 +216,10 @@ export function NewProjectModal({ id="clone-url" value={url} onChange={(e) => setUrl(e.target.value)} - placeholder="https://github.com/owner/repo.git or /path/to/repo" + placeholder={t({ + id: "dashboard.newProjectModal.repoUrlPlaceholder", + message: "https://github.com/owner/repo.git or /path/to/repo", + })} disabled={working} onKeyDown={(e) => { if (e.key === "Enter" && !working) { @@ -239,7 +271,10 @@ export function NewProjectModal({ onClick={handleBrowse} disabled={working || selectDirectory.isPending} className="shrink-0" - aria-label="Browse for directory" + aria-label={t({ + id: "dashboard.newProjectModal.browseForDirectory", + message: "Browse for directory", + })} > diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts index 2fca49fa5d..0c8dfbe6d9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { useCallback } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; @@ -23,6 +24,7 @@ export function useFolderFirstImport(options?: { onError?: (message: string) => void; onMultipleProjects?: (input: { candidates: MatchingProject[] }) => void; }): UseFolderFirstImportResult { + const { t } = useLingui(); const hostService = useLocalHostService(); const { waitForHostReady } = hostService; const finalizeSetup = useFinalizeProjectSetup(); @@ -36,7 +38,10 @@ export function useFolderFirstImport(options?: { let repoPath: string; try { const picked = await selectDirectory.mutateAsync({ - title: "Import existing folder", + title: t({ + id: "dashboard.folderFirstImport.dialogTitle", + message: "Import existing folder", + }), }); if (picked.canceled || !picked.path) return null; repoPath = picked.path; @@ -49,7 +54,7 @@ export function useFolderFirstImport(options?: { if (!activeHostUrl) { onError?.( getHostServiceUnavailableMessage(hostService, { - action: "import a folder", + action: "importFolder", }), ); return null; @@ -76,7 +81,12 @@ export function useFolderFirstImport(options?: { candidates = response.candidates; if (candidates.length === 0 && response.cloudErrors.length > 0) { const first = response.cloudErrors[0]; - onError?.(`Couldn't reach cloud for ${first.url}: ${first.message}`); + onError?.( + t({ + id: "dashboard.folderFirstImport.cloudUnreachable", + message: `Couldn't reach cloud for ${first.url}: ${first.message}`, + }), + ); return null; } } catch (err) { @@ -90,7 +100,10 @@ export function useFolderFirstImport(options?: { onMultipleProjects({ candidates }); } else { onError?.( - `Multiple projects use this repository (${candidates.length}). Open the project you want from settings to set it up on this device.`, + t({ + id: "dashboard.folderFirstImport.multipleProjects", + message: `Multiple projects use this repository (${candidates.length}). Open the project you want from settings to set it up on this device.`, + }), ); } return null; @@ -128,6 +141,7 @@ export function useFolderFirstImport(options?: { onMultipleProjects, requestGitInit, selectDirectory, + t, ]); return { start }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardContentError/DashboardContentError.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardContentError/DashboardContentError.tsx index 6822589f47..99d187e536 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardContentError/DashboardContentError.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardContentError/DashboardContentError.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { Button } from "@superset/ui/button"; import type { ErrorComponentProps } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router"; @@ -14,8 +14,17 @@ import { HiExclamationTriangle } from "react-icons/hi2"; * Go-home link both recover it. */ export function DashboardContentError({ error }: ErrorComponentProps) { + const { t } = useLingui(); const message = - error instanceof Error ? error.message : String(error ?? "Unknown error"); + error instanceof Error + ? error.message + : String( + error ?? + t({ + id: "dashboard.contentError.unknownError", + message: "Unknown error", + }), + ); useEffect(() => { console.error("[dashboard] Content route error caught:", error); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx index edd155d5ec..35cb944ae8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx @@ -4,6 +4,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { useLingui } from "@lingui/react/macro"; import { OverflowFadeContainer } from "@superset/ui/overflow-fade-container"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; @@ -35,6 +36,7 @@ import { SectionDragSpacer } from "./components/SectionDragSpacer"; import { useV2SetupScriptCard } from "./components/V2SetupScriptCard"; import { useDashboardSidebarData } from "./hooks/useDashboardSidebarData"; import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcuts"; +import { useMigrateLegacySidebarFolders } from "./hooks/useMigrateLegacySidebarFolders"; import { useDashboardSidebarDnd } from "./hooks/useSidebarDnd"; import { DashboardSidebarDndProvider } from "./providers/DashboardSidebarDndProvider"; import { DashboardSidebarHoverProvider } from "./providers/DashboardSidebarHoverProvider"; @@ -124,14 +126,20 @@ const SortableProjectWrapper = memo(function SortableProjectWrapper({ export function DashboardSidebar({ isCollapsed = false, }: DashboardSidebarProps) { + const { t } = useLingui(); const { groups, pinnedWorkspaces, sessionWorkspaces, + sessionTagGroups, + ungroupedSessionWorkspaces, refreshWorkspacePullRequest, toggleProjectCollapsed, } = useDashboardSidebarData(); const { reorderProjects } = useDashboardSidebarState(); + // Converts legacy uuid-keyed folders to tag-backed folders in the + // background; retries whenever the workspace cache changes. + useMigrateLegacySidebarFolders(); const navigate = useNavigate(); const matchRoute = useMatchRoute(); const settingsHotkey = useHotkeyDisplay("OPEN_SETTINGS").text; @@ -259,6 +267,7 @@ export function DashboardSidebar({ return ( @@ -297,6 +306,8 @@ export function DashboardSidebar({ />
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarBulkActions/DashboardSidebarBulkActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarBulkActions/DashboardSidebarBulkActions.tsx index 62023bf471..92843047b9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarBulkActions/DashboardSidebarBulkActions.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarBulkActions/DashboardSidebarBulkActions.tsx @@ -1,4 +1,5 @@ -import { Plural, Trans } from "@lingui/react/macro"; +import { plural } from "@lingui/core/macro"; +import { Plural, Trans, useLingui } from "@lingui/react/macro"; import { DropdownMenu, DropdownMenuContent, @@ -33,6 +34,7 @@ export function DashboardSidebarBulkActions({ projects, children, }: DashboardSidebarBulkActionsProps) { + const { t } = useLingui(); const { clearSelection, removeSelectedWorkspaces, selectedProjectId } = useDashboardSidebarSelection(); const selectedProject = useMemo( @@ -86,7 +88,10 @@ export function DashboardSidebarBulkActions({ ) : (
@@ -129,7 +137,13 @@ export function DashboardSidebarBulkActions({ @@ -188,7 +202,10 @@ export function DashboardSidebarBulkActions({ disabled={groupedWorkspaceIds.length === 0} onClick={ungroupSelection} className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground disabled:pointer-events-none disabled:opacity-35" - aria-label="Ungroup selected workspaces" + aria-label={t({ + id: "dashboard.sidebar.bulkActions.ungroupAriaLabel", + message: "Ungroup selected workspaces", + })} > @@ -204,7 +221,10 @@ export function DashboardSidebarBulkActions({ type="button" onClick={openDeleteDialog} className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" - aria-label="Delete selected workspaces" + aria-label={t({ + id: "dashboard.sidebar.bulkActions.deleteAriaLabel", + message: "Delete selected workspaces", + })} > diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarBulkDeleteDialog/hooks/useBulkWorkspaceDelete/useBulkWorkspaceDelete.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarBulkDeleteDialog/hooks/useBulkWorkspaceDelete/useBulkWorkspaceDelete.ts index 6f607a4625..8eaac2854a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarBulkDeleteDialog/hooks/useBulkWorkspaceDelete/useBulkWorkspaceDelete.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarBulkDeleteDialog/hooks/useBulkWorkspaceDelete/useBulkWorkspaceDelete.ts @@ -1,3 +1,6 @@ +import { plural } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react/macro"; +import { i18n } from "@superset/i18n"; import { toast } from "@superset/ui/sonner"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -36,10 +39,18 @@ export function bulkWorkspaceDestroyErrorMessage( ): string { const workspaceName = workspace.name || workspace.branch; if (error.kind === "teardown-failed") { - return `${workspaceName}: teardown failed`; + return i18n._({ + id: "dashboard.sidebar.bulkDelete.teardownFailed", + message: "{workspaceName}: teardown failed", + values: { workspaceName }, + }); } if (error.kind === "host-unavailable") { - return `${workspaceName}: host is unavailable`; + return i18n._({ + id: "dashboard.sidebar.bulkDelete.hostUnavailable", + message: "{workspaceName}: host is unavailable", + values: { workspaceName }, + }); } return `${workspaceName}: ${error.message}`; } @@ -50,6 +61,7 @@ export function useBulkWorkspaceDelete({ onOpenChange, onDeleted, }: UseBulkWorkspaceDeleteOptions) { + const { t } = useLingui(); const { cache: hostWorkspacesCache } = useHostWorkspaces(); const { navigateAwayFromWorkspace } = useNavigateAwayFromWorkspace(); const { removeWorkspaceFromSidebar } = useDashboardSidebarState(); @@ -196,12 +208,24 @@ export function useBulkWorkspaceDelete({ onDeleted(deletedIds); selectionReconciled = true; toast.success( - `Deleted ${deletedIds.length} ${deletedIds.length === 1 ? "workspace" : "workspaces"}`, + t({ + id: "dashboard.sidebar.bulkDelete.deletedToast", + message: plural(deletedIds.length, { + one: "Deleted # workspace", + other: "Deleted # workspaces", + }), + }), ); } if (nextFailures.length > 0) { toast.error( - `Couldn’t delete ${nextFailures.length} ${nextFailures.length === 1 ? "workspace" : "workspaces"}`, + t({ + id: "dashboard.sidebar.bulkDelete.deleteFailedToast", + message: plural(nextFailures.length, { + one: "Couldn’t delete # workspace", + other: "Couldn’t delete # workspaces", + }), + }), { description: nextFailures .map(({ workspace, error }) => @@ -231,13 +255,27 @@ export function useBulkWorkspaceDelete({ ); toast.error( deletedIds.length > 0 - ? "Deleted workspaces, but couldn’t finish updating the sidebar" - : "Couldn’t finish deleting workspaces", + ? t({ + id: "dashboard.sidebar.bulkDelete.partialFailureToast", + message: + "Deleted workspaces, but couldn’t finish updating the sidebar", + }) + : t({ + id: "dashboard.sidebar.bulkDelete.unexpectedFailureToast", + message: "Couldn’t finish deleting workspaces", + }), { description: deletedIds.length > 0 - ? "Reload Superset to refresh the workspace list." - : "Try again. If the problem continues, reload Superset.", + ? t({ + id: "dashboard.sidebar.bulkDelete.partialFailureDescription", + message: "Reload Superset to refresh the workspace list.", + }) + : t({ + id: "dashboard.sidebar.bulkDelete.unexpectedFailureDescription", + message: + "Try again. If the problem continues, reload Superset.", + }), }, ); } finally { @@ -256,6 +294,7 @@ export function useBulkWorkspaceDelete({ onOpenChange, preferences.deleteLocalBranch, removeWorkspaceFromSidebar, + t, targetFor, ], ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarCloudSection/DashboardSidebarCloudSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarCloudSection/DashboardSidebarCloudSection.tsx index dcffaa82b2..46e4047a25 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarCloudSection/DashboardSidebarCloudSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarCloudSection/DashboardSidebarCloudSection.tsx @@ -145,6 +145,7 @@ export function DashboardSidebarCloudSection({ ))} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/DashboardSidebarDeleteDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/DashboardSidebarDeleteDialog.tsx index f198465b49..2d250bdb3b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/DashboardSidebarDeleteDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/DashboardSidebarDeleteDialog.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { useHostWorkspaces } from "renderer/routes/_authenticated/providers/HostWorkspacesProvider"; import { DestroyConfirmPane } from "./components/DestroyConfirmPane"; import { TeardownFailedPane } from "./components/TeardownFailedPane"; @@ -25,6 +26,7 @@ export function DashboardSidebarDeleteDialog({ onOpenChange, onDeleted, }: DashboardSidebarDeleteDialogProps) { + const { t } = useLingui(); const { workspaces: hostWorkspaces } = useHostWorkspaces(); const isSession = hostWorkspaces.find((workspace) => workspace.id === workspaceId)?.type === @@ -59,7 +61,12 @@ export function DashboardSidebarDeleteDialog({ } const hasWarnings = hasChanges || hasUnpushedCommits; - const confirmLabel = hasWarnings ? "Delete anyway" : "Delete"; + const confirmLabel = hasWarnings + ? t({ + id: "dashboard.sidebar.deleteDialog.deleteAnyway", + message: "Delete anyway", + }) + : t({ id: "dashboard.sidebar.deleteDialog.delete", message: "Delete" }); return ( { + label: ReactNode; + isCollapsed: boolean; + onToggleCollapse: () => void; + actions?: ReactNode; + isEditing?: boolean; + isDraggable?: boolean; +} + +/** Shared visual and interaction shell for every nested sidebar group. */ +export const DashboardSidebarGroupHeader = forwardRef< + HTMLDivElement, + DashboardSidebarGroupHeaderProps +>( + ( + { + label, + isCollapsed, + onToggleCollapse, + actions, + isEditing = false, + isDraggable = false, + className, + ...props + }, + ref, + ) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: The header acts as a single toggle target while preserving nested inline controls in edit mode. +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onToggleCollapse(); + } + } + } + className={cn( + // Group containers have a 2px left accent border. Use a 6px left + // margin so their content aligns with borderless top-level rows. + "group ml-1.5 mr-2 flex min-h-7 items-center rounded-md py-1 pl-2 pr-2 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-fill-hover", + className, + )} + {...props} + > +
+ +
+
{label}
+ {!isEditing && ( +
+ {actions ? ( + // biome-ignore lint/a11y/noStaticElementInteractions: Nested action controls own their semantics; this wrapper isolates them from the header toggle. +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {actions} +
+ ) : null} +
+ )} +
+ ), +); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarGroupHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarGroupHeader/index.ts new file mode 100644 index 0000000000..a44848ce4b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarGroupHeader/index.ts @@ -0,0 +1 @@ +export { DashboardSidebarGroupHeader } from "./DashboardSidebarGroupHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx index c5a7b00d5c..2c6c01525f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { FEATURE_FLAGS } from "@superset/shared/constants"; import { DropdownMenu, @@ -65,6 +65,7 @@ interface DashboardSidebarHeaderProps { export function DashboardSidebarHeader({ isCollapsed = false, }: DashboardSidebarHeaderProps) { + const { t } = useLingui(); const openModal = useOpenNewWorkspaceModal(); const openEmptyProject = useOpenEmptyProjectModal(); const openNewProject = useOpenNewProjectModal(); @@ -72,23 +73,45 @@ export function DashboardSidebarHeader({ const navigate = useNavigate(); const folderImport = useFolderFirstImport({ onError: (message) => { - toast.error(`Import failed: ${message}`); + toast.error( + t({ + id: "dashboard.sidebar.header.importFailed", + message: `Import failed: ${message}`, + }), + ); }, onMultipleProjects: ({ candidates }) => { - toast.error("Import failed", { - description: `Multiple projects use this repository (${candidates.length}). Choose the project in settings to set it up on this device.`, - action: { - label: "Open Projects", - onClick: () => navigate({ to: "/settings/projects" }), + toast.error( + t({ + id: "dashboard.sidebar.header.importFailedTitle", + message: "Import failed", + }), + { + description: t({ + id: "dashboard.sidebar.header.importMultipleProjects", + message: `Multiple projects use this repository (${candidates.length}). Choose the project in settings to set it up on this device.`, + }), + action: { + label: t({ + id: "dashboard.sidebar.header.openProjects", + message: "Open Projects", + }), + onClick: () => navigate({ to: "/settings/projects" }), + }, }, - }); + ); }, }); const handleImportFolder = async () => { const result = await folderImport.start(); if (result) { - toast.success("Project ready — open it from the sidebar."); + toast.success( + t({ + id: "dashboard.sidebar.header.projectReady", + message: "Project ready — open it from the sidebar.", + }), + ); } }; @@ -260,8 +283,14 @@ export function DashboardSidebarHeader({ {searchShortcutText !== "Unassigned" - ? `Search (${searchShortcutText})` - : "Search"} + ? t({ + id: "dashboard.sidebar.header.searchWithShortcut", + message: `Search (${searchShortcutText})`, + }) + : t({ + id: "dashboard.sidebar.header.searchTooltip", + message: "Search", + })} @@ -294,8 +323,14 @@ export function DashboardSidebarHeader({ onClick={handleAutomationsClick} aria-label={ myFailedCount > 0 - ? `Automations, ${myFailedCount} failing` - : "Automations" + ? t({ + id: "dashboard.sidebar.header.automationsFailingAriaLabel", + message: `Automations, ${myFailedCount} failing`, + }) + : t({ + id: "dashboard.sidebar.header.automationsAriaLabel", + message: "Automations", + }) } className={cn( "relative flex size-7 items-center justify-center rounded-md transition-colors", @@ -331,7 +366,10 @@ export function DashboardSidebarHeader({
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx index fb2da7d9e4..ee98b9f119 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx @@ -81,6 +81,7 @@ export function DashboardSidebarProjectSection({ if (isSidebarCollapsed) { return ( )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectContextMenu/DashboardSidebarProjectContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectContextMenu/DashboardSidebarProjectContextMenu.tsx index 705e42db44..b64f0fd219 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectContextMenu/DashboardSidebarProjectContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectContextMenu/DashboardSidebarProjectContextMenu.tsx @@ -4,9 +4,13 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, ContextMenuTrigger, } from "@superset/ui/context-menu"; import { + LuEye, LuFolderInput, LuFolderOpen, LuFolderPlus, @@ -14,8 +18,10 @@ import { LuSettings, LuX, } from "react-icons/lu"; +import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; interface DashboardSidebarProjectContextMenuProps { + projectId: string; onCreateSection: () => void; onImportWorktrees: () => void; onOpenInFinder: () => void; @@ -26,6 +32,7 @@ interface DashboardSidebarProjectContextMenuProps { } export function DashboardSidebarProjectContextMenu({ + projectId, onCreateSection, onImportWorktrees, onOpenInFinder, @@ -34,6 +41,8 @@ export function DashboardSidebarProjectContextMenu({ onRename, children, }: DashboardSidebarProjectContextMenuProps) { + const { preferences, setTagFolderHidden } = useV2UserPreferences(); + const hiddenTags = preferences.hiddenTagFolders[projectId] ?? []; return ( {children} @@ -59,6 +68,26 @@ export function DashboardSidebarProjectContextMenu({ New group + {hiddenTags.length > 0 ? ( + + + + + Hidden folders + + + + {hiddenTags.map((tag) => ( + setTagFolderHidden(projectId, tag, false)} + > + {tag} + + ))} + + + ) : null} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx index fea62ce806..2ac22e3ce2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { type ComponentPropsWithoutRef, forwardRef } from "react"; @@ -45,6 +45,7 @@ export const DashboardSidebarProjectRow = forwardRef< }, ref, ) => { + const { t } = useLingui(); return ( // biome-ignore lint/a11y/noStaticElementInteractions: The header acts as a single toggle target in view mode while preserving nested inline controls.
event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()} - aria-label="New workspace" + aria-label={t({ + id: "dashboard.sidebar.projectRow.newWorkspaceAriaLabel", + message: "New workspace", + })} className="hidden size-full items-center justify-center rounded transition-colors hover:bg-fill-hover group-hover:flex group-has-[:focus]:flex focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts index 77b01d7cf9..6de795631f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts @@ -1,3 +1,5 @@ +import { plural } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react/macro"; import { errorMessage } from "@superset/i18n/errors"; import { alert } from "@superset/ui/atoms/Alert"; import { toast } from "@superset/ui/sonner"; @@ -22,6 +24,7 @@ interface UseDashboardSidebarProjectSectionActionsOptions { export function useDashboardSidebarProjectSectionActions({ project, }: UseDashboardSidebarProjectSectionActionsOptions) { + const { t } = useLingui(); const openModal = useOpenNewWorkspaceModal(); const navigate = useNavigate(); // Renames commit on a host serving the project — host.db owns the name. @@ -74,13 +77,23 @@ export function useDashboardSidebarProjectSectionActions({ const trimmed = renameValue.trim(); if (!trimmed || trimmed === project.name) return; if (!servingHostUrl) { - toast.error("Project's host is unreachable — cannot rename right now"); + toast.error( + t({ + id: "dashboard.sidebar.projectActions.renameHostUnreachable", + message: "Project's host is unreachable — cannot rename right now", + }), + ); return; } void getHostServiceClientByUrl(servingHostUrl) .project.update.mutate({ projectId: project.id, name: trimmed }) .catch((err) => { - toast.error(`Rename failed: ${errorMessage(err)}`); + toast.error( + t({ + id: "dashboard.sidebar.projectActions.renameFailed", + message: `Rename failed: ${errorMessage(err)}`, + }), + ); }); }; @@ -93,14 +106,22 @@ export function useDashboardSidebarProjectSectionActions({ ? hostProject.repoPath : undefined; if (!localRepoPath) { - toast.error("Project folder is not on this machine"); + toast.error( + t({ + id: "dashboard.sidebar.projectActions.folderNotOnMachine", + message: "Project folder is not on this machine", + }), + ); return; } try { await electronTrpcClient.external.openInFinder.mutate(localRepoPath); } catch (error) { toast.error( - `Failed to open in Finder: ${errorMessage(error, "Unknown error")}`, + t({ + id: "dashboard.sidebar.projectActions.openInFinderFailed", + message: `Failed to open in Finder: ${errorMessage(error, "Unknown error")}`, + }), ); } }; @@ -114,13 +135,29 @@ export function useDashboardSidebarProjectSectionActions({ const confirmRemoveFromSidebar = () => { alert({ - title: "Remove project from sidebar?", - description: - "This will remove workspaces from the sidebar and delete all project sections. The workspaces or projects won't be deleted.", + title: t({ + id: "dashboard.sidebar.projectActions.removeProjectTitle", + message: "Remove project from sidebar?", + }), + description: t({ + id: "dashboard.sidebar.projectActions.removeProjectDescription", + message: + "This will remove workspaces from the sidebar and delete all project sections. The workspaces or projects won't be deleted.", + }), actions: [ - { label: "Cancel", variant: "outline", onClick: () => {} }, { - label: "Remove", + label: t({ + id: "dashboard.sidebar.projectActions.removeProjectCancel", + message: "Cancel", + }), + variant: "outline", + onClick: () => {}, + }, + { + label: t({ + id: "dashboard.sidebar.projectActions.removeProjectConfirm", + message: "Remove", + }), variant: "destructive", onClick: () => removeProjectFromSidebar(project.id), }, @@ -138,7 +175,11 @@ export function useDashboardSidebarProjectSectionActions({ if (importingWorktreesRef.current) return; if (!servingHostUrl) { toast.error( - "Project's host is unreachable — cannot import worktrees right now", + t({ + id: "dashboard.sidebar.projectActions.importHostUnreachable", + message: + "Project's host is unreachable — cannot import worktrees right now", + }), ); return; } @@ -154,12 +195,22 @@ export function useDashboardSidebarProjectSectionActions({ worktree.hasWorkspace === false && worktree.isMainWorktree === false, ); if (untracked.length === 0) { - toast.info("All of this project's worktrees are already tracked"); + toast.info( + t({ + id: "dashboard.sidebar.projectActions.allWorktreesTracked", + message: "All of this project's worktrees are already tracked", + }), + ); return; } setImportableWorktrees(untracked); } catch (error) { - toast.error(`Failed to list worktrees: ${errorMessage(error)}`); + toast.error( + t({ + id: "dashboard.sidebar.projectActions.listWorktreesFailed", + message: `Failed to list worktrees: ${errorMessage(error)}`, + }), + ); } }; @@ -174,7 +225,11 @@ export function useDashboardSidebarProjectSectionActions({ if (importingWorktreesRef.current || !untracked) return; if (!servingHostId) { toast.error( - "Project's host is unreachable — cannot import worktrees right now", + t({ + id: "dashboard.sidebar.projectActions.confirmImportHostUnreachable", + message: + "Project's host is unreachable — cannot import worktrees right now", + }), ); return; } @@ -202,13 +257,20 @@ export function useDashboardSidebarProjectSectionActions({ const imported = outcomes.length - errors.length; if (errors.length > 0) { toast.error( - `Imported ${imported} of ${untracked.length} worktrees: ${errors[0]}`, + t({ + id: "dashboard.sidebar.projectActions.importPartialFailure", + message: `Imported ${imported} of ${untracked.length} worktrees: ${errors[0]}`, + }), ); } else { toast.success( - imported === 1 - ? "Imported 1 worktree as a workspace" - : `Imported ${imported} worktrees as workspaces`, + t({ + id: "dashboard.sidebar.projectActions.importedWorktrees", + message: plural(imported, { + one: "Imported # worktree as a workspace", + other: "Imported # worktrees as workspaces", + }), + }), ); } setImportableWorktrees(null); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/DashboardSidebarSectionContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/DashboardSidebarSectionContextMenu.tsx index ebff7132ef..096beeff64 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/DashboardSidebarSectionContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/DashboardSidebarSectionContextMenu.tsx @@ -16,6 +16,7 @@ export function DashboardSidebarSectionContextMenu({ onRename, onSetColor, onDelete, + onHide, children, }: DashboardSidebarSectionContextMenuProps) { return ( @@ -31,6 +32,7 @@ export function DashboardSidebarSectionContextMenu({ kind="context" onRename={onRename} onSetColor={onSetColor} + onHide={onHide} onDelete={onDelete} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/DashboardSidebarSectionActionsDropdown/DashboardSidebarSectionActionsDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/DashboardSidebarSectionActionsDropdown/DashboardSidebarSectionActionsDropdown.tsx index a982e8a420..e76dbedbcd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/DashboardSidebarSectionActionsDropdown/DashboardSidebarSectionActionsDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/DashboardSidebarSectionActionsDropdown/DashboardSidebarSectionActionsDropdown.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { DropdownMenu, DropdownMenuContent, @@ -12,7 +13,9 @@ export function DashboardSidebarSectionActionsDropdown({ onRename, onSetColor, onDelete, + onHide, }: DashboardSidebarSectionActionsProps) { + const { t } = useLingui(); return ( @@ -22,7 +25,10 @@ export function DashboardSidebarSectionActionsDropdown({ onClick={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()} className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground/80 opacity-0 transition-[opacity,color,background-color] hover:bg-fill-hover hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100" - aria-label="Group actions" + aria-label={t({ + id: "dashboard.sidebar.sectionMenu.groupActions", + message: "Group actions", + })} > @@ -39,6 +45,7 @@ export function DashboardSidebarSectionActionsDropdown({ kind="dropdown" onRename={onRename} onSetColor={onSetColor} + onHide={onHide} onDelete={onDelete} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/SectionActionsMenuItems/SectionActionsMenuItems.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/SectionActionsMenuItems/SectionActionsMenuItems.tsx index 0cd1c6d2d1..bd0b4c55c0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/SectionActionsMenuItems/SectionActionsMenuItems.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/SectionActionsMenuItems/SectionActionsMenuItems.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { ContextMenuItem, ContextMenuSeparator, @@ -14,7 +14,7 @@ import { DropdownMenuSubTrigger, } from "@superset/ui/dropdown-menu"; import { HiCheck } from "react-icons/hi2"; -import { LuPalette, LuPencil, LuTrash2 } from "react-icons/lu"; +import { LuEyeOff, LuPalette, LuPencil, LuTrash2 } from "react-icons/lu"; import { PROJECT_COLOR_DEFAULT, PROJECT_COLORS, @@ -35,11 +35,22 @@ export function SectionActionsMenuItems({ onRename, onSetColor, onDelete, + onHide, }: SectionActionsMenuItemsProps) { + const { t } = useLingui(); const selectedValue = color ?? PROJECT_COLOR_DEFAULT; - const colorOptions = [ - { name: "Default", value: PROJECT_COLOR_DEFAULT }, - ...PROJECT_COLORS, + const colorOptions: { name: string; value: string }[] = [ + { + name: t({ + id: "dashboard.sidebar.sectionMenu.defaultColor", + message: "Default", + }), + value: PROJECT_COLOR_DEFAULT, + }, + ...PROJECT_COLORS.map((projectColor) => ({ + name: projectColor.name(), + value: projectColor.value, + })), ]; const iconClassName = kind === "context" ? "size-4 mr-2" : "size-4"; @@ -91,7 +102,7 @@ export function SectionActionsMenuItems({ return renderItem({ key: projectColor.value, - onSelect: () => onSetColor(isDefault ? null : projectColor.value), + onSelect: () => onSetColor?.(isDefault ? null : projectColor.value), children: ( <> ), })} - {kind === "context" ? ( + {onSetColor && kind === "context" ? ( {colorTrigger} {colorItems} - ) : ( + ) : onSetColor ? ( {colorTrigger} {colorItems} - )} + ) : null} + {onHide + ? renderItem({ + onSelect: onHide, + children: ( + <> + + + Hide folder + + + ), + }) + : null} {kind === "context" ? ( ) : ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/types.ts index ced8619368..853b33e1b8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/types.ts @@ -1,8 +1,10 @@ export interface DashboardSidebarSectionActionsProps { color: string | null; onRename: () => void; - onSetColor: (color: string | null) => void; + onSetColor?: (color: string | null) => void; onDelete: () => void; + /** Tag-backed folders only: hide the folder without untagging anyone. */ + onHide?: () => void; } export type SectionActionsMenuKind = "context" | "dropdown"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx deleted file mode 100644 index 612b3c29c2..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { cn } from "@superset/ui/utils"; -import { - type ComponentPropsWithoutRef, - forwardRef, - type ReactNode, -} from "react"; -import { HiChevronRight } from "react-icons/hi2"; -import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; -import type { DashboardSidebarSection } from "../../../../types"; - -interface DashboardSidebarSectionHeaderProps - extends ComponentPropsWithoutRef<"div"> { - section: DashboardSidebarSection; - isRenaming: boolean; - renameValue: string; - onRenameValueChange: (value: string) => void; - onSubmitRename: () => void; - onCancelRename: () => void; - onToggleCollapse: () => void; - actions?: ReactNode; -} - -export const DashboardSidebarSectionHeader = forwardRef< - HTMLDivElement, - DashboardSidebarSectionHeaderProps ->( - ( - { - section, - isRenaming, - renameValue, - onRenameValueChange, - onSubmitRename, - onCancelRename, - onToggleCollapse, - actions, - className, - ...props - }, - ref, - ) => { - return ( - // biome-ignore lint/a11y/noStaticElementInteractions: The header acts as a single toggle target in view mode while preserving nested inline controls. -
{ - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onToggleCollapse(); - } - } - } - className={cn( - "group mx-2 flex min-h-7 items-center rounded-md pl-2 pr-2 py-1 text-[13px] font-medium", - "text-muted-foreground hover:bg-fill-hover transition-colors", - className, - )} - {...props} - > -
- -
- -
- {isRenaming ? ( - - ) : ( - {section.name} - )} -
- - {!isRenaming && ( -
- {actions ? ( - // biome-ignore lint/a11y/noStaticElementInteractions: Nested action controls handle their own semantics; this wrapper only isolates events from the header toggle. -
event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > - {actions} -
- ) : null} -
- )} -
- ); - }, -); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/index.ts deleted file mode 100644 index c69ccdfb79..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardSidebarSectionHeader } from "./DashboardSidebarSectionHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/DashboardSidebarSessionsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/DashboardSidebarSessionsSection.tsx index 1f583fb3ce..4451e33860 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/DashboardSidebarSessionsSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/DashboardSidebarSessionsSection.tsx @@ -4,6 +4,7 @@ import { } from "@dnd-kit/sortable"; import { Trans, useLingui } from "@lingui/react/macro"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useState } from "react"; import { LuPlus } from "react-icons/lu"; import { useOpenNewSessionModal } from "renderer/stores/new-workspace-modal"; import { useSidebarSectionsCollapseStore } from "renderer/stores/sidebar-sections-collapse"; @@ -13,14 +14,23 @@ import { SESSIONS_CONTAINER, useDashboardSidebarDnd, } from "../../hooks/useSidebarDnd"; -import type { DashboardSidebarWorkspace } from "../../types"; +import { useDashboardSidebarSelection } from "../../providers/DashboardSidebarSelectionProvider"; +import type { + DashboardSidebarSessionTagGroup as DashboardSidebarSessionTagGroupData, + DashboardSidebarWorkspace, + DashboardSidebarWorkspaceIndentation, +} from "../../types"; import { DashboardSidebarSectionHeader } from "../DashboardSidebarSectionHeader"; import { DashboardSidebarWorkspaceItem } from "../DashboardSidebarWorkspaceItem"; +import { WorkspaceBulkMenuScope } from "../DashboardSidebarWorkspaceItem/components/WorkspaceBulkMenuScope"; import { SidebarDropZone } from "../SidebarDropZone"; import { SortableWorkspaceItem } from "../SortableWorkspaceItem"; +import { DashboardSidebarSessionTagGroup } from "./components/DashboardSidebarSessionTagGroup"; interface DashboardSidebarSessionsSectionProps { sessionWorkspaces: DashboardSidebarWorkspace[]; + ungroupedWorkspaces: DashboardSidebarWorkspace[]; + tagGroups: DashboardSidebarSessionTagGroupData[]; isCollapsed?: boolean; workspaceShortcutLabels?: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; @@ -38,14 +48,21 @@ interface DashboardSidebarSessionsSectionProps { */ export function DashboardSidebarSessionsSection({ sessionWorkspaces, + ungroupedWorkspaces, + tagGroups, isCollapsed = false, workspaceShortcutLabels, onWorkspaceHover, }: DashboardSidebarSessionsSectionProps) { const { t } = useLingui(); + const [collapsedTagGroups, setCollapsedTagGroups] = useState>( + () => new Set(), + ); const openNewSessionModal = useOpenNewSessionModal(); - const { sessionItems, workspacesById, activeWorkspaceHome } = + const { sessionItems, activeWorkspaceHome, workspacesById } = useDashboardSidebarDnd(); + const { isWorkspaceSelected, selectWorkspaceFromEvent } = + useDashboardSidebarSelection(); const isSectionCollapsed = useSidebarSectionsCollapseStore( (s) => s.collapsed.sessions, ); @@ -56,6 +73,59 @@ export function DashboardSidebarSessionsSection({ !isCollapsed && sessionItems.length === 0 && activeWorkspaceHome === SESSIONS_CONTAINER; + const sortableIdByWorkspaceId = new Map( + sessionItems.flatMap((id) => { + const parsed = parseId(id); + return parsed?.type === "workspace" ? [[parsed.realId, String(id)]] : []; + }), + ); + const visibleSessionWorkspaces = [ + ...ungroupedWorkspaces, + ...tagGroups.flatMap((group) => + collapsedTagGroups.has(group.tag) ? [] : group.workspaces, + ), + ]; + const orderedWorkspaceIds = visibleSessionWorkspaces + .filter((workspace) => workspace.pendingTransaction?.type !== "insert") + .map((workspace) => workspace.id); + const groupInfo = new Map( + tagGroups.flatMap((group) => + group.workspaces.map( + (workspace) => [workspace.id, { sectionId: group.tag }] as const, + ), + ), + ); + const renderWorkspace = ( + workspace: DashboardSidebarWorkspace, + indentation: DashboardSidebarWorkspaceIndentation, + isInSection = false, + ) => { + const sortableId = sortableIdByWorkspaceId.get(workspace.id); + if (!sortableId) return null; + const canBulkSelect = workspace.pendingTransaction?.type !== "insert"; + return ( + + selectWorkspaceFromEvent(event, { + workspaceId: workspace.id, + projectId: "sessions", + orderedWorkspaceIds, + }) + : undefined + } + shortcutLabel={workspaceShortcutLabels?.get(workspace.id)} + onHoverCardOpen={onWorkspaceHover} + /> + ); + }; if (isCollapsed) { if (sessionWorkspaces.length === 0) return null; @@ -88,7 +158,10 @@ export function DashboardSidebarSessionsSection({
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/components/DashboardSidebarSessionTagGroup/DashboardSidebarSessionTagGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/components/DashboardSidebarSessionTagGroup/DashboardSidebarSessionTagGroup.tsx new file mode 100644 index 0000000000..aa395e4ba4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/components/DashboardSidebarSessionTagGroup/DashboardSidebarSessionTagGroup.tsx @@ -0,0 +1,107 @@ +import { normalizeWorkspaceTag } from "@superset/shared/workspace-tags"; +import { type ReactNode, useEffect, useState } from "react"; +import { useOptimisticActions } from "renderer/routes/_authenticated/hooks/useOptimisticActions"; +import { useHostWorkspaces } from "renderer/routes/_authenticated/providers/HostWorkspacesProvider"; +import { applyFolderTagChange } from "renderer/routes/_authenticated/utils/workspaceTagFolders"; +import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; +import { DashboardSidebarGroupHeader } from "../../../DashboardSidebarGroupHeader"; +import { DashboardSidebarSectionContextMenu } from "../../../DashboardSidebarSection/components/DashboardSidebarSectionContextMenu"; +import { DashboardSidebarSectionActionsDropdown } from "../../../DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/DashboardSidebarSectionActionsDropdown"; +import { useDashboardSidebarSectionRename } from "../../../DashboardSidebarSectionRenameContext"; + +interface DashboardSidebarSessionTagGroupProps { + tag: string; + isCollapsed: boolean; + onToggleCollapse: () => void; + children: ReactNode; +} + +/** A derived tag lane inside Sessions, styled like project tag folders. */ +export function DashboardSidebarSessionTagGroup({ + tag, + isCollapsed, + onToggleCollapse, + children, +}: DashboardSidebarSessionTagGroupProps) { + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(tag); + const { workspaces } = useHostWorkspaces(); + const { v2Workspaces } = useOptimisticActions(); + const { pendingRenameSectionId, clearPendingSectionRename } = + useDashboardSidebarSectionRename(); + const renameKey = `session:${tag}`; + useEffect(() => { + if (pendingRenameSectionId !== renameKey) return; + setRenameValue(tag); + setIsRenaming(true); + clearPendingSectionRename(renameKey); + }, [pendingRenameSectionId, renameKey, tag, clearPendingSectionRename]); + const members = workspaces.filter( + (workspace) => + workspace.projectId === null && + workspace.tags?.some( + (workspaceTag) => normalizeWorkspaceTag(workspaceTag) === tag, + ), + ); + const retagMembers = (nextTag: string | null) => { + for (const workspace of members) { + void v2Workspaces.updateWorkspace(workspace.id, { + tags: applyFolderTagChange(workspace.tags, [tag], nextTag), + }); + } + }; + const startRename = () => { + setRenameValue(tag); + setIsRenaming(true); + }; + const submitRename = () => { + const nextTag = normalizeWorkspaceTag(renameValue); + setIsRenaming(false); + if (!nextTag || nextTag === tag) return; + retagMembers(nextTag); + }; + const cancelRename = () => { + setRenameValue(tag); + setIsRenaming(false); + }; + const actions = ( + retagMembers(null)} + /> + ); + + return ( +
+
+ retagMembers(null)} + > + + ) : ( + {tag} + ) + } + isCollapsed={isCollapsed} + isEditing={isRenaming} + onToggleCollapse={onToggleCollapse} + actions={actions} + /> + +
+ {!isCollapsed && children} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/components/DashboardSidebarSessionTagGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/components/DashboardSidebarSessionTagGroup/index.ts new file mode 100644 index 0000000000..10e2f1a817 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSessionsSection/components/DashboardSidebarSessionTagGroup/index.ts @@ -0,0 +1 @@ +export { DashboardSidebarSessionTagGroup } from "./DashboardSidebarSessionTagGroup"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 56e63fa8c7..e425943b04 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { type KeyboardEvent, type MouseEvent, @@ -15,7 +16,10 @@ import { } from "../../providers/DashboardSidebarHoverProvider"; import type { WorkspaceSelectionEvent } from "../../providers/DashboardSidebarSelectionProvider"; import { useSidebarWorkspaceStatus } from "../../providers/DashboardSidebarWorkspaceStatusProvider"; -import type { DashboardSidebarWorkspace } from "../../types"; +import type { + DashboardSidebarWorkspace, + DashboardSidebarWorkspaceIndentation, +} from "../../types"; import { DashboardSidebarCollapsedWorkspaceButton } from "./components/DashboardSidebarCollapsedWorkspaceButton"; import { DashboardSidebarExpandedWorkspaceRow } from "./components/DashboardSidebarExpandedWorkspaceRow"; import { @@ -31,6 +35,7 @@ interface DashboardSidebarWorkspaceItemProps { shortcutLabel?: string; isCollapsed?: boolean; isInSection?: boolean; + indentation?: DashboardSidebarWorkspaceIndentation; isSelected?: boolean; onSelectionClick?: (event: WorkspaceSelectionEvent) => boolean; /** @@ -47,10 +52,12 @@ export function DashboardSidebarWorkspaceItem({ shortcutLabel, isCollapsed = false, isInSection = false, + indentation, isSelected = false, onSelectionClick, pinnedContext, }: DashboardSidebarWorkspaceItemProps) { + const { t } = useLingui(); const { id, projectId, @@ -63,6 +70,7 @@ export function DashboardSidebarWorkspaceItem({ pullRequest, } = workspace; const isMainWorkspace = workspace.type === "main"; + const isSessionWorkspace = workspace.type === "session"; const { status: workspaceStatus, diffStats } = useSidebarWorkspaceStatus(id); const { cancelRename, @@ -72,6 +80,7 @@ export function DashboardSidebarWorkspaceItem({ handleCopyPath, handleCopyBranchName, handleCreateSection, + handleMoveToSection, handleOpenInFinder, handleRemoveFromSidebar, handleRemovePullRequest, @@ -80,7 +89,6 @@ export function DashboardSidebarWorkspaceItem({ isActive, isUnread, isRenaming, - moveWorkspaceToSection, renameValue, requestDelete, setRenameValue, @@ -89,6 +97,7 @@ export function DashboardSidebarWorkspaceItem({ } = useDashboardSidebarWorkspaceItemActions({ workspaceId: id, projectId, + isSessionWorkspace, workspaceName: name, branch, isMainWorkspace, @@ -226,7 +235,15 @@ export function DashboardSidebarWorkspaceItem({ pullRequestState={pullRequest?.state ?? null} aria-label={ isPending - ? `Creating ${workspace.type === "session" ? "session" : "workspace"}: ${name}` + ? workspace.type === "session" + ? t({ + id: "dashboard.sidebar.workspaceItem.creatingSession", + message: `Creating session: ${name}`, + }) + : t({ + id: "dashboard.sidebar.workspaceItem.creatingWorkspace", + message: `Creating workspace: ${name}`, + }) : undefined } /> @@ -242,6 +259,7 @@ export function DashboardSidebarWorkspaceItem({ - moveWorkspaceToSection(id, projectId, targetSectionId) - } + onMoveToSection={handleMoveToSection} onOpenInFinder={handleOpenInFinder} onCopyPath={handleCopyPath} onCopyBranchName={handleCopyBranchName} @@ -304,6 +320,7 @@ export function DashboardSidebarWorkspaceItem({ diffStats={isPending ? null : diffStats} workspaceStatus={workspaceStatus} isInSection={isInSection} + indentation={indentation} isBulkSelectable={onSelectionClick != null} isSelected={isSelected} onClick={handleExpandedClick} @@ -334,14 +351,13 @@ export function DashboardSidebarWorkspaceItem({ - moveWorkspaceToSection(id, projectId, targetSectionId) - } + onMoveToSection={handleMoveToSection} isLocalWorkspace={hostType === "local-device"} isLocalMainWorkspace={ isMainWorkspace && hostType === "local-device" diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index a01c48b3d7..ef309be19b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -1,4 +1,7 @@ -import { Trans } from "@lingui/react/macro"; +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; +import { i18n } from "@superset/i18n"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { @@ -20,6 +23,7 @@ import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/R import type { ActivePaneStatus } from "shared/tabs-types"; import type { DashboardSidebarWorkspace, + DashboardSidebarWorkspaceIndentation, DashboardSidebarWorkspacePullRequest, } from "../../../../types"; import { DashboardSidebarWorkspaceDiffStats } from "../DashboardSidebarWorkspaceDiffStats"; @@ -28,13 +32,28 @@ import { DashboardSidebarWorkspaceChips } from "./components/DashboardSidebarWor const PR_STATE_LABEL: Record< DashboardSidebarWorkspacePullRequest["state"], - string + MessageDescriptor > = { - open: "Open", - merged: "Merged", - closed: "Closed", - draft: "Draft", - queued: "Queued", + open: msg({ + id: "dashboard.sidebar.expandedWorkspaceRow.prStateOpen", + message: "Open", + }), + merged: msg({ + id: "dashboard.sidebar.expandedWorkspaceRow.prStateMerged", + message: "Merged", + }), + closed: msg({ + id: "dashboard.sidebar.expandedWorkspaceRow.prStateClosed", + message: "Closed", + }), + draft: msg({ + id: "dashboard.sidebar.expandedWorkspaceRow.prStateDraft", + message: "Draft", + }), + queued: msg({ + id: "dashboard.sidebar.expandedWorkspaceRow.prStateQueued", + message: "Queued", + }), }; interface DashboardSidebarExpandedWorkspaceRowProps @@ -47,6 +66,7 @@ interface DashboardSidebarExpandedWorkspaceRowProps diffStats: DiffStats | null; workspaceStatus?: ActivePaneStatus | null; isInSection?: boolean; + indentation?: DashboardSidebarWorkspaceIndentation; isBulkSelectable?: boolean; isSelected?: boolean; /** Present when rendered in the Pinned section: shows the project avatar. */ @@ -77,6 +97,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< diffStats, workspaceStatus = null, isInSection = false, + indentation, isBulkSelectable = false, isSelected = false, pinnedContext, @@ -94,6 +115,9 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< }, ref, ) => { + const { t } = useLingui(); + const resolvedIndentation = + indentation ?? (isInSection ? "grouped" : "workspace"); const { hostType, hostIsOnline, @@ -176,7 +200,11 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< onBlur={handleRowBlur} className={cn( "group relative flex h-7 w-full items-center pr-2", - isInSection ? "pl-10" : "pl-6", + resolvedIndentation === "top-level" + ? "pl-2" + : resolvedIndentation === "grouped" + ? "pl-10" + : "pl-6", onClick && "cursor-pointer", )} > @@ -199,7 +227,10 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< event.stopPropagation(); } }} - aria-label={`Open pull request #${pullRequest.number}`} + aria-label={t({ + id: "dashboard.sidebar.expandedWorkspaceRow.openPrAriaLabel", + message: `Open pull request #${pullRequest.number}`, + })} className="relative mr-2 flex size-4 shrink-0 cursor-pointer items-center justify-center rounded hover:bg-foreground/10" > PR #{pullRequest.number} —{" "} - {PR_STATE_LABEL[pullRequest.state]} + {i18n._(PR_STATE_LABEL[pullRequest.state])}

@@ -302,14 +333,24 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef<

- {pinnedContext.projectName ?? "Session"} + {pinnedContext.projectName ?? + t({ + id: "dashboard.sidebar.expandedWorkspaceRow.sessionTooltipFallback", + message: "Session", + })} )} @@ -389,13 +430,21 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< } }} className="flex items-center justify-center text-muted-foreground hover:text-foreground" - aria-label="Remove from sidebar" + aria-label={t({ + id: "dashboard.sidebar.expandedWorkspaceRow.removeFromSidebarAriaLabel", + message: "Remove from sidebar", + })} > - + ) : ( @@ -417,14 +466,20 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< } }} className="flex items-center justify-center text-muted-foreground hover:text-foreground" - aria-label="Close workspace" + aria-label={t({ + id: "dashboard.sidebar.expandedWorkspaceRow.closeWorkspaceAriaLabel", + message: "Close workspace", + })} > @@ -439,6 +494,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/DashboardSidebarWorkspaceChips.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/DashboardSidebarWorkspaceChips.tsx index 51c9b2fb8b..f9233db687 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/DashboardSidebarWorkspaceChips.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/DashboardSidebarWorkspaceChips.tsx @@ -3,6 +3,7 @@ import type { MouseEventHandler } from "react"; import { useDashboardSidebarWorkspacePorts } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarPortsProvider"; import { useInlineWorkspacePortsEnabled } from "renderer/stores/inline-workspace-ports"; import { useWorkspaceAgentsRowEnabled } from "renderer/stores/workspace-agents-row"; +import type { DashboardSidebarWorkspaceIndentation } from "../../../../../../types"; import { DashboardSidebarAgentsChip } from "./components/DashboardSidebarAgentsChip"; import { DashboardSidebarPortsChip } from "./components/DashboardSidebarPortsChip"; import { useDashboardSidebarWorkspaceRunningAgents } from "./hooks/useDashboardSidebarWorkspaceRunningAgents"; @@ -10,6 +11,7 @@ import { useDashboardSidebarWorkspaceRunningAgents } from "./hooks/useDashboardS interface DashboardSidebarWorkspaceChipsProps { workspaceId: string; isInSection?: boolean; + indentation?: DashboardSidebarWorkspaceIndentation; /** Invoked when the strip itself (not one of its chips) is clicked. */ onClick?: MouseEventHandler; } @@ -23,6 +25,7 @@ interface DashboardSidebarWorkspaceChipsProps { export function DashboardSidebarWorkspaceChips({ workspaceId, isInSection = false, + indentation, onClick, }: DashboardSidebarWorkspaceChipsProps) { const inlineWorkspacePortsEnabled = useInlineWorkspacePortsEnabled(); @@ -47,7 +50,11 @@ export function DashboardSidebarWorkspaceChips({
event.stopPropagation()} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarAgentsChip/DashboardSidebarAgentsChip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarAgentsChip/DashboardSidebarAgentsChip.tsx index 8a51d478ba..524182ec74 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarAgentsChip/DashboardSidebarAgentsChip.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarAgentsChip/DashboardSidebarAgentsChip.tsx @@ -1,4 +1,5 @@ -import { Trans } from "@lingui/react/macro"; +import { plural } from "@lingui/core/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { Badge } from "@superset/ui/badge"; import { HoverCard, @@ -27,6 +28,7 @@ export function DashboardSidebarAgentsChip({ workspaceId, agents, }: DashboardSidebarAgentsChipProps) { + const { t } = useLingui(); const { isOpen, onOpenChange, onPointerEnter, onPointerLeave, toggleOpen } = useDashboardSidebarChipHoverSuppression(); @@ -65,7 +67,23 @@ export function DashboardSidebarAgentsChip({ } }} aria-expanded={isOpen} - aria-label={`${agents.length} running agents — ${isOpen ? "hide" : "show"} details`} + aria-label={ + isOpen + ? t({ + id: "dashboard.sidebar.agentsChip.hideDetailsAriaLabel", + message: plural(agents.length, { + one: "# running agent — hide details", + other: "# running agents — hide details", + }), + }) + : t({ + id: "dashboard.sidebar.agentsChip.showDetailsAriaLabel", + message: plural(agents.length, { + one: "# running agent — show details", + other: "# running agents — show details", + }), + }) + } className={cn( "group/chip h-[18px] overflow-visible bg-muted/60 px-1.5 py-0 text-[9px] font-medium tabular-nums text-muted-foreground", "[&>svg]:size-2.5 hover:bg-muted hover:text-foreground", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarAgentsChip/components/DashboardSidebarAgentHoverRow/DashboardSidebarAgentHoverRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarAgentsChip/components/DashboardSidebarAgentHoverRow/DashboardSidebarAgentHoverRow.tsx index fe464c7ccf..915965428f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarAgentsChip/components/DashboardSidebarAgentHoverRow/DashboardSidebarAgentHoverRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarAgentsChip/components/DashboardSidebarAgentHoverRow/DashboardSidebarAgentHoverRow.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; @@ -25,6 +26,7 @@ export function DashboardSidebarAgentHoverRow({ workspaceId, agent, }: DashboardSidebarAgentHoverRowProps) { + const { t } = useLingui(); const navigate = useNavigate(); const handleOpen = () => { @@ -37,7 +39,9 @@ export function DashboardSidebarAgentHoverRow({ }; const statusLabel = - agent.status === "idle" ? "Idle" : getStatusTooltip(agent.status); + agent.status === "idle" + ? t({ id: "dashboard.sidebar.agentHoverRow.idle", message: "Idle" }) + : getStatusTooltip(agent.status); return (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarPortsChip/DashboardSidebarPortsChip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarPortsChip/DashboardSidebarPortsChip.tsx index e31b3e16e8..c805a56557 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarPortsChip/DashboardSidebarPortsChip.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarPortsChip/DashboardSidebarPortsChip.tsx @@ -1,4 +1,5 @@ -import { Trans } from "@lingui/react/macro"; +import { plural } from "@lingui/core/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { Badge } from "@superset/ui/badge"; import { HoverCard, @@ -27,6 +28,7 @@ interface DashboardSidebarPortsChipProps { export function DashboardSidebarPortsChip({ ports, }: DashboardSidebarPortsChipProps) { + const { t } = useLingui(); const { isPending, killPorts } = useDashboardSidebarPortKill(); const { isOpen, onOpenChange, onPointerEnter, onPointerLeave, toggleOpen } = useDashboardSidebarChipHoverSuppression(); @@ -37,7 +39,13 @@ export function DashboardSidebarPortsChip({ const closedCount = results.filter((result) => result.success).length; if (closedCount > 0) { toast.success( - closedCount === 1 ? "Closed 1 port" : `Closed ${closedCount} ports`, + t({ + id: "dashboard.sidebar.portsChip.closedPortsToast", + message: plural(closedCount, { + one: "Closed # port", + other: "Closed # ports", + }), + }), ); } }; @@ -70,7 +78,23 @@ export function DashboardSidebarPortsChip({ disabled={isPending} aria-busy={isPending} aria-expanded={isOpen} - aria-label={`${ports.length} active ${ports.length === 1 ? "port" : "ports"} — ${isOpen ? "hide" : "show"} details`} + aria-label={ + isOpen + ? t({ + id: "dashboard.sidebar.portsChip.hideDetailsAriaLabel", + message: plural(ports.length, { + one: "# active port — hide details", + other: "# active ports — hide details", + }), + }) + : t({ + id: "dashboard.sidebar.portsChip.showDetailsAriaLabel", + message: plural(ports.length, { + one: "# active port — show details", + other: "# active ports — show details", + }), + }) + } className={cn( "group/chip h-[18px] bg-muted/60 px-1.5 py-0 text-[9px] font-medium tabular-nums text-muted-foreground", "[&>svg]:size-2.5 hover:bg-muted hover:text-foreground disabled:opacity-70", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarPortsChip/components/DashboardSidebarPortHoverRow/DashboardSidebarPortHoverRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarPortsChip/components/DashboardSidebarPortHoverRow/DashboardSidebarPortHoverRow.tsx index 9f54ca6720..83085954af 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarPortsChip/components/DashboardSidebarPortHoverRow/DashboardSidebarPortHoverRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/components/DashboardSidebarWorkspaceChips/components/DashboardSidebarPortsChip/components/DashboardSidebarPortHoverRow/DashboardSidebarPortHoverRow.tsx @@ -1,7 +1,11 @@ +import { useLingui } from "@lingui/react/macro"; import { LuX } from "react-icons/lu"; import { useDashboardSidebarPortKill } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarPortKill"; import type { DashboardSidebarPort } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarPortsData"; import { usePortOpenActions } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/usePortOpenActions"; +import { usePortForward } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/PortForwardsProvider"; +import { formatPortRowLabel } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/formatPortRowLabel"; +import { PortForwardBusyActions } from "renderer/routes/_authenticated/_dashboard/components/PortForwardBusyActions"; import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; interface DashboardSidebarPortHoverRowProps { @@ -11,8 +15,11 @@ interface DashboardSidebarPortHoverRowProps { export function DashboardSidebarPortHoverRow({ port, }: DashboardSidebarPortHoverRowProps) { + const { t } = useLingui(); const { isPending, killPort } = useDashboardSidebarPortKill(); const { openPrimary } = usePortOpenActions(port); + const forward = usePortForward(port); + const address = formatPortRowLabel({ port, forward }); return (
@@ -26,11 +33,14 @@ export function DashboardSidebarPortHoverRow({ {port.label && ( {port.label} )} - - {"localhost:"} - {port.port} + + {address.text} + {forward && } {/* Always in layout, shown via visibility so the row never changes size on hover. */} {expanded && ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksSummary/ChecksSummary.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksSummary/ChecksSummary.tsx index 324621068e..e6915441e1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksSummary/ChecksSummary.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ChecksSummary/ChecksSummary.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { LuCheck, LuLoaderCircle, LuX } from "react-icons/lu"; import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; import type { DashboardSidebarWorkspacePullRequestCheck } from "../../../../../../types"; @@ -8,6 +9,7 @@ interface ChecksSummaryProps { } export function ChecksSummary({ checks, status }: ChecksSummaryProps) { + const { t } = useLingui(); if (status === "none") return null; const passing = checks.filter((check) => check.status === "success").length; @@ -31,7 +33,13 @@ export function ChecksSummary({ checks, status }: ChecksSummaryProps) { }; const { icon: Icon, className } = config[status]; - const label = total > 0 ? `${passing}/${total} checks` : "Checks"; + const label = + total > 0 + ? t({ + id: "dashboard.sidebar.checksSummary.passingCount", + message: `${passing}/${total} checks`, + }) + : t({ id: "dashboard.sidebar.checksSummary.checks", message: "Checks" }); return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/LinkedTaskSection/LinkedTaskSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/LinkedTaskSection/LinkedTaskSection.tsx index 96304f425d..8ffaa675bb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/LinkedTaskSection/LinkedTaskSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/LinkedTaskSection/LinkedTaskSection.tsx @@ -1,4 +1,4 @@ -import { Trans } from "@lingui/react/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { Link } from "@tanstack/react-router"; import { LuExternalLink } from "react-icons/lu"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; @@ -12,6 +12,7 @@ interface LinkedTaskSectionProps { } export function LinkedTaskSection({ taskId }: LinkedTaskSectionProps) { + const { t } = useLingui(); const { data: taskRecord } = cloudTrpc.task.byIdOrSlug.useQuery(taskId); const { data: statuses } = cloudTrpc.task.statuses.list.useQuery(undefined); @@ -63,7 +64,10 @@ export function LinkedTaskSection({ taskId }: LinkedTaskSectionProps) { target="_blank" rel="noopener noreferrer" className="shrink-0 text-muted-foreground hover:text-foreground" - title="Open task externally" + title={t({ + id: "dashboard.sidebar.linkedTask.openExternally", + message: "Open task externally", + })} onClick={(e) => e.stopPropagation()} > diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/PullRequestStatusBadge/PullRequestStatusBadge.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/PullRequestStatusBadge/PullRequestStatusBadge.tsx index c63a13fb8a..395ae5c106 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/PullRequestStatusBadge/PullRequestStatusBadge.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/PullRequestStatusBadge/PullRequestStatusBadge.tsx @@ -1,7 +1,20 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; +import { i18n } from "@superset/i18n"; + interface PullRequestStatusBadgeProps { state: "open" | "draft" | "merged" | "closed" | "queued"; } +const LABELS: Record = + { + open: msg({ id: "dashboard.sidebar.prStatus.open", message: "Open" }), + draft: msg({ id: "dashboard.sidebar.prStatus.draft", message: "Draft" }), + merged: msg({ id: "dashboard.sidebar.prStatus.merged", message: "Merged" }), + closed: msg({ id: "dashboard.sidebar.prStatus.closed", message: "Closed" }), + queued: msg({ id: "dashboard.sidebar.prStatus.queued", message: "Queued" }), + }; + export function PullRequestStatusBadge({ state }: PullRequestStatusBadgeProps) { const styles = { open: "bg-emerald-500/15 text-emerald-500", @@ -11,19 +24,11 @@ export function PullRequestStatusBadge({ state }: PullRequestStatusBadgeProps) { queued: "bg-amber-500/15 text-amber-500", }; - const labels = { - open: "Open", - draft: "Draft", - merged: "Merged", - closed: "Closed", - queued: "Queued", - }; - return ( - {labels[state]} + {i18n._(LABELS[state])} ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ReviewStatus/ReviewStatus.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ReviewStatus/ReviewStatus.tsx index 04a0c509b3..5187311af6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ReviewStatus/ReviewStatus.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/ReviewStatus/ReviewStatus.tsx @@ -1,3 +1,5 @@ +import { useLingui } from "@lingui/react/macro"; + interface ReviewStatusProps { status: "approved" | "changes_requested" | "pending"; requestedReviewers?: string[]; @@ -7,20 +9,33 @@ export function ReviewStatus({ status, requestedReviewers, }: ReviewStatusProps) { + const { t } = useLingui(); const config = { approved: { - label: "Approved", + label: t({ + id: "dashboard.sidebar.reviewStatus.approved", + message: "Approved", + }), className: "bg-emerald-500/15 text-emerald-500", }, changes_requested: { - label: "Changes requested", + label: t({ + id: "dashboard.sidebar.reviewStatus.changesRequested", + message: "Changes requested", + }), className: "bg-destructive/15 text-destructive-foreground", }, pending: { label: requestedReviewers && requestedReviewers.length > 0 - ? `Awaiting ${requestedReviewers.join(", ")}` - : "Review pending", + ? t({ + id: "dashboard.sidebar.reviewStatus.awaitingReviewers", + message: `Awaiting ${requestedReviewers.join(", ")}`, + }) + : t({ + id: "dashboard.sidebar.reviewStatus.reviewPending", + message: "Review pending", + }), className: "bg-amber-500/15 text-amber-500", }, }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/WorkspaceBulkMenuScope/WorkspaceBulkMenuScope.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/WorkspaceBulkMenuScope/WorkspaceBulkMenuScope.tsx index d690b91eb3..cd541c5458 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/WorkspaceBulkMenuScope/WorkspaceBulkMenuScope.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/WorkspaceBulkMenuScope/WorkspaceBulkMenuScope.tsx @@ -2,7 +2,7 @@ import { createContext, type ReactNode, useContext, useMemo } from "react"; import type { DashboardSidebarWorkspace } from "../../../../types"; interface WorkspaceBulkMenuScopeValue { - projectId: string; + projectId: string | null; workspacesById: ReadonlyMap; sectionIdByWorkspaceId: ReadonlyMap; } @@ -20,7 +20,7 @@ export function WorkspaceBulkMenuScope({ groupInfo, children, }: { - projectId: string; + projectId: string | null; workspacesById: ReadonlyMap; groupInfo: ReadonlyMap; children: ReactNode; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts index eaf7e4a363..5312d5d7b5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts @@ -1,4 +1,6 @@ +import { useLingui } from "@lingui/react/macro"; import { errorMessage } from "@superset/i18n/errors"; +import { normalizeWorkspaceTags } from "@superset/shared/workspace-tags"; import { toast } from "@superset/ui/sonner"; import { useQueryClient } from "@tanstack/react-query"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; @@ -17,7 +19,12 @@ import { } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarWorkspaceStatusProvider"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useOptimisticActions } from "renderer/routes/_authenticated/hooks/useOptimisticActions"; +import { useHostWorkspaces } from "renderer/routes/_authenticated/providers/HostWorkspacesProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + applyFolderTagChange, + mintFolderTag, +} from "renderer/routes/_authenticated/utils/workspaceTagFolders"; import { useDeleteWorkspaceIntent } from "renderer/stores/delete-workspace-intent"; import { useRemoveFromSidebarIntent } from "renderer/stores/remove-workspace-from-sidebar-intent"; import { useV2NotificationStore } from "renderer/stores/v2-notifications"; @@ -26,6 +33,11 @@ interface UseDashboardSidebarWorkspaceItemActionsOptions { workspaceId: string; /** Null for project-less "session" workspaces. */ projectId: string | null; + /** + * Cloud rows are also project-less, so a null `projectId` alone does not + * mean "session". Only sessions may be grouped by tag. + */ + isSessionWorkspace?: boolean; workspaceName: string; branch: string; isMainWorkspace?: boolean; @@ -35,11 +47,13 @@ interface UseDashboardSidebarWorkspaceItemActionsOptions { export function useDashboardSidebarWorkspaceItemActions({ workspaceId, projectId, + isSessionWorkspace = false, workspaceName, branch, isMainWorkspace = false, isPinned = false, }: UseDashboardSidebarWorkspaceItemActionsOptions) { + const { t } = useLingui(); const navigate = useNavigate(); const matchRoute = useMatchRoute(); const hostService = useLocalHostService(); @@ -54,6 +68,17 @@ export function useDashboardSidebarWorkspaceItemActions({ const { isUnread } = useSidebarWorkspaceStatus(workspaceId); const workspaceHostUrl = useWorkspaceHostUrl(workspaceId); const queryClient = useQueryClient(); + const { workspaces: hostWorkspaces } = useHostWorkspaces(); + const sessionGroupTags = new Set( + hostWorkspaces.flatMap((workspace) => + workspace.projectId === null + ? normalizeWorkspaceTags(workspace.tags) + : [], + ), + ); + const currentWorkspaceTags = normalizeWorkspaceTags( + hostWorkspaces.find((workspace) => workspace.id === workspaceId)?.tags, + ); const clearWorkspaceAttention = () => { clearManualUnread(workspaceId); @@ -130,17 +155,39 @@ export function useDashboardSidebarWorkspaceItemActions({ }; const handleCreateSection = () => { - // Sessions get groups in the stacked nesting PR. - if (projectId === null) return; + if (projectId === null) { + if (!isSessionWorkspace) return; + const tag = mintFolderTag("New group", sessionGroupTags); + void workspaceActions.updateWorkspace(workspaceId, { + tags: applyFolderTagChange(currentWorkspaceTags, sessionGroupTags, tag), + }); + requestSectionRename(`session:${tag}`); + return; + } const sectionId = createSection(projectId); moveWorkspaceToSection(workspaceId, projectId, sectionId); requestSectionRename(sectionId); }; + const handleMoveToSection = (sectionId: string | null) => { + if (projectId !== null) { + moveWorkspaceToSection(workspaceId, projectId, sectionId); + return; + } + if (!isSessionWorkspace) return; + void workspaceActions.updateWorkspace(workspaceId, { + tags: applyFolderTagChange( + currentWorkspaceTags, + sessionGroupTags, + sectionId, + ), + }); + }; + const resolveWorktreePath = async (): Promise => { if (!activeHostUrl) { showHostServiceUnavailableToast(hostService, { - action: "resolve the workspace path", + action: "resolveWorkspacePath", }); return null; } @@ -148,7 +195,12 @@ export function useDashboardSidebarWorkspaceItemActions({ activeHostUrl, ).workspace.get.query({ id: workspaceId }); if (!workspace?.worktreePath) { - toast.error("Workspace path is not available"); + toast.error( + t({ + id: "dashboard.sidebar.workspaceActions.pathUnavailable", + message: "Workspace path is not available", + }), + ); return null; } return workspace.worktreePath; @@ -161,7 +213,10 @@ export function useDashboardSidebarWorkspaceItemActions({ await electronTrpcClient.external.openInFinder.mutate(path); } catch (error) { toast.error( - `Failed to open in Finder: ${errorMessage(error, "Unknown error")}`, + t({ + id: "dashboard.sidebar.workspaceActions.openInFinderFailed", + message: `Failed to open in Finder: ${errorMessage(error, "Unknown error")}`, + }), ); } }; @@ -171,10 +226,18 @@ export function useDashboardSidebarWorkspaceItemActions({ const path = await resolveWorktreePath(); if (!path) return; await copyToClipboard(path); - toast.success("Path copied"); + toast.success( + t({ + id: "dashboard.sidebar.workspaceActions.pathCopied", + message: "Path copied", + }), + ); } catch (error) { toast.error( - `Failed to copy path: ${errorMessage(error, "Unknown error")}`, + t({ + id: "dashboard.sidebar.workspaceActions.copyPathFailed", + message: `Failed to copy path: ${errorMessage(error, "Unknown error")}`, + }), ); } }; @@ -207,7 +270,10 @@ export function useDashboardSidebarWorkspaceItemActions({ }); } catch (error) { toast.error( - `Failed to clear agent status: ${errorMessage(error, "Unknown error")}`, + t({ + id: "dashboard.sidebar.workspaceActions.clearStatusFailed", + message: `Failed to clear agent status: ${errorMessage(error, "Unknown error")}`, + }), ); } }; @@ -215,7 +281,7 @@ export function useDashboardSidebarWorkspaceItemActions({ const handleRemovePullRequest = async () => { if (!workspaceHostUrl) { showHostServiceUnavailableToast(hostService, { - action: "remove the PR link", + action: "removePrLink", }); return; } @@ -228,22 +294,38 @@ export function useDashboardSidebarWorkspaceItemActions({ }); } catch (error) { toast.error( - `Failed to remove PR link: ${errorMessage(error, "Unknown error")}`, + t({ + id: "dashboard.sidebar.workspaceActions.removePrLinkFailed", + message: `Failed to remove PR link: ${errorMessage(error, "Unknown error")}`, + }), ); } }; const handleCopyBranchName = async () => { if (!branch) { - toast.error("Branch name is not available"); + toast.error( + t({ + id: "dashboard.sidebar.workspaceActions.branchUnavailable", + message: "Branch name is not available", + }), + ); return; } try { await copyToClipboard(branch); - toast.success("Branch name copied"); + toast.success( + t({ + id: "dashboard.sidebar.workspaceActions.branchCopied", + message: "Branch name copied", + }), + ); } catch (error) { toast.error( - `Failed to copy branch name: ${errorMessage(error, "Unknown error")}`, + t({ + id: "dashboard.sidebar.workspaceActions.copyBranchFailed", + message: `Failed to copy branch name: ${errorMessage(error, "Unknown error")}`, + }), ); } }; @@ -255,6 +337,7 @@ export function useDashboardSidebarWorkspaceItemActions({ handleCopyPath, handleCopyBranchName, handleCreateSection, + handleMoveToSection, handleOpenInFinder, handleRemoveFromSidebar, handleRemovePullRequest, @@ -263,7 +346,6 @@ export function useDashboardSidebarWorkspaceItemActions({ isActive, isRenaming, isUnread, - moveWorkspaceToSection, pendingName, renameValue, requestDelete, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/DashboardSidebarWorkspacesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/DashboardSidebarWorkspacesHeader.tsx index 6437279afe..262543f6aa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/DashboardSidebarWorkspacesHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/DashboardSidebarWorkspacesHeader.tsx @@ -30,23 +30,45 @@ export function DashboardSidebarWorkspacesHeader() { const navigate = useNavigate(); const folderImport = useFolderFirstImport({ onError: (message) => { - toast.error(`Import failed: ${message}`); + toast.error( + t({ + id: "dashboard.sidebar.workspacesHeader.importFailed", + message: `Import failed: ${message}`, + }), + ); }, onMultipleProjects: ({ candidates }) => { - toast.error("Import failed", { - description: `Multiple projects use this repository (${candidates.length}). Choose the project in settings to set it up on this device.`, - action: { - label: "Open Projects", - onClick: () => navigate({ to: "/settings/projects" }), + toast.error( + t({ + id: "dashboard.sidebar.workspacesHeader.importFailedTitle", + message: "Import failed", + }), + { + description: t({ + id: "dashboard.sidebar.workspacesHeader.importMultipleProjects", + message: `Multiple projects use this repository (${candidates.length}). Choose the project in settings to set it up on this device.`, + }), + action: { + label: t({ + id: "dashboard.sidebar.workspacesHeader.openProjectsAction", + message: "Open Projects", + }), + onClick: () => navigate({ to: "/settings/projects" }), + }, }, - }); + ); }, }); const handleImportFolder = async () => { const result = await folderImport.start(); if (result) { - toast.success("Project ready — open it from the sidebar."); + toast.success( + t({ + id: "dashboard.sidebar.workspacesHeader.projectReady", + message: "Project ready — open it from the sidebar.", + }), + ); } }; @@ -64,7 +86,10 @@ export function DashboardSidebarWorkspacesHeader() { + )} + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PortForwardBusyActions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PortForwardBusyActions/index.ts new file mode 100644 index 0000000000..938de52bd1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PortForwardBusyActions/index.ts @@ -0,0 +1 @@ +export { PortForwardBusyActions } from "./PortForwardBusyActions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/ProjectFilter/ProjectFilter.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/ProjectFilter/ProjectFilter.tsx index 06f62c2879..c5e96e1a93 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/ProjectFilter/ProjectFilter.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/ProjectFilter/ProjectFilter.tsx @@ -1,4 +1,5 @@ -import { Trans } from "@lingui/react/macro"; +import { plural } from "@lingui/core/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; import { Button } from "@superset/ui/button"; import { Command, @@ -35,6 +36,7 @@ export function ProjectFilter({ onChange, alwaysShowLabel, }: ProjectFilterProps) { + const { t } = useLingui(); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -71,12 +73,24 @@ export function ProjectFilter({ const isAllSelected = value.length === 0; const isLoadingWithoutProjects = !isReady && projects.length === 0; const label = isLoadingWithoutProjects - ? "Loading repositories" + ? t({ + id: "dashboard.projectFilter.loadingRepositories", + message: "Loading repositories", + }) : isAllSelected - ? "All repositories" + ? t({ + id: "dashboard.projectFilter.allRepositoriesLabel", + message: "All repositories", + }) : selectedProjects.length === 1 ? selectedProjects[0]?.name - : `${selectedProjects.length} repositories`; + : t({ + id: "dashboard.projectFilter.repositoriesCount", + message: plural(selectedProjects.length, { + one: "# repository", + other: "# repositories", + }), + }); const selectedProject = selectedProjects.length === 1 ? selectedProjects[0] : null; @@ -93,7 +107,10 @@ export function ProjectFilter({ variant="ghost" size="sm" title={label} - aria-label={`Repositories: ${label}`} + aria-label={t({ + id: "dashboard.projectFilter.repositoriesAriaLabel", + message: `Repositories: ${label}`, + })} className="h-8 gap-1.5 px-2 text-muted-foreground hover:text-foreground" > {selectedProject ? ( @@ -121,7 +138,10 @@ export function ProjectFilter({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/RemotePortForwarder.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/RemotePortForwarder.tsx new file mode 100644 index 0000000000..f79dc82a88 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/RemotePortForwarder.tsx @@ -0,0 +1,19 @@ +import { useRouterState } from "@tanstack/react-router"; +import { useRemotePortForwarding } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useRemotePortForwarding"; + +const V2_WORKSPACE_PATH = /^\/v2-workspace\/([^/]+)/; + +/** + * Renders nothing; keeps the main process's port forwards in step with the + * workspace on screen. Reads the live pathname itself, so only this leaf + * re-renders on navigation, and forwards stop the moment the user leaves a + * v2 workspace route. + */ +export function RemotePortForwarder() { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + const activeWorkspaceId = V2_WORKSPACE_PATH.exec(pathname)?.[1] ?? null; + useRemotePortForwarding(activeWorkspaceId); + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/index.ts new file mode 100644 index 0000000000..0ca98e445c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/RemotePortForwarder/index.ts @@ -0,0 +1 @@ +export { RemotePortForwarder } from "./RemotePortForwarder"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/SortableHeader/SortableHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/SortableHeader/SortableHeader.tsx index 98d4a29cfa..b86135b017 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/SortableHeader/SortableHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/SortableHeader/SortableHeader.tsx @@ -1,3 +1,4 @@ +import { useLingui } from "@lingui/react/macro"; import { cn } from "@superset/ui/utils"; import { LuChevronDown, LuChevronsUpDown, LuChevronUp } from "react-icons/lu"; @@ -24,6 +25,7 @@ export function SortableHeader({ onSort, srOnlyLabel = false, }: SortableHeaderProps) { + const { t } = useLingui(); const isActive = sortField === field; const Icon = !isActive ? LuChevronsUpDown @@ -32,15 +34,18 @@ export function SortableHeader({ : LuChevronDown; const sortLabel = isActive ? sortDirection === "asc" - ? "ascending" - : "descending" - : "not sorted"; + ? t({ id: "dashboard.sortableHeader.ascending", message: "ascending" }) + : t({ id: "dashboard.sortableHeader.descending", message: "descending" }) + : t({ id: "dashboard.sortableHeader.notSorted", message: "not sorted" }); return (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx index 9f0159756e..84e9a0f173 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx @@ -1,4 +1,7 @@ -import { Trans } from "@lingui/react/macro"; +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; +import { i18n } from "@superset/i18n"; import { Button } from "@superset/ui/button"; import { DropdownMenu, @@ -25,11 +28,20 @@ import { useResourceNavigation } from "./hooks/useResourceNavigation"; import { useResourceSnapshot } from "./hooks/useResourceSnapshot"; import type { SortOption, UsageValues } from "./types"; -const SORT_LABELS: Record = { - memory: "Memory", - cpu: "CPU", - name: "Name", - sidebar: "Sidebar order", +const SORT_LABELS: Record = { + memory: msg({ + id: "dashboard.topBar.resources.sortLabelMemory", + message: "Memory", + }), + cpu: msg({ id: "dashboard.topBar.resources.sortLabelCpu", message: "CPU" }), + name: msg({ + id: "dashboard.topBar.resources.sortLabelName", + message: "Name", + }), + sidebar: msg({ + id: "dashboard.topBar.resources.sortLabelSidebarOrder", + message: "Sidebar order", + }), }; function getTotalUsage( @@ -51,6 +63,7 @@ export function ResourceConsumption({ surface = "v1", className, }: ResourceConsumptionProps) { + const { t } = useLingui(); const [open, setOpen] = useState(false); const { data: enabled } = electronTrpc.settings.getShowResourceMonitor.useQuery(); @@ -70,7 +83,10 @@ export function ResourceConsumption({ @@ -204,7 +224,10 @@ function ResourceConsumptionContent({ type="button" onClick={() => refetch()} className="h-6 w-6 inline-flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors" - aria-label="Refresh metrics" + aria-label={t({ + id: "dashboard.topBar.resources.refreshMetrics", + message: "Refresh metrics", + })} > {showOther && (
@@ -64,7 +86,10 @@ export function ResourceMetricsSummary({
toggleProject(project.projectId)} className="group w-full flex items-center justify-between px-2 py-1.5 hover:bg-foreground/[0.04] transition-colors" aria-label={ - isProjectCollapsed ? "Expand project" : "Collapse project" + isProjectCollapsed + ? t({ + id: "dashboard.topBar.workspaceResources.expandProject", + message: "Expand project", + }) + : t({ + id: "dashboard.topBar.workspaceResources.collapseProject", + message: "Collapse project", + }) } >
@@ -119,7 +129,15 @@ export function WorkspaceResourceSection({ onClick={() => toggleWorkspace(workspace.workspaceId)} className="flex items-center justify-center h-7 w-5 ml-3.5 shrink-0 text-muted-foreground/60 hover:text-muted-foreground transition-colors" aria-label={ - isCollapsed ? "Expand workspace" : "Collapse workspace" + isCollapsed + ? t({ + id: "dashboard.topBar.workspaceResources.expandWorkspace", + message: "Expand workspace", + }) + : t({ + id: "dashboard.topBar.workspaceResources.collapseWorkspace", + message: "Collapse workspace", + }) } > {isCollapsed ? ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/utils/workspaceGrouping.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/utils/workspaceGrouping.ts index bb97408216..2525f6df68 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/utils/workspaceGrouping.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/utils/workspaceGrouping.ts @@ -1,3 +1,4 @@ +import { i18n } from "@superset/i18n"; import type { SortOption, WorkspaceMetrics } from "../types"; export interface ProjectResourceGroup { @@ -15,7 +16,12 @@ export function groupWorkspacesByProject( for (const workspace of workspaces) { const projectId = workspace.projectId || "unknown"; - const projectName = workspace.projectName || "Unknown Project"; + const projectName = + workspace.projectName || + i18n._({ + id: "dashboard.topBar.workspaceResources.unknownProject", + message: "Unknown Project", + }); let group = projectMap.get(projectId); if (!group) { group = { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/TopBarPortsDropdown/TopBarPortsDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/TopBarPortsDropdown/TopBarPortsDropdown.tsx index a97dc88303..4e06070937 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/TopBarPortsDropdown/TopBarPortsDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/TopBarPortsDropdown/TopBarPortsDropdown.tsx @@ -1,4 +1,4 @@ -import { Plural, Trans } from "@lingui/react/macro"; +import { Plural, Trans, useLingui } from "@lingui/react/macro"; import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useState } from "react"; @@ -28,6 +28,7 @@ interface TopBarPortsDropdownProps { export function TopBarPortsDropdown({ align = "end", }: TopBarPortsDropdownProps) { + const { t } = useLingui(); const isV2CloudEnabled = useIsV2CloudEnabled(); const portsDisplayMode = usePortsDisplayMode(); const [open, setOpen] = useState(false); @@ -50,7 +51,10 @@ export function TopBarPortsDropdown({ + {forward?.status.state === "busy" && ( +
+ +
+ )}
{canOpenInBrowser && ( @@ -92,7 +126,10 @@ export function PageCard({ type="button" variant="ghost" size="icon-sm" - aria-label={`Actions for ${page.title}`} + aria-label={t({ + id: "dashboard.pages.pageCard.actionsFor", + message: `Actions for ${page.title}`, + })} className={cn( "absolute top-2 right-2 size-7 bg-background/80 backdrop-blur transition-opacity", "opacity-0 focus-visible:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100", @@ -118,8 +155,25 @@ export function PageCard({ Copy link + {isOwner ? ( + setDeleteOpen(true)} + > + + Delete + + ) : null} + + onDelete(page.id)} + />
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/PageThumbnail.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/PageThumbnail.tsx index 895a79df3a..b58b4a15fc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/PageThumbnail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/PageThumbnail.tsx @@ -1,108 +1,45 @@ -import { Spinner } from "@superset/ui/spinner"; -import { useQuery } from "@tanstack/react-query"; import { FileText } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import { - FRAME_HEIGHT, - FRAME_WIDTH, - THUMBNAIL_ASPECT_RATIO, -} from "../../../../constants"; -import { - loadThumbnailUrl, - thumbnailCacheKey, -} from "./utils/pageThumbnailCache"; +import { useState } from "react"; +import { THUMBNAIL_ASPECT_RATIO } from "../../../../constants"; interface PageThumbnailProps { - slug: string; - pageId: string; + src: string | null; } -export function PageThumbnail({ slug, pageId }: PageThumbnailProps) { - const containerRef = useRef(null); - const [isVisible, setIsVisible] = useState(false); - const [scale, setScale] = useState(0); - - useEffect(() => { - const element = containerRef.current; - if (!element) return; - - const observer = new IntersectionObserver( - ([entry]) => { - if (entry?.isIntersecting) setIsVisible(true); - }, - { rootMargin: "300px" }, - ); - observer.observe(element); - - const resize = new ResizeObserver(([entry]) => { - const width = entry?.contentRect.width ?? 0; - if (width > 0) setScale(width / FRAME_WIDTH); - }); - resize.observe(element); - - return () => { - observer.disconnect(); - resize.disconnect(); - }; - }, []); - - const pull = cloudTrpc.page.pull.useQuery( - { id: pageId }, - { enabled: isVisible, staleTime: 5 * 60 * 1000 }, +/** + * Thumbnails are captured server-side after publish and served by the + * usercontent origin; one that has not been captured yet 404s, and the card + * shows a placeholder until the next list refetch. + */ +export function PageThumbnail({ src }: PageThumbnailProps) { + const [failed, setFailed] = useState<{ src: string; at: number } | null>( + null, ); - - const downloadUrl = pull.data?.downloadUrl; - const version = pull.data?.version ?? 0; - - const thumbnailEnabled = Boolean(downloadUrl) && version > 0; - - const thumbnail = useQuery({ - queryKey: ["page-thumbnail", pageId, version], - enabled: thumbnailEnabled, - staleTime: Number.POSITIVE_INFINITY, - queryFn: () => - loadThumbnailUrl(thumbnailCacheKey(pageId, version), async () => { - const response = await fetch(downloadUrl as string, { - cache: "force-cache", - }); - if (!response.ok) { - throw new Error(`Preview failed to load (${response.status})`); - } - return response.text(); - }), - }); - - const isLoading = - isVisible && (pull.isPending || (thumbnailEnabled && thumbnail.isPending)); + // A capture that has not happened yet 404s; the failure is remembered per + // src but expires, so a later list refetch retries instead of showing the + // placeholder forever under the same thumbnail URL. + const showImage = + src !== null && + !(failed && failed.src === src && Date.now() - failed.at < 60_000); return (
- {thumbnail.data && scale > 0 ? ( -