mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge branch 'custom_task_20260812' of https://github.com/Stirling-Tools/Stirling-PDF into custom_task_20260812
This commit is contained in:
@@ -68,7 +68,6 @@ project: &project
|
||||
frontend: &frontend
|
||||
- *ci
|
||||
- frontend/**
|
||||
- .github/workflows/testdriver.yml
|
||||
- testing/**
|
||||
- docker/**
|
||||
- scripts/translations/*.py
|
||||
|
||||
@@ -191,22 +191,19 @@ jobs:
|
||||
# untrusted tree gets built below - never leave credentials in .git/config
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-deploy-pr-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: ./.github/actions/setup-task
|
||||
|
||||
@@ -7,10 +7,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets
|
||||
CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
environment: pr-preview
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
name: AI - PR Title Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited]
|
||||
branches: [main]
|
||||
|
||||
permissions: # required for secure-repo hardening
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ai-title-review:
|
||||
# GITHUB_TOKEN obeys this block, so it must cover every API call made below.
|
||||
permissions:
|
||||
contents: read # actions/checkout, git fetch/diff
|
||||
issues: write # issues.listComments / createComment / updateComment on the PR
|
||||
pull-requests: write # same endpoints when the target is a pull request
|
||||
models: read # actions/ai-inference
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git to suppress detached HEAD warning
|
||||
run: git config --global advice.detachedHead false
|
||||
|
||||
- name: Check if actor is repo developer
|
||||
id: actor
|
||||
run: |
|
||||
if [[ "${{ github.actor }}" == *"[bot]" ]]; then
|
||||
echo "PR opened by a bot – skipping AI title review."
|
||||
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f .github/config/repo_devs.json ]; then
|
||||
echo "Error: .github/config/repo_devs.json not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Validate JSON and extract repo_devs
|
||||
REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; })
|
||||
# Convert developer list into Bash array
|
||||
mapfile -t DEVS_ARRAY <<< "$REPO_DEVS"
|
||||
if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then
|
||||
echo "is_repo_dev=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Get PR diff
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: get_diff
|
||||
run: |
|
||||
git fetch origin ${{ github.base_ref }}
|
||||
git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff
|
||||
echo "diff<<EOF" >> $GITHUB_OUTPUT
|
||||
cat pr.diff >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check and sanitize PR title
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: sanitize_pr_title
|
||||
env:
|
||||
PR_TITLE_RAW: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
# Sanitize PR title: max 72 characters, only printable characters
|
||||
PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g')
|
||||
if [[ ${#PR_TITLE} -lt 5 ]]; then
|
||||
echo "PR title is too short. Must be at least 5 characters." >&2
|
||||
fi
|
||||
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: AI PR Title Analysis
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: ai-title-analysis
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt-file: ".github/config/system-prompt.txt"
|
||||
prompt: |
|
||||
Based on the following input data:
|
||||
|
||||
{
|
||||
"diff": "${{ steps.get_diff.outputs.diff }}",
|
||||
"pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}"
|
||||
}
|
||||
|
||||
Respond ONLY with valid JSON in the format:
|
||||
{
|
||||
"improved_rating": <0-10>,
|
||||
"improved_ai_title_rating": <0-10>,
|
||||
"improved_title": "<ai generated title>"
|
||||
}
|
||||
|
||||
- name: Validate and set SCRIPT_OUTPUT
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
run: |
|
||||
cat <<EOF > ai_response.json
|
||||
${{ steps.ai-title-analysis.outputs.response }}
|
||||
EOF
|
||||
|
||||
# Validate JSON structure
|
||||
jq -e '
|
||||
(keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and
|
||||
(.improved_rating | type == "number" and . >= 0 and . <= 10) and
|
||||
(.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and
|
||||
(.improved_title | type == "string")
|
||||
' ai_response.json
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Invalid AI response format" >&2
|
||||
cat ai_response.json >&2
|
||||
exit 1
|
||||
fi
|
||||
# Parse JSON fields
|
||||
IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json)
|
||||
IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json)
|
||||
# Limit comment length to 1000 characters
|
||||
COMMENT=$(cat <<EOF
|
||||
## 🤖 AI PR Title Suggestion
|
||||
|
||||
**PR-Title Rating**: $IMPROVED_RATING/10
|
||||
|
||||
### ⬇️ Suggested Title (copy & paste):
|
||||
|
||||
\`\`\`
|
||||
$IMPROVED_TITLE
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
*Generated by GitHub Models AI*
|
||||
EOF
|
||||
)
|
||||
echo "$COMMENT" > /tmp/ai-title-comment.md
|
||||
# Log input and output to the GitHub Step Summary
|
||||
echo "### 🤖 AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```bash' >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY
|
||||
echo '```json' >> $GITHUB_STEP_SUMMARY
|
||||
cat ai_response.json >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Post comment on PR if needed
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8');
|
||||
const { GITHUB_REPOSITORY } = process.env;
|
||||
const [owner, repo] = GITHUB_REPOSITORY.split('/');
|
||||
const issue_number = context.issue.number;
|
||||
|
||||
const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/);
|
||||
const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null;
|
||||
|
||||
const expectedActor = "github-actions[bot]";
|
||||
const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
|
||||
|
||||
const existing = comments.data.find(c =>
|
||||
c.user?.login === expectedActor &&
|
||||
c.body.includes("## 🤖 AI PR Title Suggestion")
|
||||
);
|
||||
|
||||
if (rating === null) {
|
||||
console.log("No rating found in AI response – skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (rating <= 5) {
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo,
|
||||
comment_id: existing.id,
|
||||
body
|
||||
});
|
||||
console.log("Updated existing suggestion comment.");
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number,
|
||||
body
|
||||
});
|
||||
console.log("Created new suggestion comment.");
|
||||
}
|
||||
} else {
|
||||
const praise = `## 🤖 AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\n✅ No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`;
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo,
|
||||
comment_id: existing.id,
|
||||
body: praise
|
||||
});
|
||||
console.log("Replaced suggestion with praise.");
|
||||
} else {
|
||||
console.log("Rating > 5 and no existing comment – skipping comment.");
|
||||
}
|
||||
}
|
||||
|
||||
- name: is not repo dev
|
||||
if: steps.actor.outputs.is_repo_dev != 'true'
|
||||
run: |
|
||||
exit 0 # Skip the AI title review for non-repo developers
|
||||
|
||||
- name: Clean up
|
||||
if: always()
|
||||
run: |
|
||||
rm -f pr.diff ai_response.json /tmp/ai-title-comment.md
|
||||
echo "Cleaned up temporary files."
|
||||
continue-on-error: true # Ensure cleanup runs even if previous steps fail
|
||||
@@ -20,6 +20,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
environment: ci-unsigned
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -34,23 +35,20 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK ${{ matrix.jdk-version }}
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: ./.github/actions/setup-task
|
||||
- name: Check Java formatting (Spotless)
|
||||
@@ -155,7 +153,7 @@ jobs:
|
||||
STIRLING_FLAVOR: ${{ matrix.flavor }}
|
||||
# Configure the Gradle daemon explicitly; GRADLE_OPTS alone only
|
||||
# configures the Gradle client JVM.
|
||||
GRADLE_OPTS: '-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC'
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC"
|
||||
|
||||
- name: Check Test Reports Exist
|
||||
if: always()
|
||||
|
||||
@@ -15,6 +15,11 @@ name: Enterprise E2E (Playwright)
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
use_shared_cache:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
@@ -37,6 +42,7 @@ jobs:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
playwright-e2e-enterprise:
|
||||
environment: ci-unsigned
|
||||
needs: pick
|
||||
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
|
||||
# so the suite can't boot premium and would fail. See the header comment.
|
||||
@@ -55,21 +61,31 @@ jobs:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
if: inputs.use_shared_cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Cache Gradle User Home
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Restore cache Gradle
|
||||
if: inputs.use_shared_cache == false
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-playwright-e2e-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -309,6 +325,7 @@ jobs:
|
||||
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
|
||||
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
|
||||
multinode-e2e:
|
||||
environment: ci-unsigned
|
||||
needs: [pick, playwright-e2e-enterprise]
|
||||
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
|
||||
if: >-
|
||||
|
||||
+46
-14
@@ -61,6 +61,7 @@ jobs:
|
||||
filters: .github/config/.files.yaml
|
||||
|
||||
gradle-cache-prime:
|
||||
environment: ci-unsigned
|
||||
name: Prime shared Gradle cache
|
||||
needs: [files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -72,29 +73,48 @@ jobs:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
|
||||
- name: Calculate Gradle cache key
|
||||
id: gradle-cache-key
|
||||
shell: bash
|
||||
run: |
|
||||
echo "key=gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Gradle (lookup-only)
|
||||
id: cache-gradle-restore
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: ${{ steps.gradle-cache-key.outputs.key }}
|
||||
lookup-only: true
|
||||
|
||||
- name: Set up JDK 25
|
||||
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Resolve backend dependencies
|
||||
run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon
|
||||
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
|
||||
run: ./gradlew :stirling-pdf:classes --no-daemon
|
||||
env:
|
||||
STIRLING_FLAVOR: saas
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Save cache Gradle User Home
|
||||
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ steps.gradle-cache-key.outputs.key }}
|
||||
|
||||
build:
|
||||
needs: [files-changed, gradle-cache-prime]
|
||||
permissions:
|
||||
@@ -169,6 +189,8 @@ jobs:
|
||||
contents: read
|
||||
uses: ./.github/workflows/build-enterprise.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
use_shared_cache: true
|
||||
|
||||
check-licence:
|
||||
if: needs.files-changed.outputs.build == 'true'
|
||||
@@ -192,7 +214,14 @@ jobs:
|
||||
|
||||
test-build-docker-images:
|
||||
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
|
||||
needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime]
|
||||
needs:
|
||||
[
|
||||
files-changed,
|
||||
build,
|
||||
check-generateOpenApiDocs,
|
||||
check-licence,
|
||||
gradle-cache-prime,
|
||||
]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
@@ -204,7 +233,7 @@ jobs:
|
||||
|
||||
tauri-build:
|
||||
if: needs.files-changed.outputs.tauri == 'true'
|
||||
needs: [files-changed]
|
||||
needs: [files-changed, gradle-cache-prime]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -218,6 +247,7 @@ jobs:
|
||||
with:
|
||||
platform: windows-macos
|
||||
sign: true
|
||||
use_shared_cache: true
|
||||
|
||||
ai-engine:
|
||||
if: needs.files-changed.outputs.engine == 'true'
|
||||
@@ -241,6 +271,8 @@ jobs:
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/check-generated-models.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
use_shared_cache: true
|
||||
|
||||
pre-commit:
|
||||
needs: [files-changed]
|
||||
|
||||
@@ -9,6 +9,11 @@ name: Check generated models
|
||||
# post-merge safety net.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
use_shared_cache:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
@@ -39,22 +44,29 @@ jobs:
|
||||
engine/uv.lock
|
||||
cache-suffix: generated-models
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
- name: Restore cache Gradle User Home
|
||||
if: inputs.use_shared_cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Restore cache Gradle
|
||||
if: inputs.use_shared_cache == false
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-generated-models-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
||||
@@ -10,6 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-licence:
|
||||
environment: ci-unsigned
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -20,23 +21,20 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: ./.github/actions/setup-task
|
||||
- name: Check licenses for compatibility
|
||||
|
||||
@@ -11,6 +11,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-generate-openapi-docs:
|
||||
environment: ci-unsigned
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -21,23 +22,20 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: ./.github/actions/setup-task
|
||||
- name: Generate OpenAPI documentation
|
||||
|
||||
@@ -40,23 +40,20 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
|
||||
@@ -13,6 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
migration-test:
|
||||
environment: ci-unsigned
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -24,23 +25,20 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: 25
|
||||
distribution: temurin
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
# Keep the normal formatting path here so this smoke test exercises the
|
||||
# same Gradle configuration as the backend build.
|
||||
- name: Build Stirling-PDF JAR
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
name: Auto V2 Deploy on Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- V2
|
||||
- deploy-on-v2-commit
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy-v2-on-push:
|
||||
environment: pr-preview
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
concurrency:
|
||||
group: deploy-v2-push-V2
|
||||
cancel-in-progress: true
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Get commit hashes for frontend and backend
|
||||
id: commit-hashes
|
||||
run: |
|
||||
# Get last commit that touched the frontend folder, docker/frontend, or docker/compose
|
||||
FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$FRONTEND_HASH" ]; then
|
||||
FRONTEND_HASH="no-frontend-changes"
|
||||
fi
|
||||
|
||||
# Get last commit that touched backend code, docker/backend, or docker/compose
|
||||
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$BACKEND_HASH" ]; then
|
||||
BACKEND_HASH="no-backend-changes"
|
||||
fi
|
||||
|
||||
echo "Frontend hash: $FRONTEND_HASH"
|
||||
echo "Backend hash: $BACKEND_HASH"
|
||||
|
||||
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
|
||||
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Short hashes for tags
|
||||
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
|
||||
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
|
||||
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Check if frontend image exists
|
||||
id: check-frontend
|
||||
run: |
|
||||
if docker manifest inspect ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Frontend image already exists, skipping build"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
echo "Frontend image needs to be built"
|
||||
fi
|
||||
|
||||
env:
|
||||
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
|
||||
- name: Check if backend image exists
|
||||
id: check-backend
|
||||
run: |
|
||||
if docker manifest inspect ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Backend image already exists, skipping build"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
echo "Backend image needs to be built"
|
||||
fi
|
||||
|
||||
env:
|
||||
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
|
||||
|
||||
- name: Build and push frontend image
|
||||
if: steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-v2-frontend
|
||||
cache-to: type=gha,mode=max,scope=stirling-v2-frontend
|
||||
tags: |
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push backend image
|
||||
if: steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-v2-backend
|
||||
cache-to: type=gha,mode=max,scope=stirling-v2-backend
|
||||
tags: |
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${NEW_VPS_SSH_KEY}" > ../private.key
|
||||
chmod 600 ../private.key
|
||||
|
||||
env:
|
||||
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
|
||||
- name: Deploy to VPS on port 3000
|
||||
run: |
|
||||
export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
|
||||
|
||||
cat > $UNIQUE_NAME << EOF
|
||||
version: '3.3'
|
||||
services:
|
||||
backend:
|
||||
container_name: stirling-v2-backend
|
||||
image: ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
ports:
|
||||
- "13000:8080"
|
||||
volumes:
|
||||
- /stirling/V2/data:/usr/share/tessdata:rw
|
||||
- /stirling/V2/config:/configs:rw
|
||||
- /stirling/V2/logs:/logs:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "true"
|
||||
SECURITY_ENABLELOGIN: "false"
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: "Stirling-PDF V2"
|
||||
UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split"
|
||||
UI_APPNAMENAVBAR: "V2 Deployment"
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "false"
|
||||
SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
|
||||
baseUrl: "https://demo.stirlingpdf.cloud"
|
||||
restart: on-failure:5
|
||||
|
||||
frontend:
|
||||
container_name: stirling-v2-frontend
|
||||
image: ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
ports:
|
||||
- "3000:80"
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://${NEW_VPS_HOST}:13000"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
# Copy to remote with unique name
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/$UNIQUE_NAME
|
||||
|
||||
# SSH and rename/move atomically to avoid interference
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH
|
||||
mkdir -p /stirling/V2/{data,config,logs}
|
||||
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
|
||||
cd /stirling/V2
|
||||
docker-compose down || true
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
docker system prune -af --volumes || true
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
|
||||
ENDSSH
|
||||
|
||||
env:
|
||||
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
|
||||
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
|
||||
- name: Cleanup temporary files
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ../private.key
|
||||
@@ -17,6 +17,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
docker-compose-tests:
|
||||
environment: ci-unsigned
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
@@ -32,23 +33,20 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
# When the PR changes the base image, test.sh builds it locally
|
||||
# (stirling-pdf-base:local) into the daemon image store. A buildx
|
||||
# container builder can't see that store, so skip it here and let
|
||||
|
||||
@@ -11,6 +11,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
playwright-e2e-live:
|
||||
environment: ci-unsigned
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -20,39 +21,21 @@ jobs:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
# Gradle does not retry 429s, and a cold cache resolving the buildscript
|
||||
# classpath is exactly where Maven Central rate-limits us. Retry it here,
|
||||
# where a failure is cheap, instead of inside the backgrounded bootRun.
|
||||
- name: Prime Gradle dependencies
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if ./gradlew --quiet -PnoSpotless :stirling-pdf:classes; then
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::Gradle dependency resolution failed (attempt $attempt of 3)"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
echo "::error::Gradle could not resolve dependencies after 3 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -42,6 +42,8 @@ jobs:
|
||||
filters: .github/config/.files.yaml
|
||||
|
||||
generate-frontend-license-report:
|
||||
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
|
||||
environment: ci-bot
|
||||
if: needs.files-changed.outputs.licenses-frontend == 'true'
|
||||
name: Generate Frontend License Report
|
||||
needs: files-changed
|
||||
@@ -316,6 +318,8 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
generate-backend-license-report:
|
||||
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
|
||||
environment: ci-bot
|
||||
if: needs.files-changed.outputs.licenses-backend == 'true'
|
||||
needs: files-changed
|
||||
name: Generate Backend License Report
|
||||
@@ -344,22 +348,19 @@ jobs:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-license-report-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: ./.github/actions/setup-task
|
||||
|
||||
@@ -38,6 +38,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
environment: ci-unsigned
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -51,22 +52,19 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: ./.github/actions/setup-task
|
||||
@@ -118,6 +116,7 @@ jobs:
|
||||
env:
|
||||
INPUT_PLATFORM: ${{ github.event.inputs.platform }}
|
||||
build-jars:
|
||||
environment: ci-unsigned
|
||||
needs: determine-matrix
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -143,22 +142,19 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
@@ -204,7 +200,6 @@ jobs:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -237,6 +232,14 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
# x86_64 JDK is set up first so the aarch64 step below can leave its
|
||||
# JAVA_HOME as the active one. The macOS universal JRE build needs
|
||||
# jmods from both arches; the x64 path is captured into the env
|
||||
@@ -260,17 +263,6 @@ jobs:
|
||||
java-version: "25"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: ./.github/actions/setup-task
|
||||
|
||||
@@ -295,7 +287,7 @@ jobs:
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
@@ -305,7 +297,7 @@ jobs:
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
@@ -344,40 +336,8 @@ jobs:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -398,7 +358,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -414,7 +374,7 @@ jobs:
|
||||
# Without this, signCommand failures are opaque (Tauri captures but drops
|
||||
# smctl's stderr) - running these loudly surfaces auth/env/keypair issues.
|
||||
- name: Preflight smctl
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
|
||||
shell: pwsh
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -445,7 +405,7 @@ jobs:
|
||||
# smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD
|
||||
# from env (set by prior DigiCert setup step). No --config-file needed.
|
||||
- name: Configure Windows code signing
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
|
||||
shell: bash
|
||||
env:
|
||||
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
@@ -466,7 +426,7 @@ jobs:
|
||||
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json
|
||||
|
||||
- name: Import release GPG signing key (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
|
||||
run: |
|
||||
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
|
||||
gpg --list-secret-keys --keyid-format=long
|
||||
@@ -498,8 +458,8 @@ jobs:
|
||||
# APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively
|
||||
# SIGN_KEY appimagetool picks the key matching this fingerprint
|
||||
# Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present.
|
||||
# Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or V2-master, when secret is present.
|
||||
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
|
||||
# Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or the release branch, when secret is present.
|
||||
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -525,7 +485,7 @@ jobs:
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
|
||||
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
|
||||
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
run: |
|
||||
@@ -564,7 +524,7 @@ jobs:
|
||||
echo "Stripped bundled libwayland from $(basename "$AI")"
|
||||
|
||||
- name: Clear release GPG key from runner keyring (Linux)
|
||||
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
|
||||
env:
|
||||
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
|
||||
run: |
|
||||
@@ -579,7 +539,7 @@ jobs:
|
||||
# artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw
|
||||
# cargo output unsigned, so checking it produces false negatives.
|
||||
- name: Verify Windows Code Signature
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
|
||||
timeout-minutes: 15
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -911,11 +871,11 @@ jobs:
|
||||
# workflow_dispatch path requires platform=='all' so a single-platform
|
||||
# dispatch can't overwrite an existing release's full latest.json with a
|
||||
# partial one (action-gh-release defaults overwrite_files:true).
|
||||
# release / V2-master always build the full matrix so no extra guard needed.
|
||||
# release event / release branch always build the full matrix so no extra guard needed.
|
||||
# fail_on_unmatched_files makes a missing latest.json or installer fail loudly
|
||||
# instead of silently shipping a broken auto-update.
|
||||
- name: Upload binaries to Release
|
||||
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
|
||||
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/release'
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
|
||||
@@ -127,6 +127,7 @@ jobs:
|
||||
# Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run
|
||||
# of every other feature.
|
||||
cucumber-nightly:
|
||||
environment: ci-unsigned
|
||||
name: Cucumber (nightly scenarios + full concurrency)
|
||||
runs-on: ubuntu-latest
|
||||
# Fork pull requests get no MAVEN_* secrets, so the image build cannot work.
|
||||
|
||||
@@ -17,6 +17,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
push-base:
|
||||
# Own environment: docker-publish is branch-locked to release/main,
|
||||
# which excludes the baseDockerImage/accessIssueFix branches this runs on.
|
||||
environment: docker-base-publish
|
||||
if: ${{ vars.CI_PROFILE != 'lite' && github.actor == 'Frooodle' }}
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
|
||||
@@ -20,9 +20,8 @@ on:
|
||||
default: false
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- release
|
||||
- main
|
||||
- V2-master
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
@@ -59,22 +58,19 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-push-docker-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
@@ -91,13 +87,13 @@ jobs:
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: github.ref == 'refs/heads/release'
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: github.ref == 'refs/heads/release'
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
@@ -133,8 +129,8 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/release' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/release' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
@@ -158,7 +154,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-latest.outputs.digest != ''
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-latest.outputs.digest != ''
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
@@ -182,8 +178,8 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/release' }}
|
||||
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/release' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
@@ -204,7 +200,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-fat.outputs.digest != ''
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-fat.outputs.digest != ''
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
@@ -226,8 +222,8 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
|
||||
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
@@ -248,7 +244,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-lite.outputs.digest != ''
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-lite.outputs.digest != ''
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
@@ -260,7 +256,7 @@ jobs:
|
||||
done
|
||||
|
||||
# Standalone unoserver image — versioned independently via
|
||||
# docker/unoserver/VERSION. master/V2-master: publish <version>+latest
|
||||
# docker/unoserver/VERSION. release: publish <version>+latest
|
||||
# only when the version is new. main/testMain: republish :alpha only
|
||||
# when the source hash differs from the published image's annotation.
|
||||
- name: Read unoserver image version
|
||||
@@ -319,7 +315,7 @@ jobs:
|
||||
fi
|
||||
|
||||
case "$EFFECTIVE_REF" in
|
||||
refs/heads/master|refs/heads/V2-master)
|
||||
refs/heads/release)
|
||||
if [ "${FORCE_REBUILD}" = "true" ]; then
|
||||
echo "force_unoserver_rebuild=true — building stable regardless"
|
||||
mode="stable"
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- release
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
@@ -23,6 +23,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
# package-publish holds SWAGGERHUB_API_KEY. It requires reviewer approval and
|
||||
# is limited to main / release / v* tags, so every push to release waits on one.
|
||||
environment: package-publish
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -33,22 +36,19 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
key: gradle-swagger-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
|
||||
@@ -26,6 +26,10 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
use_shared_cache:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
@@ -57,6 +61,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
# Only probes APPLE_CERTIFICATE for presence, so it stays on the unrestricted
|
||||
# signing environment - release-signing would block every PR run.
|
||||
environment: ci-signing
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -103,6 +110,12 @@ jobs:
|
||||
echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
|
||||
|
||||
build:
|
||||
# Windows/GPG signing only runs on main (see the per-step gates below), so only
|
||||
# that path needs the reviewer-gated release-signing environment. Everything else
|
||||
# (PRs, merge queue, nightly) signs macOS only and uses ci-signing, which has no
|
||||
# approval or branch restriction.
|
||||
environment:
|
||||
name: ${{ (inputs.sign && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))) && 'release-signing' || 'ci-signing' }}
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -110,7 +123,6 @@ jobs:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
# Per-platform sign gate. macOS signs on any run with the cert available,
|
||||
@@ -160,6 +172,24 @@ jobs:
|
||||
# Save the dependency cache even if a later step fails
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
if: inputs.use_shared_cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Restore cache Gradle
|
||||
if: inputs.use_shared_cache == false
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-tauri-build-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
@@ -179,17 +209,6 @@ jobs:
|
||||
java-version: "25"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Setup Task
|
||||
uses: ./.github/actions/setup-task
|
||||
|
||||
@@ -264,38 +283,6 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
|
||||
env:
|
||||
|
||||
@@ -37,6 +37,7 @@ jobs:
|
||||
# spring-security=true matrix entry if `task backend:build` and
|
||||
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
|
||||
test-build-docker-images:
|
||||
environment: ci-unsigned
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -78,23 +79,20 @@ jobs:
|
||||
docker system prune -af || true
|
||||
echo "Disk space after cleanup:" && df -h
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install Task
|
||||
uses: ./.github/actions/setup-task
|
||||
- name: Build application
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
name: UI test with TestDriverAI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master", "UITest", "testdriver"]
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment: pr-preview
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle User Home
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
|
||||
gradle-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Build and push test image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-latest
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:test-${{ github.sha }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${NEW_VPS_SSH_KEY}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
env:
|
||||
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
|
||||
- name: Deploy to VPS
|
||||
run: |
|
||||
cat > docker-compose.yml << EOF
|
||||
version: '3.3'
|
||||
services:
|
||||
stirling-pdf:
|
||||
container_name: stirling-pdf-test-${{ github.sha }}
|
||||
image: ${IMAGE_BASE}:test-${{ github.sha }}
|
||||
ports:
|
||||
- "1337:8080"
|
||||
volumes:
|
||||
- /stirling/test-${{ github.sha }}/data:/usr/share/tessdata:rw
|
||||
- /stirling/test-${{ github.sha }}/config:/configs:rw
|
||||
- /stirling/test-${{ github.sha }}/logs:/logs:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "true"
|
||||
SECURITY_ENABLELOGIN: "false"
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: "Stirling-PDF Test"
|
||||
UI_HOMEDESCRIPTION: "Test Deployment"
|
||||
UI_APPNAMENAVBAR: "Test"
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "false"
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF
|
||||
mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
|
||||
mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
|
||||
cd /stirling/test-${{ github.sha }}
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
EOF
|
||||
|
||||
env:
|
||||
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
|
||||
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
|
||||
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
files-changed:
|
||||
if: always()
|
||||
name: detect what files changed
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
id: changes
|
||||
with:
|
||||
filters: ".github/config/.files.yaml"
|
||||
|
||||
test:
|
||||
environment: pr-preview
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: [deploy, files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Run TestDriver.ai
|
||||
uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3
|
||||
with:
|
||||
key: ${{secrets.TESTDRIVER_API_KEY}}
|
||||
prerun: |
|
||||
choco install go-task -y
|
||||
task frontend:build
|
||||
cd frontend
|
||||
npm install dashcam-chrome --save
|
||||
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337"
|
||||
Start-Sleep -Seconds 20
|
||||
prompt: |
|
||||
1. /run testing/testdriver/test.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FORCE_COLOR: "3"
|
||||
|
||||
cleanup:
|
||||
environment: pr-preview
|
||||
needs: [deploy, test]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${NEW_VPS_SSH_KEY}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
env:
|
||||
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
|
||||
- name: Cleanup deployment
|
||||
if: always()
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF
|
||||
cd /stirling/test-${{ github.sha }}
|
||||
docker-compose down
|
||||
cd /stirling
|
||||
rm -rf test-${{ github.sha }}
|
||||
EOF
|
||||
env:
|
||||
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
|
||||
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
continue-on-error: true # Ensure cleanup runs even if previous steps fail
|
||||
+33
-38
@@ -4,6 +4,11 @@ This guide explains how to set up Windows code signing for Stirling-PDF desktop
|
||||
|
||||
## Overview
|
||||
|
||||
Releases are signed with **DigiCert KeyLocker**, a cloud HSM: the private key never
|
||||
leaves DigiCert, and the runner signs through a PKCS#11 provider. The older approach
|
||||
of uploading a base64 `.pfx` to a repository secret has been removed from the
|
||||
workflows - the sections below describe KeyLocker, which is what actually runs.
|
||||
|
||||
Windows code signing is essential for:
|
||||
- Preventing Windows SmartScreen warnings
|
||||
- Building trust with users
|
||||
@@ -49,29 +54,19 @@ openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certifica
|
||||
|
||||
### Required Secrets
|
||||
|
||||
Navigate to your GitHub repository → Settings → Secrets and variables → Actions
|
||||
Navigate to your GitHub repository → Settings → Environments → `release-signing`.
|
||||
|
||||
Add the following secrets:
|
||||
These live in the `release-signing` environment, not at repository scope. That
|
||||
environment requires reviewer approval and is limited to `main`, `release`,
|
||||
`hotfix/*` and `v*` tags. All five come from the DigiCert ONE console.
|
||||
|
||||
#### 1. `WINDOWS_CERTIFICATE`
|
||||
- **Description**: Base64-encoded .pfx certificate file
|
||||
- **How to create**:
|
||||
|
||||
**On macOS/Linux:**
|
||||
```bash
|
||||
base64 -i certificate.pfx | pbcopy # Copies to clipboard
|
||||
```
|
||||
|
||||
**On Windows (PowerShell):**
|
||||
```powershell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
|
||||
```
|
||||
|
||||
Paste the entire base64 string into the GitHub secret.
|
||||
|
||||
#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
|
||||
- **Description**: Password for the .pfx certificate
|
||||
- **Value**: The password you set when creating/exporting the .pfx file
|
||||
| Secret | Description |
|
||||
| --- | --- |
|
||||
| `SM_API_KEY` | KeyLocker API key. Also acts as the on/off switch: signing steps are gated on it being non-empty. |
|
||||
| `SM_CLIENT_CERT_FILE_B64` | Base64-encoded PKCS#12 client authentication certificate. |
|
||||
| `SM_CLIENT_CERT_PASSWORD` | Password for that client certificate. |
|
||||
| `SM_KEYPAIR_ALIAS` | Alias of the signing keypair to use. |
|
||||
| `SM_HOST` | DigiCert ONE host, e.g. `https://clientauth.one.digicert.com`. |
|
||||
|
||||
### Optional Secrets for Tauri Updater
|
||||
|
||||
@@ -110,23 +105,23 @@ The Windows signing configuration is already set up:
|
||||
|
||||
### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
|
||||
|
||||
The workflow includes three Windows signing steps:
|
||||
The workflow includes four Windows signing steps, all gated on `SM_API_KEY` being
|
||||
set and the ref being the release branch:
|
||||
|
||||
1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
|
||||
2. **Build Tauri App**: Builds and signs the application using the imported certificate
|
||||
3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
|
||||
1. **Setup DigiCert KeyLocker**: Installs the DigiCert signing tools via `digicert/ssm-code-signing`
|
||||
2. **Setup DigiCert KeyLocker Certificate**: Writes the client cert and exports the PKCS#11 config
|
||||
3. **Configure Windows code signing / Build Tauri app**: Signs through the PKCS#11 provider
|
||||
4. **Verify Windows Code Signature**: Validates that the .exe and .msi are properly signed
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
### 1. Local Testing (Windows Only)
|
||||
|
||||
Before pushing to GitHub, test locally:
|
||||
KeyLocker is CI-only. To check signing locally, install your own certificate into
|
||||
the Windows store and point Tauri at it; the build no longer reads any certificate
|
||||
from an environment variable.
|
||||
|
||||
```powershell
|
||||
# Set environment variables
|
||||
$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
|
||||
$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
|
||||
|
||||
# Build the application
|
||||
cd frontend
|
||||
npm run tauri build
|
||||
@@ -191,9 +186,10 @@ Look for:
|
||||
- Consider EV certificate for immediate reputation
|
||||
|
||||
### Certificate Not Found During Build
|
||||
- Verify `WINDOWS_CERTIFICATE` secret is set
|
||||
- Check base64 encoding is correct (no extra whitespace)
|
||||
- Ensure password is correct
|
||||
- Verify `SM_API_KEY` is present in the `release-signing` environment. If it is empty
|
||||
the signing steps skip silently and the build succeeds unsigned.
|
||||
- Check `SM_CLIENT_CERT_FILE_B64` base64 encoding is correct (no extra whitespace)
|
||||
- Ensure `SM_CLIENT_CERT_PASSWORD` and `SM_KEYPAIR_ALIAS` match the DigiCert keypair
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
@@ -220,11 +216,10 @@ Look for:
|
||||
## Certificate Lifecycle
|
||||
|
||||
### Before Expiration
|
||||
1. Obtain new certificate from CA (typically annual renewal)
|
||||
2. Convert to .pfx format if needed
|
||||
3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
|
||||
4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
|
||||
5. Test build to verify new certificate works
|
||||
1. Renew the certificate in the DigiCert ONE console (typically annual)
|
||||
2. If the keypair alias changed, update `SM_KEYPAIR_ALIAS` in the `release-signing` environment
|
||||
3. If the client authentication certificate was reissued, update `SM_CLIENT_CERT_FILE_B64` and `SM_CLIENT_CERT_PASSWORD`
|
||||
4. Test build to verify the new certificate works
|
||||
|
||||
### Expired Certificates
|
||||
- Signed binaries remain valid (timestamp proves signing time)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import org.hibernate.boot.model.relational.Namespace;
|
||||
import org.hibernate.boot.model.relational.Sequence;
|
||||
import org.hibernate.mapping.Table;
|
||||
import org.hibernate.tool.schema.spi.SchemaFilter;
|
||||
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
|
||||
|
||||
/**
|
||||
* Hides the migration-owned tables from Hibernate's schema management.
|
||||
*
|
||||
* <p>Wired on the SaaS profile only, via {@code hibernate.hbm2ddl.schema_filter_provider}.
|
||||
* Self-hosted is untouched: there Hibernate rightly owns everything.
|
||||
*
|
||||
* <p>Why a filter rather than simply turning {@code ddl-auto} off: the SaaS database has two
|
||||
* writers. The Supabase migrations own the SaaS tables, and Hibernate owns roughly thirty tables
|
||||
* inherited from the self-hosted app that no migration has ever created. Turn {@code ddl-auto} off
|
||||
* and a fresh preview branch is missing that second half; leave it on and Hibernate is free to
|
||||
* reconcile migration-owned tables, which is how {@code team_memberships.role} ended up widened to
|
||||
* varchar(255) and needed a migration to put back. A filter keeps the first half working and makes
|
||||
* the second impossible.
|
||||
*
|
||||
* <p>Note that this is a per-table filter, not a per-schema one. Hibernate's schema management runs
|
||||
* over every mapped entity regardless of namespace, so moving SaaS tables to their own schema would
|
||||
* not by itself keep Hibernate out of them. {@link SaasSchemaOwnership} is the register; this class
|
||||
* only applies it.
|
||||
*
|
||||
* <p><b>Foreign keys still cross the line, on purpose.</b> Several inherited tables reference
|
||||
* migration-owned ones — {@code folders}, {@code stored_files} and {@code file_shares} all point at
|
||||
* {@code users}/{@code teams}. Hibernate's {@code SchemaCreatorImpl.createForeignKeys} and {@code
|
||||
* AbstractSchemaMigrator.applyForeignKeys} check {@code includeTable} against the *owning* table
|
||||
* only and then emit every foreign key on it, without consulting the referenced table. So excluding
|
||||
* {@code users} does not cost the branch its referential integrity, and a branch ends up matching
|
||||
* staging. It does mean the referenced tables have to exist by the time Hibernate runs, which holds
|
||||
* because a Supabase branch applies its migrations at build time and the app connects afterwards.
|
||||
*
|
||||
* <p><b>Known gap: this cannot detect drift.</b> Filtering means Hibernate never inspects these
|
||||
* tables, and {@link #getValidateFilter()} extends that to {@code validate}, so nothing here
|
||||
* compares a migration-owned table against its entity. Combined with the register being a
|
||||
* hand-maintained list of another repo's contents (see {@link SaasSchemaOwnership}), there is
|
||||
* currently no automated signal when the register, the entities and the database disagree. That is
|
||||
* a deliberate trade for a boot that does not fail on differences we accept, not a claim that drift
|
||||
* cannot happen; a non-fatal drift report is the missing piece and belongs outside this class.
|
||||
*/
|
||||
public class MigrationOwnedSchemaFilter implements SchemaFilterProvider, SchemaFilter {
|
||||
|
||||
/**
|
||||
* The one decision this class makes. Everything Hibernate might do to a table it does not own —
|
||||
* create, alter, drop, truncate — is refused.
|
||||
*/
|
||||
@Override
|
||||
public boolean includeTable(Table table) {
|
||||
return !SaasSchemaOwnership.isMigrationOwned(table.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespaces are never filtered. The inherited tables and the migration-owned ones share {@code
|
||||
* stirling_pdf}, so excluding the namespace would take both with it.
|
||||
*/
|
||||
@Override
|
||||
public boolean includeNamespace(Namespace namespace) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequences are left alone. Every id here is an identity column rather than a mapped generator,
|
||||
* so there is nothing for Hibernate to create; filtering them would be dead code pretending to
|
||||
* be a safeguard.
|
||||
*/
|
||||
@Override
|
||||
public boolean includeSequence(Sequence sequence) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaFilter getCreateFilter() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaFilter getMigrateFilter() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaFilter getDropFilter() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaFilter getTruncatorFilter() {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation is filtered too, which is the one debatable call here.
|
||||
*
|
||||
* <p>Letting it through would give a useful signal when a migration-owned table drifts from its
|
||||
* entity. But {@code ddl-auto=validate} fails startup, and it would fail on differences we have
|
||||
* deliberately accepted — {@code ai_create_sessions} carries columns from a reverted feature
|
||||
* that nothing maps, for instance. A boot failure over a table we have chosen not to manage is
|
||||
* noise, so the rule stays uniform: Hibernate does not concern itself with these tables at all.
|
||||
*/
|
||||
@Override
|
||||
public SchemaFilter getValidateFilter() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which side owns each table in the SaaS database.
|
||||
*
|
||||
* <p>The SaaS schema has two writers and always has: the Supabase migrations in the
|
||||
* Stirling-PDF-SaaS repo, and Hibernate's {@code ddl-auto}. That was a convention rather than a
|
||||
* rule, and it leaked twice. An older {@code ddl-auto} run widened {@code team_memberships.role} to
|
||||
* varchar(255), which needed a dedicated migration to repair because RLS policies depended on the
|
||||
* column. Separately {@code payg_instance_usage} went months with an entity and no migration, so it
|
||||
* simply did not exist on a fresh preview branch.
|
||||
*
|
||||
* <p>This class makes the boundary explicit and {@code SaasSchemaOwnershipTest} makes it binding:
|
||||
* every {@code @Entity} the SaaS app maps must appear in exactly one of these two sets. A new
|
||||
* entity fails the build until someone states who owns its table, which is the decision that was
|
||||
* previously made by accident.
|
||||
*
|
||||
* <p>{@link MigrationOwnedSchemaFilter} enforces it at runtime: Hibernate is never shown the
|
||||
* migration-owned tables, so it cannot create, alter or drop them whatever {@code ddl-auto} says.
|
||||
* Inherited tables stay under Hibernate, so a preview branch built from migrations alone still
|
||||
* heals itself on first boot.
|
||||
*
|
||||
* <p><b>What this does not catch.</b> The register is a hand-maintained copy of what lives in
|
||||
* another repository, and only one direction is enforced. The test fails when a *new* entity
|
||||
* appears with no owner. It cannot notice a table changing sides: write a migration for {@code
|
||||
* folders} in Stirling-PDF-SaaS and nothing here changes, the test still passes, and Hibernate
|
||||
* carries on managing a table the migrations now own — which is precisely how {@code
|
||||
* team_memberships.role} got widened. Adding a migration for anything in {@link #HIBERNATE_MANAGED}
|
||||
* therefore means moving it to {@link #MIGRATION_OWNED} in the same change; nothing will remind
|
||||
* you. Making that structural rather than remembered is what moving the SaaS tables into their own
|
||||
* schema would buy, and is the reason this class is a stepping stone rather than the answer.
|
||||
*/
|
||||
public final class SaasSchemaOwnership {
|
||||
|
||||
/**
|
||||
* Created and altered by the Supabase migrations. Hibernate must not touch these: the
|
||||
* migrations carry constraints, defaults and RLS policies it knows nothing about and would
|
||||
* reconcile away.
|
||||
*/
|
||||
public static final Set<String> MIGRATION_OWNED =
|
||||
Set.of(
|
||||
"ai_create_sessions",
|
||||
"audit_events",
|
||||
"authorities",
|
||||
"billing_subscriptions",
|
||||
"job_artifact_hash",
|
||||
"legal_consent",
|
||||
"linked_instance",
|
||||
"payg_instance_usage",
|
||||
"payg_meter_event_log",
|
||||
"payg_prepaid_bundle",
|
||||
"payg_shadow_charge",
|
||||
"payg_team_extensions",
|
||||
"persistent_logins",
|
||||
"pricing_policy",
|
||||
"processing_job",
|
||||
"processing_job_step",
|
||||
"procurement_agreement_signature",
|
||||
"procurement_deal",
|
||||
"procurement_quote",
|
||||
"saas_team_extensions",
|
||||
"saas_user_extensions",
|
||||
"sessions",
|
||||
"team_invitations",
|
||||
"team_memberships",
|
||||
"teams",
|
||||
"users",
|
||||
"wallet_entitlement_snapshot",
|
||||
"wallet_ledger",
|
||||
"wallet_policy");
|
||||
|
||||
/**
|
||||
* Inherited from the self-hosted app, where {@code ddl-auto} owns the schema and no Supabase
|
||||
* migration exists. Deliberately left under Hibernate so a fresh branch gets them on first
|
||||
* boot.
|
||||
*/
|
||||
public static final Set<String> HIBERNATE_MANAGED =
|
||||
Set.of(
|
||||
"account_link_device_credential",
|
||||
"account_link_metered_signature",
|
||||
"account_link_sync_state",
|
||||
"account_link_usage_counter",
|
||||
"api_key_daily_usage",
|
||||
"api_keys",
|
||||
"file_encryption_keys",
|
||||
"file_run_events",
|
||||
"file_share_accesses",
|
||||
"file_shares",
|
||||
"folders",
|
||||
"integration_configs",
|
||||
"invite_tokens",
|
||||
"jwt_signing_keys",
|
||||
"policies",
|
||||
"policy_assets",
|
||||
"policy_completed_migrations",
|
||||
"policy_processed_files",
|
||||
"policy_source_doc_counts",
|
||||
"policy_source_doc_totals",
|
||||
"policy_sources",
|
||||
"resource_grants",
|
||||
"storage_cleanup_entries",
|
||||
"stored_file_blobs",
|
||||
"stored_files",
|
||||
"user_license_settings",
|
||||
"user_server_certificates",
|
||||
"workflow_participants",
|
||||
"workflow_sessions");
|
||||
|
||||
private SaasSchemaOwnership() {}
|
||||
|
||||
/** Case-insensitive: Hibernate hands us whatever casing the mapping used. */
|
||||
public static boolean isMigrationOwned(String tableName) {
|
||||
return tableName != null && MIGRATION_OWNED.contains(tableName.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,13 @@ spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true
|
||||
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
|
||||
# ...but only over the tables Hibernate actually owns. The SaaS database has two writers: the
|
||||
# Supabase migrations own the SaaS tables, Hibernate owns ~30 inherited from the self-hosted app that
|
||||
# no migration has ever created. This filter hides the former from schema management, so ddl-auto can
|
||||
# still heal a fresh preview branch without being free to reconcile a migration-owned table — which
|
||||
# is how team_memberships.role ended up widened to varchar(255). Register: SaasSchemaOwnership.
|
||||
spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=stirling.software.saas.config.MigrationOwnedSchemaFilter
|
||||
|
||||
# ---------- Supabase JWT auth ----------
|
||||
# Required: set SAAS_DB_PROJECT_REF via env.
|
||||
app.supabase.project-ref=${SAAS_DB_PROJECT_REF:}
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
|
||||
import org.hibernate.mapping.Table;
|
||||
import org.hibernate.tool.schema.spi.SchemaFilter;
|
||||
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Covers {@link MigrationOwnedSchemaFilter} and, just as importantly, its wiring.
|
||||
*
|
||||
* <p>{@link SaasSchemaOwnershipTest} proves the register is complete; nothing proved the filter
|
||||
* applies it, or that Hibernate is even asking. A typo in the {@code
|
||||
* hibernate.hbm2ddl.schema_filter_provider} key, a stale fully-qualified name after a package move,
|
||||
* or a getter returning null would all leave every migration-owned table exposed to {@code
|
||||
* ddl-auto} with a fully green build. Hence the property assertion below, which is the only thing
|
||||
* here that would catch that.
|
||||
*/
|
||||
class MigrationOwnedSchemaFilterTest {
|
||||
|
||||
private static final String FILTER_PROPERTY =
|
||||
"spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider";
|
||||
|
||||
private final MigrationOwnedSchemaFilter filter = new MigrationOwnedSchemaFilter();
|
||||
|
||||
/** "orm" is Hibernate's own default contributor; the value is irrelevant to the filter. */
|
||||
private static Table table(String name) {
|
||||
return new Table("orm", name);
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrationOwnedTablesAreHiddenFromHibernate() {
|
||||
assertThat(filter.includeTable(table("teams"))).isFalse();
|
||||
assertThat(filter.includeTable(table("users"))).isFalse();
|
||||
assertThat(filter.includeTable(table("team_memberships"))).isFalse();
|
||||
assertThat(filter.includeTable(table("payg_instance_usage"))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void inheritedTablesStayUnderHibernate() {
|
||||
assertThat(filter.includeTable(table("folders"))).isTrue();
|
||||
assertThat(filter.includeTable(table("stored_files"))).isTrue();
|
||||
assertThat(filter.includeTable(table("api_keys"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownTableIsLeftToHibernate() {
|
||||
// Fail-open is the right default: an unrecognised table is either brand new or from a
|
||||
// module
|
||||
// we do not know about, and SaasSchemaOwnershipTest is what stops it staying unrecognised.
|
||||
assertThat(filter.includeTable(table("no_such_table"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void casingDoesNotDefeatTheFilter() {
|
||||
assertThat(filter.includeTable(table("TEAMS"))).isFalse();
|
||||
assertThat(filter.includeTable(table("Team_Memberships"))).isFalse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreign keys from an inherited table into a migration-owned one survive the filter.
|
||||
*
|
||||
* <p>Worth pinning, because it is not obvious and it decides whether a preview branch keeps
|
||||
* referential integrity. {@code folders}, {@code stored_files} and {@code file_shares} all
|
||||
* reference {@code users}/{@code teams}, which the filter excludes. Hibernate 7.2's {@code
|
||||
* SchemaCreatorImpl.createForeignKeys} (and {@code AbstractSchemaMigrator.applyForeignKeys})
|
||||
* tests {@code includeTable} against the *owning* table only, then emits every foreign key on
|
||||
* it; the referenced table is never consulted. So the constraints are still created and a
|
||||
* branch matches staging.
|
||||
*
|
||||
* <p>The one thing this depends on is ordering: the referenced tables have to exist first. They
|
||||
* do, because a Supabase branch runs its migrations at build time and the app connects after.
|
||||
*/
|
||||
@Test
|
||||
void foreignKeysIntoMigrationOwnedTablesAreStillEmitted() {
|
||||
assertThat(filter.includeTable(table("folders"))).isTrue();
|
||||
assertThat(filter.includeTable(table("file_shares"))).isTrue();
|
||||
assertThat(filter.includeTable(table("users"))).isFalse();
|
||||
assertThat(filter.includeTable(table("teams"))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void everySchemaActionGetsTheSameFilter() {
|
||||
assertThat(filter.getCreateFilter()).isSameAs(filter);
|
||||
assertThat(filter.getMigrateFilter()).isSameAs(filter);
|
||||
assertThat(filter.getDropFilter()).isSameAs(filter);
|
||||
assertThat(filter.getTruncatorFilter()).isSameAs(filter);
|
||||
assertThat(filter.getValidateFilter()).isSameAs(filter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void namespacesAndSequencesAreNeverFiltered() {
|
||||
// Both share the stirling_pdf namespace, so filtering it would take the inherited tables
|
||||
// with it. Neither argument is read, so nulls are fine and keep the test free of Hibernate
|
||||
// bootstrap machinery.
|
||||
assertThat(filter.includeNamespace(null)).isTrue();
|
||||
assertThat(filter.includeSequence(null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theFilterIsActuallyWiredIntoHibernate() throws Exception {
|
||||
Properties properties = new Properties();
|
||||
try (InputStream in = getClass().getResourceAsStream("/application-saas.properties")) {
|
||||
assertThat(in)
|
||||
.as("application-saas.properties must be on the test classpath to check wiring")
|
||||
.isNotNull();
|
||||
properties.load(in);
|
||||
}
|
||||
|
||||
String configured = properties.getProperty(FILTER_PROPERTY);
|
||||
assertThat(configured)
|
||||
.as(
|
||||
"%s is unset, so Hibernate installs its default filter and every"
|
||||
+ " migration-owned table is back under ddl-auto",
|
||||
FILTER_PROPERTY)
|
||||
.isNotBlank();
|
||||
|
||||
Class<?> wired = Class.forName(configured.trim());
|
||||
assertThat(SchemaFilterProvider.class)
|
||||
.as("Hibernate only accepts a SchemaFilterProvider here")
|
||||
.isAssignableFrom(wired);
|
||||
assertThat(SchemaFilter.class).isAssignableFrom(wired);
|
||||
assertThat(wired).isEqualTo(MigrationOwnedSchemaFilter.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.config.BeanDefinition;
|
||||
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
|
||||
import org.springframework.core.type.filter.AnnotationTypeFilter;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import stirling.software.proprietary.security.configuration.DatabaseConfig;
|
||||
|
||||
/**
|
||||
* Makes {@link SaasSchemaOwnership} binding rather than decorative.
|
||||
*
|
||||
* <p>Every {@code @Entity} the SaaS app maps has to be declared as owned by either the Supabase
|
||||
* migrations or Hibernate. Adding an entity without saying which fails here, at build time, instead
|
||||
* of months later on a preview branch that has no such table. That is not hypothetical: {@code
|
||||
* payg_instance_usage} shipped with an entity and no migration and went unnoticed until a branch
|
||||
* tried to use it.
|
||||
*
|
||||
* <p>"Maps" is meant precisely: the scan covers the packages named by the {@code @EntityScan}
|
||||
* declarations the app actually boots with, not everything under {@code stirling.software}. See
|
||||
* {@link #mappedPackages()}. Note this only enforces one direction — {@link SaasSchemaOwnership}
|
||||
* documents the drift it cannot see.
|
||||
*/
|
||||
class SaasSchemaOwnershipTest {
|
||||
|
||||
/**
|
||||
* The packages the running app actually maps, read off the two {@code @EntityScan} declarations
|
||||
* that define them rather than hardcoded.
|
||||
*
|
||||
* <p>Scanning all of {@code stirling.software} would be easier and wrong in a quiet way: it is
|
||||
* a superset, so it would force ownership declarations for entities Hibernate never sees and
|
||||
* let the register claim tables that do not exist as far as the SaaS app is concerned. Deriving
|
||||
* the list means this test measures the same set Hibernate does, and follows a package being
|
||||
* added or moved without anyone updating it here.
|
||||
*/
|
||||
private static Set<String> mappedPackages() {
|
||||
Set<String> packages = new TreeSet<>();
|
||||
for (Class<?> config : List.of(SaasJpaConfig.class, DatabaseConfig.class)) {
|
||||
EntityScan scan = config.getAnnotation(EntityScan.class);
|
||||
assertThat(scan)
|
||||
.as("%s must carry @EntityScan, or its entities are not mapped", config)
|
||||
.isNotNull();
|
||||
packages.addAll(Arrays.asList(scan.value()));
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
private static TreeMap<String, String> mappedTables() {
|
||||
ClassPathScanningCandidateComponentProvider scanner =
|
||||
new ClassPathScanningCandidateComponentProvider(false);
|
||||
scanner.addIncludeFilter(new AnnotationTypeFilter(Entity.class));
|
||||
TreeMap<String, String> byTable = new TreeMap<>();
|
||||
for (String basePackage : mappedPackages()) {
|
||||
for (BeanDefinition bd : scanner.findCandidateComponents(basePackage)) {
|
||||
String className = bd.getBeanClassName();
|
||||
Class<?> type;
|
||||
try {
|
||||
type =
|
||||
ClassUtils.forName(
|
||||
className, SaasSchemaOwnershipTest.class.getClassLoader());
|
||||
} catch (ClassNotFoundException | LinkageError e) {
|
||||
continue; // not on this module's runtime classpath; nothing to own
|
||||
}
|
||||
Table table = type.getAnnotation(Table.class);
|
||||
String name =
|
||||
table != null && !table.name().isBlank()
|
||||
? table.name()
|
||||
: camelToSnake(type.getSimpleName());
|
||||
byTable.put(name.toLowerCase(), className);
|
||||
}
|
||||
}
|
||||
return byTable;
|
||||
}
|
||||
|
||||
/** Mirrors Spring Boot's default CamelCaseToUnderscoresNamingStrategy for an unnamed @Table. */
|
||||
private static String camelToSnake(String name) {
|
||||
return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyEntityTableIsOwnedByExactlyOneSide() {
|
||||
TreeMap<String, String> mapped = mappedTables();
|
||||
assertThat(mapped)
|
||||
.as("entity scan found nothing, so this test proves nothing")
|
||||
.isNotEmpty();
|
||||
// The scan is derived from @EntityScan now, so a package quietly dropped from either
|
||||
// declaration would shrink it and weaken this test rather than fail it. These four straddle
|
||||
// the two declarations, so losing either side fails here instead of silently checking less.
|
||||
assertThat(mapped.keySet())
|
||||
.as("both @EntityScan declarations must have contributed to the scan")
|
||||
.contains("users", "teams", "payg_instance_usage", "folders");
|
||||
|
||||
Set<String> undeclared = new TreeSet<>();
|
||||
Set<String> both = new TreeSet<>();
|
||||
for (String table : mapped.keySet()) {
|
||||
boolean migration = SaasSchemaOwnership.MIGRATION_OWNED.contains(table);
|
||||
boolean hibernate = SaasSchemaOwnership.HIBERNATE_MANAGED.contains(table);
|
||||
if (migration && hibernate) both.add(table);
|
||||
if (!migration && !hibernate) undeclared.add(table);
|
||||
}
|
||||
|
||||
assertThat(undeclared)
|
||||
.as(
|
||||
"""
|
||||
These entity tables are not declared in SaasSchemaOwnership, so nobody owns \
|
||||
them. Decide and add each to exactly one set:
|
||||
- MIGRATION_OWNED: also add a migration in Stirling-PDF-SaaS, or the table \
|
||||
will not exist on a fresh preview branch.
|
||||
- HIBERNATE_MANAGED: only correct for a table inherited from the \
|
||||
self-hosted app that no Supabase migration creates.
|
||||
Offending tables -> entities: %s"""
|
||||
.formatted(
|
||||
undeclared.stream()
|
||||
.map(t -> t + " (" + mapped.get(t) + ")")
|
||||
.toList()))
|
||||
.isEmpty();
|
||||
|
||||
assertThat(both)
|
||||
.as("declared as owned by both sides, which is the one thing it cannot be")
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theTwoSetsDoNotOverlap() {
|
||||
Set<String> overlap = new TreeSet<>(SaasSchemaOwnership.MIGRATION_OWNED);
|
||||
overlap.retainAll(SaasSchemaOwnership.HIBERNATE_MANAGED);
|
||||
assertThat(overlap).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableNamesAreLowercaseSoLookupsCannotMiss() {
|
||||
// isMigrationOwned() lowercases its input; a capital in either set would be unreachable.
|
||||
assertThat(SaasSchemaOwnership.MIGRATION_OWNED)
|
||||
.allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase()));
|
||||
assertThat(SaasSchemaOwnership.HIBERNATE_MANAGED)
|
||||
.allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrationOwnedTablesIncludeTheOnesThatBitUs() {
|
||||
// team_memberships is the table an old ddl-auto run widened; payg_instance_usage is the one
|
||||
// that had an entity and no migration. Both must be on the migrations' side of the line.
|
||||
assertThat(SaasSchemaOwnership.MIGRATION_OWNED)
|
||||
.contains("team_memberships", "payg_instance_usage", "teams", "users");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isMigrationOwnedIsCaseInsensitiveAndNullSafe() {
|
||||
assertThat(SaasSchemaOwnership.isMigrationOwned("TEAM_MEMBERSHIPS")).isTrue();
|
||||
assertThat(SaasSchemaOwnership.isMigrationOwned("team_memberships")).isTrue();
|
||||
assertThat(SaasSchemaOwnership.isMigrationOwned(null)).isFalse();
|
||||
assertThat(SaasSchemaOwnership.isMigrationOwned("no_such_table")).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -1879,6 +1879,9 @@ width = "Width"
|
||||
[app]
|
||||
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
|
||||
[appBanner]
|
||||
dismiss = "Dismiss"
|
||||
|
||||
[attachments]
|
||||
convertToPdfA3b = "Convert to PDF/A-3b"
|
||||
convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments"
|
||||
@@ -3010,6 +3013,7 @@ saturation = "Saturation and brightness"
|
||||
title = "Choose color"
|
||||
|
||||
[common]
|
||||
actions = "Actions"
|
||||
back = "Back"
|
||||
cancel = "Cancel"
|
||||
close = "Close"
|
||||
@@ -3025,6 +3029,7 @@ error = "Error"
|
||||
expand = "Expand"
|
||||
loading = "Loading..."
|
||||
next = "Next"
|
||||
open = "Open"
|
||||
preview = "Preview"
|
||||
previous = "Previous"
|
||||
refresh = "Refresh"
|
||||
@@ -4825,9 +4830,6 @@ title = "Image to PDF"
|
||||
[imageToPdf]
|
||||
tags = "conversion,img,jpg,picture,photo"
|
||||
|
||||
[infoBanner]
|
||||
dismiss = "Dismiss"
|
||||
|
||||
[invite]
|
||||
acceptError = "Failed to create account"
|
||||
accountFor = "Creating account for"
|
||||
@@ -5055,6 +5057,14 @@ title = "Upload from Mobile"
|
||||
tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide"
|
||||
title = "PDF Multi Tool"
|
||||
|
||||
[navFooter]
|
||||
openEditor = "Open PDF Editor"
|
||||
openProcessor = "Open PDF Processor"
|
||||
|
||||
[navFooter.credits]
|
||||
count = "{{remaining}} of {{total}}"
|
||||
label = "Free credits"
|
||||
|
||||
[oauth.error]
|
||||
message = "Authentication was not successful. You can close this window and try again."
|
||||
title = "Authentication Failed"
|
||||
@@ -5619,8 +5629,8 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man
|
||||
freeTitle = "Unlimited PDF editing"
|
||||
|
||||
[payg.free.hero]
|
||||
barAria = "Free PDFs used"
|
||||
capSuffix = "/ {{limit}} free PDFs"
|
||||
barAria = "Free PDFs remaining"
|
||||
capSuffix = "of {{limit}} free PDFs left"
|
||||
metaCategories = "Automation · AI · API requests"
|
||||
|
||||
[payg.free.member]
|
||||
@@ -6339,7 +6349,6 @@ revoked = "Revoked"
|
||||
unnamed = "Unnamed instance"
|
||||
|
||||
[portal.accountLink.instances.columns]
|
||||
actions = "Actions"
|
||||
instance = "Instance"
|
||||
lastSeen = "Last seen"
|
||||
linked = "Linked"
|
||||
@@ -6684,12 +6693,12 @@ reachedTitle = "Monthly spend limit reached"
|
||||
title = "Couldn't open Stripe portal"
|
||||
|
||||
[portal.billing.walletMeter]
|
||||
barAria = "Free PDFs used"
|
||||
capSuffix_one = "of {{allowance}} free PDFs used"
|
||||
capSuffix_other = "of {{allowance}} free PDFs used"
|
||||
barAria = "Free PDFs remaining"
|
||||
capSuffix_one = "of {{allowance}} free PDF left"
|
||||
capSuffix_other = "of {{allowance}} free PDFs left"
|
||||
eyebrow = "Processor trial"
|
||||
statusLabel_one = "{{remaining}} left"
|
||||
statusLabel_other = "{{remaining}} left"
|
||||
statusLabel_one = "{{used}} used"
|
||||
statusLabel_other = "{{used}} used"
|
||||
sub = "Use the PDF Editor for free. Pay to process PDFs automatically."
|
||||
title_one = "Process {{allowance}} PDFs free"
|
||||
title_other = "Process {{allowance}} PDFs free"
|
||||
@@ -7262,12 +7271,11 @@ editorAction = "Editor"
|
||||
empty = "No documents match this filter."
|
||||
rowActions = "Row actions"
|
||||
sensitiveLabel = "Sensitive"
|
||||
sensitiveTitle = "Sensitive — access required"
|
||||
|
||||
[portal.documents.table.columns]
|
||||
action = "Pipeline / Action"
|
||||
actions = "Actions"
|
||||
document = "Document"
|
||||
labels = "Labels"
|
||||
product = "Product"
|
||||
status = "Status"
|
||||
time = "Time"
|
||||
@@ -7283,6 +7291,7 @@ host = "Host"
|
||||
lastSeen = "Last seen"
|
||||
region = "Region"
|
||||
status = "Status"
|
||||
target = "Target"
|
||||
version = "Version"
|
||||
|
||||
[portal.editorAdmin.health.empty]
|
||||
@@ -7371,6 +7380,14 @@ confirm = "Are you sure?"
|
||||
dismiss = "Dismiss"
|
||||
dismissSkipFile = "Skip this file"
|
||||
|
||||
[portal.failures.debug]
|
||||
copyJson = "Copy JSON"
|
||||
dismissAll = "Dismiss all ({{total}})"
|
||||
dismissing = "Dismissing..."
|
||||
hideJson = "Hide raw JSON ({{total}})"
|
||||
refresh = "Refresh failures"
|
||||
showJson = "Show raw JSON ({{total}})"
|
||||
|
||||
[portal.failures.disabled]
|
||||
closed = "This failure is already closed."
|
||||
unavailable = "Not available for this failure."
|
||||
@@ -7602,11 +7619,9 @@ security = "Security"
|
||||
storage = "Storage"
|
||||
|
||||
[portal.integrations]
|
||||
addAnother = "Add another"
|
||||
availableHeading = "Available"
|
||||
comingSoonHeading = "Coming soon"
|
||||
connect = "Connect"
|
||||
connectedHeading = "Connected"
|
||||
connectionCount_one = "{{count}} connection"
|
||||
connectionCount_other = "{{count}} connections"
|
||||
customApi = "Custom API"
|
||||
@@ -7624,6 +7639,10 @@ security = "Security"
|
||||
signing = "Signing"
|
||||
storage = "Storage"
|
||||
|
||||
[portal.integrations.noResults]
|
||||
description = "No integrations match your filters. Try a different category or search."
|
||||
title = "No matches"
|
||||
|
||||
[portal.integrations.status]
|
||||
connected = "Connected"
|
||||
|
||||
@@ -7654,7 +7673,6 @@ integrations = "Integrations"
|
||||
pipelines = "Pipelines"
|
||||
policies = "Policies"
|
||||
procurement = "Procurement"
|
||||
settings = "Settings"
|
||||
sources = "Sources"
|
||||
usage = "Usage & Billing"
|
||||
users = "Users"
|
||||
@@ -7826,10 +7844,10 @@ paused = "Paused"
|
||||
|
||||
[portal.pipelines.table]
|
||||
name = "Pipeline"
|
||||
open = "Open"
|
||||
sources = "Sources"
|
||||
status = "Status"
|
||||
steps = "Steps"
|
||||
trigger = "Trigger"
|
||||
|
||||
[portal.pipelines.trigger]
|
||||
folder-watch = "Folder watch"
|
||||
@@ -8722,9 +8740,9 @@ unused = "Unused"
|
||||
|
||||
[portal.sources.table]
|
||||
documents = "Documents"
|
||||
open = "Open"
|
||||
source = "Source"
|
||||
status = "Status"
|
||||
type = "Type"
|
||||
usedBy = "Policies"
|
||||
|
||||
[portal.sources.types.box]
|
||||
@@ -10948,6 +10966,13 @@ approver = "Approves policy"
|
||||
editor = "Editor"
|
||||
processor = "Processor"
|
||||
|
||||
[users.columns]
|
||||
capabilities = "Capabilities"
|
||||
email = "Email"
|
||||
person = "Person"
|
||||
role = "Role"
|
||||
status = "Status"
|
||||
|
||||
[users.confirm]
|
||||
cancelInviteBody = "Cancel the invitation to {{email}}? They won't be able to join with the current link."
|
||||
cancelInviteTitle = "Cancel invitation"
|
||||
@@ -10967,10 +10992,8 @@ title = "No members yet"
|
||||
addToTeam = "Add to team"
|
||||
guestCount = "{{count}} guest"
|
||||
guests = "Guests"
|
||||
guestsDesc = "External collaborators, scoped to what you shared. Editor only."
|
||||
ledBy = "led by {{owner}}"
|
||||
org = "Organization"
|
||||
orgDesc = "Owners with org-wide authority and policy approval"
|
||||
owners = "{{count}} owner"
|
||||
team = "{{name}} team"
|
||||
teamMeta = "{{count}} people"
|
||||
@@ -11010,13 +11033,15 @@ usernamePlaceholder = "jsmith"
|
||||
[users.invites]
|
||||
by = "Invited by {{who}}"
|
||||
cancel = "Cancel"
|
||||
count = "{{count}} pending"
|
||||
desc = "Invited people who haven't joined yet. They hold a seat until they accept."
|
||||
expiresInDays_one = "Expires in {{count}} day"
|
||||
expiresInDays_other = "Expires in {{count}} days"
|
||||
expiresToday = "Expires today"
|
||||
title = "Pending invitations"
|
||||
|
||||
[users.invites.columns]
|
||||
expires = "Expires"
|
||||
invitee = "Invitee"
|
||||
|
||||
[users.loadError]
|
||||
description = "Something went wrong reaching the backend, or you don't have access. Try again."
|
||||
title = "Couldn't load members"
|
||||
|
||||
@@ -642,7 +642,6 @@ const CODE_EXEMPT_PATH = [
|
||||
/mantineTheme|\/theme\.ts$|toolsTaxonomy|LayoutPreview|PageNumberPreview|CloudStorageIcons|BrandMarks/,
|
||||
/\/onboarding\//,
|
||||
/addStamp|addWatermark|\/tooltips\//,
|
||||
/UpgradeBanner|AdminPlanSection/,
|
||||
// Stories are checked like app code; colour-as-data lines opt out with
|
||||
// `theme-allow-color`.
|
||||
/\.test\.[jt]sx?$|\/types\//,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Group, Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
|
||||
/**
|
||||
@@ -105,7 +105,7 @@ export function TeamInvitationBanner() {
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="mail"
|
||||
message={
|
||||
<Group
|
||||
@@ -120,10 +120,6 @@ export function TeamInvitationBanner() {
|
||||
}
|
||||
show={shouldShow}
|
||||
dismissible={false}
|
||||
background="var(--mantine-color-dark-7)"
|
||||
borderColor="var(--mantine-color-dark-5)"
|
||||
textColor="rgba(255, 255, 255, 0.95)"
|
||||
iconColor="rgba(255, 255, 255, 0.95)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatPeriodDate,
|
||||
MeterBar,
|
||||
meterState,
|
||||
remainingMeter,
|
||||
} from "@app/billing";
|
||||
import "@app/components/shared/config/configSections/Payg.css";
|
||||
import "@app/components/shared/config/configSections/PaygFree.css";
|
||||
@@ -48,7 +49,8 @@ export function useFreeSnapshot(): FreeSnapshot {
|
||||
|
||||
export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
|
||||
const remaining = Math.max(0, snap.billableLimit - snap.billableUsed);
|
||||
const { state, pct } = remainingMeter(remaining, snap.billableLimit);
|
||||
const stateLabel =
|
||||
state === "DEGRADED"
|
||||
? t("payg.free.state.limitReached", "Limit reached")
|
||||
@@ -60,9 +62,9 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
||||
<MeterBar
|
||||
state={state}
|
||||
pct={pct}
|
||||
barLabel={t("payg.free.hero.barAria", "Free PDFs used")}
|
||||
figure={snap.billableUsed.toLocaleString()}
|
||||
capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
|
||||
barLabel={t("payg.free.hero.barAria", "Free PDFs remaining")}
|
||||
figure={remaining.toLocaleString()}
|
||||
capSuffix={t("payg.free.hero.capSuffix", "of {{limit}} free PDFs left", {
|
||||
limit: snap.billableLimit.toLocaleString(),
|
||||
})}
|
||||
statusLabel={stateLabel}
|
||||
@@ -175,15 +177,15 @@ export function prepaidSnapshotFromWallet(
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepaid capacity meter. The bar fills as the pool is drawn down ({@code used =
|
||||
* total − remaining}), so it WARNs when the pool is running low and DEGRADEs once
|
||||
* exhausted — same bands as the free/cap meters. Prepaid is consumed ahead of the
|
||||
* meter and outside the spend cap, so it reads as its own dimension.
|
||||
* Prepaid capacity meter. The bar shows what is left and drains towards empty,
|
||||
* while the bands still key on what is gone ({@code used = total − remaining}), so
|
||||
* it WARNs when the pool is running low and DEGRADEs once exhausted — same bands
|
||||
* as the free/cap meters. Prepaid is consumed ahead of the meter and outside the
|
||||
* spend cap, so it reads as its own dimension.
|
||||
*/
|
||||
export function PrepaidCapacityMeterPanel({ snap }: { snap: PrepaidSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
const used = Math.max(0, snap.total - snap.remaining);
|
||||
const { state, pct } = meterState(used, snap.total);
|
||||
const { state, pct } = remainingMeter(snap.remaining, snap.total);
|
||||
const stateLabel =
|
||||
state === "DEGRADED"
|
||||
? t("payg.prepaid.state.exhausted", "Used up")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useWallet } from "@app/hooks/useWallet";
|
||||
import {
|
||||
readCachedCredits,
|
||||
writeCachedCredits,
|
||||
type CachedCredits,
|
||||
} from "@app/services/navFooterCache";
|
||||
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
|
||||
|
||||
/** The wallet reduced to what the footer shows: figures, or null for a payer. */
|
||||
function toCredits(
|
||||
status: string,
|
||||
freeRemaining: number,
|
||||
freeAllowance: number,
|
||||
): CachedCredits {
|
||||
// Free teams only. The grant is a lifetime pool that survives subscribing, so
|
||||
// a paying team would otherwise sit on a permanent "0 of 500" in red while
|
||||
// nothing is wrong. Plan draws the same line — subscribed teams get the
|
||||
// spend-vs-cap meter there, and admins get usage in the processor.
|
||||
if (status === "subscribed") return null;
|
||||
return { remaining: freeRemaining, total: freeAllowance };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud builds read the free grant off the live wallet — the same snapshot the
|
||||
* Plan page's free meter renders, so the sidebar and Plan can't disagree.
|
||||
*
|
||||
* Seeded from the last figures this browser saw, so the row is right at first
|
||||
* paint and stays put while the wallet refetches underneath. Only a browser
|
||||
* that has never loaded a wallet has nothing to show, and that one time the row
|
||||
* animates in. The seed is read once, at first render: later reads would fight
|
||||
* the live value, and the whole point is that the row stops moving.
|
||||
*/
|
||||
export function useFreeCreditsSummary(): NavFooterCredits | null {
|
||||
const { wallet } = useWallet();
|
||||
const [seed] = useState(readCachedCredits);
|
||||
|
||||
const live = wallet
|
||||
? toCredits(wallet.status, wallet.freeRemaining, wallet.freeAllowance)
|
||||
: undefined;
|
||||
|
||||
// useWallet reuses the snapshot reference when nothing changed, so keying on
|
||||
// it writes only on a real change, not on every render.
|
||||
useEffect(() => {
|
||||
if (live !== undefined) writeCachedCredits(live);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wallet]);
|
||||
|
||||
return (live !== undefined ? live : seed) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Cloud editor builds open the settings modal on its Plan section, which is
|
||||
* where the free grant is explained and the Processor plan is switched on.
|
||||
* Routed rather than called directly because the modal is URL-driven here
|
||||
* (`/settings/*`), the same path the admin tour uses to open it.
|
||||
*/
|
||||
export function useOpenPlan(): (() => void) | null {
|
||||
const navigate = useNavigate();
|
||||
return useCallback(() => navigate("/settings/plan"), [navigate]);
|
||||
}
|
||||
@@ -32,6 +32,14 @@
|
||||
* promise see the UI flip exactly once the new state is visible — no
|
||||
* intermediate flash of the old value.
|
||||
*
|
||||
* <h2>Freshness</h2>
|
||||
*
|
||||
* The figures drain as metered work runs, so a mounted consumer re-reads the
|
||||
* wallet every {@link WALLET_POLL_MS} and again whenever the tab regains
|
||||
* visibility. Those refreshes are silent — they leave {@code loading} and
|
||||
* {@code error} alone and only commit fresher data — so consumers that gate on
|
||||
* those flags don't flicker on a background tick.
|
||||
*
|
||||
* <h2>Dev preview fallback</h2>
|
||||
*
|
||||
* When the hook is rendered outside the saas app (e.g. on {@code
|
||||
@@ -178,6 +186,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
|
||||
return prev;
|
||||
}
|
||||
|
||||
/**
|
||||
* How often a mounted consumer re-reads the wallet. Matches the app query
|
||||
* client's staleTime, so the sidebar meter and anything cached elsewhere age
|
||||
* out on the same clock.
|
||||
*/
|
||||
const WALLET_POLL_MS = 30_000;
|
||||
|
||||
export function useWallet(): UseWalletResult {
|
||||
// Resolved once: the dev-preview side-channel when rendered outside the real
|
||||
// app (saas /dev/payg-preview route), else null (every real build + desktop).
|
||||
@@ -201,13 +216,29 @@ export function useWallet(): UseWalletResult {
|
||||
// "the request fired." Cleared when no load is pending.
|
||||
const inFlight = useRef<Promise<void> | null>(null);
|
||||
|
||||
// Set for refreshes the user didn't ask for (the poll below). Silence governs
|
||||
// whether a load may RAISE `loading` / `error`, never whether it may clear
|
||||
// them: consumers gate on both — the limit modals do
|
||||
// `if (loading || !wallet) return null`, and Plan swaps in an error alert —
|
||||
// so a background tick must not blink an open modal out or replace a working
|
||||
// page over a transient failure. Clearing is always the latest request's job,
|
||||
// silent or not; a silent load that skipped the clear would strand `loading`
|
||||
// true after superseding a visible one, which suppresses those modals for the
|
||||
// rest of the session.
|
||||
const silentRefresh = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const reqId = ++latestReqId.current;
|
||||
let cancelled = false;
|
||||
|
||||
const silent = silentRefresh.current;
|
||||
silentRefresh.current = false;
|
||||
|
||||
const promise = (async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
if (devPreview) {
|
||||
const synth = devPreview.buildWallet(devPreview.role());
|
||||
@@ -221,11 +252,22 @@ export function useWallet(): UseWalletResult {
|
||||
const res = await apiClient.get<Wallet>("/api/v1/payg/wallet");
|
||||
if (cancelled || reqId !== latestReqId.current) return;
|
||||
setWallet((prev) => reuseIfEqual(prev, res.data));
|
||||
// Fresh data retires any earlier failure, including one a silent poll
|
||||
// is recovering from — otherwise Plan keeps its alert over good data.
|
||||
setError(null);
|
||||
} catch (e: unknown) {
|
||||
if (cancelled || reqId !== latestReqId.current) return;
|
||||
console.warn("[useWallet] fetch failed", e);
|
||||
setError(e instanceof Error ? e.message : "Failed to load wallet");
|
||||
if (!silent) {
|
||||
console.warn("[useWallet] fetch failed", e);
|
||||
setError(e instanceof Error ? e.message : "Failed to load wallet");
|
||||
}
|
||||
// A failed background refresh is a non-event: the last good snapshot
|
||||
// stands and the next tick self-heals, so it neither surfaces nor
|
||||
// logs — otherwise an offline tab warns every WALLET_POLL_MS.
|
||||
} finally {
|
||||
// Deliberately not gated on `silent`: whichever load is latest owns
|
||||
// settling the flag, or a silent refresh that supersedes a visible one
|
||||
// leaves it stuck true.
|
||||
if (!cancelled && reqId === latestReqId.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -242,6 +284,46 @@ export function useWallet(): UseWalletResult {
|
||||
};
|
||||
}, [devPreview, refetchTick]);
|
||||
|
||||
// The wallet drains as automation, AI and API work runs, so a figure fetched
|
||||
// on mount goes stale while the user watches it. Refresh on a timer, and
|
||||
// immediately on returning to the tab — coming back to a stale number is the
|
||||
// case people actually notice. Hidden tabs don't poll, and the dev-preview
|
||||
// wallet is synthesised locally so there is nothing to re-read.
|
||||
useEffect(() => {
|
||||
if (devPreview) return;
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
const refresh = () => {
|
||||
silentRefresh.current = true;
|
||||
setRefetchTick((t) => t + 1);
|
||||
};
|
||||
const stop = () => {
|
||||
if (timer !== undefined) {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
const start = () => {
|
||||
stop();
|
||||
timer = setInterval(refresh, WALLET_POLL_MS);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
refresh();
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
if (document.visibilityState === "visible") start();
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [devPreview]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setRefetchTick((t) => t + 1);
|
||||
// Snapshot the next-tick promise so the caller awaits this refetch
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AppLayout } from "@app/components/AppLayout";
|
||||
import { BannerProvider, useBanner } from "@app/contexts/BannerContext";
|
||||
import { NavigationProvider } from "@app/contexts/NavigationContext";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
|
||||
const meta = {
|
||||
title: "Components/AppLayout",
|
||||
@@ -49,7 +49,7 @@ function BannerSetter() {
|
||||
const { setBanner } = useBanner();
|
||||
useEffect(() => {
|
||||
setBanner(
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="info-rounded"
|
||||
title="Heads up"
|
||||
message="This workspace is running in offline mode."
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/* App-wide top bar. One shape, four tones — callers pick a tone, never a colour.
|
||||
Named `app-banner`, not `sui-banner`: that belongs to the SUI Banner primitive. */
|
||||
.app-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 3.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
/* Full-bleed across the top of the app: square corners, one rule underneath. */
|
||||
border-bottom: 1px solid var(--app-banner-border);
|
||||
background: var(--app-banner-bg);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.app-banner--compact {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.app-banner--info {
|
||||
--app-banner-bg: var(--c-primary-subtle);
|
||||
--app-banner-border: var(--c-primary-border);
|
||||
--app-banner-icon: var(--c-accent-fg, var(--c-primary));
|
||||
}
|
||||
|
||||
/* The one bar meant to pop, so it takes the feature gradient rather than a tint.
|
||||
Fixed hues by design — it doesn't follow the chosen accent. */
|
||||
.app-banner--promo {
|
||||
--app-banner-bg: linear-gradient(
|
||||
135deg,
|
||||
var(--c-hue-indigo) 0%,
|
||||
var(--c-hue-purple) 100%
|
||||
);
|
||||
--app-banner-border: transparent;
|
||||
--app-banner-icon: var(--color-text-on-accent);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.app-banner--warning {
|
||||
--app-banner-bg: var(--c-warning-subtle);
|
||||
--app-banner-border: color-mix(in srgb, var(--c-warning) 32%, transparent);
|
||||
--app-banner-icon: var(--c-warning);
|
||||
}
|
||||
|
||||
.app-banner--danger {
|
||||
--app-banner-bg: var(--c-danger-subtle);
|
||||
--app-banner-border: color-mix(in srgb, var(--c-danger) 32%, transparent);
|
||||
--app-banner-icon: var(--c-danger);
|
||||
}
|
||||
|
||||
/* Only the icon carries the tone; text stays neutral in every tone. */
|
||||
.app-banner__icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
color: var(--app-banner-icon);
|
||||
}
|
||||
|
||||
.app-banner__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.app-banner__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-banner__message {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.app-banner__body:not(:has(.app-banner__title)) .app-banner__message {
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-banner--compact .app-banner__title,
|
||||
.app-banner--compact .app-banner__message {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* On the gradient everything is white; muted grey would disappear. */
|
||||
.app-banner--promo .app-banner__message,
|
||||
.app-banner--promo .app-banner__actions .sui-btn--tertiary,
|
||||
.app-banner--promo .app-banner__actions .sui-ai {
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
/* Lifts the premium CTA off the gradient it sits on. */
|
||||
.app-banner--promo .app-banner__actions .sui-btn--primary {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.app-banner__actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mantine trims the leading padding when a button has a left section, which reads
|
||||
as off-centre next to the label. Even it back up. */
|
||||
.app-banner__actions .sui-btn .mantine-Button-inner {
|
||||
padding-inline: 0;
|
||||
}
|
||||
.app-banner__actions .sui-btn {
|
||||
padding-inline: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/AppBanner",
|
||||
component: AppBanner,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof AppBanner>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
icon: "info-rounded",
|
||||
title: "Heads up",
|
||||
message: "This document contains form fields that will be flattened.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Promo: Story = {
|
||||
args: {
|
||||
tone: "promo",
|
||||
icon: "stars-rounded",
|
||||
title: "Upgrade to Server Plan",
|
||||
message:
|
||||
"Get the most out of Stirling PDF with unlimited users and advanced features.",
|
||||
buttonText: "Upgrade Now",
|
||||
buttonIcon: "upgrade-rounded",
|
||||
onButtonClick: () => {},
|
||||
compact: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
tone: "warning",
|
||||
icon: "warning-rounded",
|
||||
title: "Action required",
|
||||
message: "Some pages could not be processed and were skipped.",
|
||||
buttonText: "Review",
|
||||
onButtonClick: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Danger: Story = {
|
||||
args: {
|
||||
tone: "danger",
|
||||
icon: "warning-rounded",
|
||||
title: "This server needs admin attention",
|
||||
message: "Review the license requirements to keep this server compliant.",
|
||||
buttonText: "See info",
|
||||
buttonIcon: "info-rounded",
|
||||
onButtonClick: () => {},
|
||||
dismissible: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
icon: "info-rounded",
|
||||
message: "Autosave is enabled for this file.",
|
||||
dismissible: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** Message-only, no title: the message takes the title's weight so the bar still reads. */
|
||||
export const MessageOnly: Story = {
|
||||
args: {
|
||||
icon: "picture-as-pdf-rounded",
|
||||
message:
|
||||
"Make Stirling PDF your default application for opening PDF files.",
|
||||
buttonText: "Set Default",
|
||||
onButtonClick: () => {},
|
||||
secondaryButtonText: "Don't remind me again",
|
||||
onSecondaryButtonClick: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
function Row({ caption, children }: { caption: string; children: ReactNode }) {
|
||||
return (
|
||||
<section
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.6875rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--c-text-subtle)",
|
||||
padding: "0 1rem",
|
||||
}}
|
||||
>
|
||||
{caption}
|
||||
</span>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every top bar the app can show, in one place: each entry mirrors a real caller,
|
||||
* so a change to the component is visible against the whole set at once. Renders a
|
||||
* composition rather than the component, so it takes no args of its own.
|
||||
*/
|
||||
export const AllTopBars: StoryObj = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1.5rem",
|
||||
padding: "1.5rem 0",
|
||||
background: "var(--c-bg)",
|
||||
}}
|
||||
>
|
||||
<Row caption="Upgrade prompt · UpgradeBanner (friendly)">
|
||||
<AppBanner
|
||||
tone="promo"
|
||||
compact
|
||||
icon="stars-rounded"
|
||||
title="Upgrade to Server Plan"
|
||||
message="Get the most out of Stirling PDF with unlimited users and advanced features."
|
||||
buttonText="Upgrade Now"
|
||||
buttonIcon="upgrade-rounded"
|
||||
onButtonClick={() => {}}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Server needs attention · UpgradeBanner (urgent)">
|
||||
<AppBanner
|
||||
tone="warning"
|
||||
icon="warning-rounded"
|
||||
title="This server needs admin attention"
|
||||
message="Review the license requirements to keep this server compliant."
|
||||
buttonText="See info"
|
||||
buttonIcon="info-rounded"
|
||||
onButtonClick={() => {}}
|
||||
dismissible={false}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Free tier limit reached · AdminPlanSection">
|
||||
<AppBanner
|
||||
tone="warning"
|
||||
icon="warning-rounded"
|
||||
title="Free self-hosted limit reached"
|
||||
message="You have 12 users on a plan that covers 10."
|
||||
buttonText="See plans"
|
||||
buttonIcon="upgrade-rounded"
|
||||
onButtonClick={() => {}}
|
||||
dismissible={false}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Team invitation · TeamInvitationBanner">
|
||||
<AppBanner
|
||||
icon="mail"
|
||||
message="You have been invited to join the Acme Legal team."
|
||||
buttonText="Accept"
|
||||
onButtonClick={() => {}}
|
||||
secondaryButtonText="Decline"
|
||||
onSecondaryButtonClick={() => {}}
|
||||
dismissible={false}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Set as default app · DefaultAppBanner (desktop)">
|
||||
<AppBanner
|
||||
icon="picture-as-pdf-rounded"
|
||||
message="Make Stirling PDF your default application for opening PDF files."
|
||||
buttonText="Set Default"
|
||||
onButtonClick={() => {}}
|
||||
secondaryButtonText="Don't remind me again"
|
||||
onSecondaryButtonClick={() => {}}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Danger tone (available, no caller yet)">
|
||||
<AppBanner
|
||||
tone="danger"
|
||||
icon="warning-rounded"
|
||||
title="Storage is full"
|
||||
message="New uploads will fail until space is freed."
|
||||
buttonText="Manage storage"
|
||||
onButtonClick={() => {}}
|
||||
dismissible={false}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import "@app/components/shared/AppBanner.css";
|
||||
|
||||
/** Picks the whole look. Callers choose meaning, never colours. */
|
||||
export type AppBannerTone = "info" | "promo" | "warning" | "danger";
|
||||
|
||||
/** Tone decides the button too, so the CTA can't drift from the bar it sits on. */
|
||||
const TONE_BUTTON = {
|
||||
info: { variant: "secondary", accent: "default" },
|
||||
promo: { variant: "primary", accent: "premium" },
|
||||
warning: { variant: "primary", accent: "warning" },
|
||||
danger: { variant: "primary", accent: "danger" },
|
||||
} as const;
|
||||
|
||||
interface AppBannerProps {
|
||||
/** A LocalIcon name, or a pre-rendered node (e.g. a logo) dropped in as-is. */
|
||||
icon?: string | ReactNode;
|
||||
title?: ReactNode;
|
||||
message: ReactNode;
|
||||
buttonText?: string;
|
||||
buttonIcon?: string;
|
||||
onButtonClick?: () => void;
|
||||
/** Muted secondary action, e.g. "Don't remind me again". */
|
||||
secondaryButtonText?: string;
|
||||
onSecondaryButtonClick?: () => void;
|
||||
onDismiss?: () => void;
|
||||
dismissible?: boolean;
|
||||
loading?: boolean;
|
||||
show?: boolean;
|
||||
tone?: AppBannerTone;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** The app's top bar: dismissible messaging above the workspace. */
|
||||
export const AppBanner: React.FC<AppBannerProps> = ({
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
buttonText,
|
||||
buttonIcon = "check-circle-rounded",
|
||||
onButtonClick,
|
||||
secondaryButtonText,
|
||||
onSecondaryButtonClick,
|
||||
onDismiss,
|
||||
dismissible = true,
|
||||
loading = false,
|
||||
show = true,
|
||||
tone = "info",
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (!show) return null;
|
||||
|
||||
const iconSize = compact ? "1rem" : "1.25rem";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"app-banner",
|
||||
`app-banner--${tone}`,
|
||||
compact ? "app-banner--compact" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{icon != null && (
|
||||
<span className="app-banner__icon" aria-hidden>
|
||||
{typeof icon === "string" ? (
|
||||
<LocalIcon icon={icon} width={iconSize} height={iconSize} />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="app-banner__body">
|
||||
{title && <span className="app-banner__title">{title}</span>}
|
||||
<span className="app-banner__message">{message}</span>
|
||||
</div>
|
||||
|
||||
<div className="app-banner__actions">
|
||||
{buttonText && onButtonClick && (
|
||||
<Button
|
||||
variant={TONE_BUTTON[tone].variant}
|
||||
accent={TONE_BUTTON[tone].accent}
|
||||
size="sm"
|
||||
loading={loading}
|
||||
onClick={onButtonClick}
|
||||
leftSection={
|
||||
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
{secondaryButtonText && onSecondaryButtonClick && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={onSecondaryButtonClick}
|
||||
>
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
)}
|
||||
{dismissible && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={() => onDismiss?.()}
|
||||
aria-label={t("appBanner.dismiss", "Dismiss")}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -48,9 +48,55 @@
|
||||
transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428);
|
||||
}
|
||||
|
||||
/* One-shot "thinking" drift — the two parallelograms swap past each other and
|
||||
settle back. Same motion the chat FAB loops while the agent works, but this
|
||||
pair starts and ends at rest (translate 0, full opacity) so a single
|
||||
iteration can end without snapping. Callers apply it for one beat; see
|
||||
NavFooter.css for the hover use. */
|
||||
@keyframes sui-brandmark-drift-a {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
25% {
|
||||
transform: translate(-1px, -5px);
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
transform: translate(-6px, 0);
|
||||
opacity: 0.9;
|
||||
}
|
||||
75% {
|
||||
transform: translate(-1px, 5px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sui-brandmark-drift-b {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
25% {
|
||||
transform: translate(1px, 5px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
50% {
|
||||
transform: translate(6px, 0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
75% {
|
||||
transform: translate(1px, -5px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sui-brandmark__a,
|
||||
.sui-brandmark__b {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,16 +75,13 @@
|
||||
padding: 0.25rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.file-sidebar-footer-box {
|
||||
padding: 0.25rem 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* The footer is the shared <NavFooter>: it brings its own boxes and padding,
|
||||
so this class only positions it in the column. */
|
||||
|
||||
/* Collapsed rail: the file tree isn't rendered, so hide its (empty) box and
|
||||
let the boxes stack at the top — controls, then the settings footer right
|
||||
after — instead of the files box stretching to fill. */
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-controls,
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-footer-box {
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-controls {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-files-box {
|
||||
@@ -538,86 +535,3 @@
|
||||
pointer-events: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ---- Bottom bar (user + settings) ---- */
|
||||
.file-sidebar-bottom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* Bottom bar settings icon tracks the right edge during collapse animation */
|
||||
|
||||
.file-sidebar-bottom-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--c-accent-text);
|
||||
color: var(--c-text-on-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* No colored disc behind an actual photo; keep it for the initials fallback. */
|
||||
.file-sidebar-bottom-avatar--picture {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--c-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-bar[role="button"]:hover {
|
||||
background-color: var(--c-hover);
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-bar[role="button"]:focus-visible {
|
||||
outline: 2px solid var(--c-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--c-text-subtle);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-settings {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-bar {
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
@@ -22,13 +22,15 @@ import {
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
|
||||
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
|
||||
import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
|
||||
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
|
||||
import { useOpenPlan } from "@app/hooks/useOpenPlan";
|
||||
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
||||
import {
|
||||
useIndexedDB,
|
||||
useIndexedDBRevision,
|
||||
} from "@app/contexts/IndexedDBContext";
|
||||
import { accountService } from "@app/services/accountService";
|
||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
@@ -37,8 +39,7 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
|
||||
import { useLabelName } from "@app/data/labelDisplay";
|
||||
@@ -241,43 +242,11 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const { addFiles } = useFileHandler();
|
||||
const indexedDB = useIndexedDB();
|
||||
|
||||
// Each auth layer derives its own displayName from its native user shape.
|
||||
// Fall back to the proprietary REST endpoint only when the auth
|
||||
// context yields nothing - then to "User" as a generic last resort.
|
||||
const { displayName: authDisplayName, isAnonymous } = useAuth();
|
||||
const [accountUsername, setAccountUsername] = useState<string | null>(null);
|
||||
const displayName =
|
||||
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User");
|
||||
|
||||
const profilePictureUrl = useProfilePictureUrl();
|
||||
const [pictureFailed, setPictureFailed] = useState(false);
|
||||
useEffect(() => setPictureFailed(false), [profilePictureUrl]);
|
||||
const showProfilePicture = !!profilePictureUrl && !pictureFailed;
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.enableLogin) {
|
||||
setAccountUsername(null);
|
||||
return;
|
||||
}
|
||||
if (authDisplayName) {
|
||||
// The auth context has a name; don't bother hitting the REST
|
||||
// endpoint, but clear any stale cached value from a prior call.
|
||||
setAccountUsername(null);
|
||||
return;
|
||||
}
|
||||
accountService
|
||||
.getAccountData()
|
||||
.then((data) => {
|
||||
// Always reflect the latest result - including clearing it on
|
||||
// sign-out, when the endpoint returns no username (or 401s into
|
||||
// the catch branch below). Without this, signing out would leave
|
||||
// the old username on screen.
|
||||
setAccountUsername(data?.username ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
setAccountUsername(null);
|
||||
});
|
||||
}, [config?.enableLogin, authDisplayName]);
|
||||
const { displayName, profilePictureUrl, isAnonymous } =
|
||||
useAccountIdentity();
|
||||
const credits = useFreeCreditsSummary();
|
||||
const otherApp = useOtherAppSwitch();
|
||||
const openPlan = useOpenPlan();
|
||||
|
||||
// Leaf files = user-visible files (excludes intermediate tool outputs)
|
||||
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
|
||||
@@ -1115,7 +1084,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
)}
|
||||
data-testid="open-files-page"
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
|
||||
<OpenInFullIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
@@ -1264,70 +1233,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
{/* Getting-started checklist, floating above the footer (SaaS only). */}
|
||||
<SidebarChecklistSlot collapsed={collapsed} />
|
||||
|
||||
{/* Box 3 — account footer (avatar + name + settings). */}
|
||||
<NavSurface className="file-sidebar-footer-box">
|
||||
{/* Bottom bar: user name + settings */}
|
||||
<Tooltip
|
||||
label={
|
||||
onOpenSettings
|
||||
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
|
||||
: displayName
|
||||
}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className="file-sidebar-bottom-bar"
|
||||
onClick={onOpenSettings}
|
||||
role={onOpenSettings ? "button" : undefined}
|
||||
tabIndex={onOpenSettings ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onOpenSettings
|
||||
? (e) => e.key === "Enter" && onOpenSettings()
|
||||
: undefined
|
||||
}
|
||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||
aria-label={
|
||||
onOpenSettings
|
||||
? t("fileSidebar.openSettings", "Open settings")
|
||||
: displayName
|
||||
}
|
||||
style={onOpenSettings ? { cursor: "pointer" } : undefined}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-bottom-avatar${
|
||||
showProfilePicture
|
||||
? " file-sidebar-bottom-avatar--picture"
|
||||
: ""
|
||||
}`}
|
||||
aria-label={displayName}
|
||||
>
|
||||
{showProfilePicture ? (
|
||||
<img
|
||||
src={profilePictureUrl}
|
||||
alt=""
|
||||
className="file-sidebar-bottom-avatar-img"
|
||||
onError={() => setPictureFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
displayName.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-bottom-name sidebar-content-fade">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{onOpenSettings && !collapsed && (
|
||||
<div className="file-sidebar-bottom-settings">
|
||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</NavSurface>
|
||||
{/* Box 3 — the shared footer: credits, app switch, account row. */}
|
||||
<NavFooter
|
||||
className="file-sidebar-footer-box"
|
||||
displayName={displayName}
|
||||
profilePictureUrl={profilePictureUrl}
|
||||
onOpenSettings={onOpenSettings}
|
||||
credits={credits}
|
||||
onOpenPlan={openPlan ?? undefined}
|
||||
otherApp={otherApp}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/InfoBanner",
|
||||
component: InfoBanner,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof InfoBanner>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
icon: "info-rounded",
|
||||
title: "Heads up",
|
||||
message: "This document contains form fields that will be flattened.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
tone: "warning",
|
||||
icon: "warning-rounded",
|
||||
title: "Action required",
|
||||
message: "Some pages could not be processed and were skipped.",
|
||||
buttonText: "Review",
|
||||
onButtonClick: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
icon: "info-rounded",
|
||||
message: "Autosave is enabled for this file.",
|
||||
dismissible: false,
|
||||
},
|
||||
};
|
||||
@@ -1,263 +0,0 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { Paper, Group, Text, Stack } from "@mantine/core";
|
||||
import { Button, type ButtonVariant, type ButtonAccent } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
type InfoBannerTone = "info" | "warning";
|
||||
|
||||
const toneStyles: Record<
|
||||
InfoBannerTone,
|
||||
{
|
||||
background: string;
|
||||
border: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
buttonColor: string;
|
||||
}
|
||||
> = {
|
||||
info: {
|
||||
background: "var(--mantine-color-blue-0)",
|
||||
border: "var(--mantine-color-blue-2)",
|
||||
text: "var(--mantine-color-blue-9)",
|
||||
icon: "var(--mantine-color-blue-6)",
|
||||
buttonColor: "blue",
|
||||
},
|
||||
warning: {
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
border: "var(--mantine-color-orange-3)",
|
||||
text: "var(--color-amber-dark)",
|
||||
icon: "var(--mantine-color-orange-7)",
|
||||
buttonColor: "orange",
|
||||
},
|
||||
};
|
||||
|
||||
function toSharedButtonVariant(
|
||||
variant: "light" | "filled" | "white" | "outline" | "subtle",
|
||||
): ButtonVariant {
|
||||
switch (variant) {
|
||||
case "filled":
|
||||
return "primary";
|
||||
case "outline":
|
||||
return "secondary";
|
||||
case "subtle":
|
||||
return "tertiary";
|
||||
case "light":
|
||||
case "white":
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
function toSharedButtonAccent(color: string | undefined): ButtonAccent {
|
||||
// Mantine colours may carry a shade suffix (e.g. "orange.7"); use the hue.
|
||||
const hue = (color ?? "").split(".")[0];
|
||||
switch (hue) {
|
||||
case "red":
|
||||
return "danger";
|
||||
case "green":
|
||||
return "success";
|
||||
case "yellow":
|
||||
case "orange":
|
||||
return "warning";
|
||||
case "blue":
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
interface InfoBannerProps {
|
||||
/**
|
||||
* Either a LocalIcon name (string) for the standard sized icon slot, or a
|
||||
* pre-rendered ReactNode (e.g. a logo image) which is dropped in as-is.
|
||||
*/
|
||||
icon?: string | ReactNode;
|
||||
title?: ReactNode;
|
||||
message: ReactNode;
|
||||
buttonText?: string;
|
||||
buttonIcon?: string;
|
||||
onButtonClick?: () => void;
|
||||
/** Optional muted secondary action (e.g. "Don't remind me again"). */
|
||||
secondaryButtonText?: string;
|
||||
onSecondaryButtonClick?: () => void;
|
||||
onDismiss?: () => void;
|
||||
dismissible?: boolean;
|
||||
loading?: boolean;
|
||||
show?: boolean;
|
||||
tone?: InfoBannerTone;
|
||||
background?: string;
|
||||
borderColor?: string;
|
||||
textColor?: string;
|
||||
iconColor?: string;
|
||||
buttonColor?: string;
|
||||
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
|
||||
/** Override the button label colour (for dark/custom theme variants). */
|
||||
buttonTextColor?: string;
|
||||
minHeight?: number | string;
|
||||
closeIconColor?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic info banner component for displaying dismissible messages at the top of the app
|
||||
*/
|
||||
export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
buttonText,
|
||||
buttonIcon = "check-circle-rounded",
|
||||
onButtonClick,
|
||||
secondaryButtonText,
|
||||
onSecondaryButtonClick,
|
||||
onDismiss,
|
||||
dismissible = true,
|
||||
loading = false,
|
||||
show = true,
|
||||
tone = "info",
|
||||
background,
|
||||
borderColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
buttonColor,
|
||||
buttonVariant = "light",
|
||||
buttonTextColor,
|
||||
minHeight = 56,
|
||||
closeIconColor,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toneStyle = toneStyles[tone] ?? toneStyles.info;
|
||||
const resolvedTextColor = textColor ?? toneStyle.text;
|
||||
const handleDismiss = () => {
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
const iconSize = compact ? "1rem" : "1.2rem";
|
||||
const textSize = compact ? "xs" : "sm";
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p={compact ? "xs" : "sm"}
|
||||
radius={0}
|
||||
style={{
|
||||
background: background ?? toneStyle.background,
|
||||
border: "none",
|
||||
borderBottom:
|
||||
borderColor === "transparent"
|
||||
? "none"
|
||||
: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
minHeight,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
justify="space-between"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Group
|
||||
gap={compact ? "xs" : "sm"}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{icon != null &&
|
||||
(typeof icon === "string" ? (
|
||||
<LocalIcon
|
||||
icon={icon}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
))}
|
||||
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{title && (
|
||||
<Text
|
||||
fw={600}
|
||||
size={textSize}
|
||||
style={{ color: resolvedTextColor }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
fw={title ? 400 : 500}
|
||||
size={textSize}
|
||||
style={{ color: resolvedTextColor }}
|
||||
lineClamp={compact ? 1 : 2}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
{buttonText && onButtonClick && (
|
||||
<Button
|
||||
variant={toSharedButtonVariant(buttonVariant)}
|
||||
accent={toSharedButtonAccent(
|
||||
buttonColor ?? toneStyle.buttonColor,
|
||||
)}
|
||||
size="sm"
|
||||
loading={loading}
|
||||
onClick={onButtonClick}
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon={buttonIcon}
|
||||
width={compact ? "0.75rem" : "0.9rem"}
|
||||
height={compact ? "0.75rem" : "0.9rem"}
|
||||
/>
|
||||
}
|
||||
style={buttonTextColor ? { color: buttonTextColor } : undefined}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
{secondaryButtonText && onSecondaryButtonClick && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={onSecondaryButtonClick}
|
||||
style={{ color: "var(--c-text-muted)" }}
|
||||
>
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
)}
|
||||
{dismissible && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
aria-label={t("infoBanner.dismiss", "Dismiss")}
|
||||
style={{
|
||||
color: closeIconColor ?? "var(--c-text-muted)",
|
||||
}}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width={compact ? "0.85rem" : "1rem"}
|
||||
height={compact ? "0.85rem" : "1rem"}
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
/* Shared sidebar footer: one surface holding the link-account CTA, the credits
|
||||
meter, the other-app switch and the account row, hairline-separated.
|
||||
Structural only — every colour comes from a --c-* semantic token. */
|
||||
|
||||
.nav-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
/* Vertical only: the slots carry the horizontal padding so their separator
|
||||
runs the full width of the surface. */
|
||||
padding: 0.25rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-footer__slot {
|
||||
padding-inline: 0.375rem;
|
||||
}
|
||||
|
||||
/* Separators are drawn by the slots themselves, never as their own elements.
|
||||
A slot whose contents render nothing (the link-account CTA returns null once
|
||||
the org is linked, and an element is truthy even when it renders null) is
|
||||
:empty, so it is skipped by both rules below — it can't leave a line behind,
|
||||
and it can't push one to the top or bottom of the surface. A rule that only
|
||||
ever matches a slot PRECEDED by another visible slot cannot draw a leading
|
||||
separator, whatever the caller passes in. */
|
||||
.nav-footer__slot:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-footer__slot:not(:empty) ~ .nav-footer__slot:not(:empty) {
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Fades the rows up on the first footer mount of a page session only. They are
|
||||
seeded from cache, so they're already present at first paint; replaying this
|
||||
on every later mount (switching apps, remounting a view) would animate
|
||||
content that never changed and read as a twitch. */
|
||||
@keyframes nav-footer-row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-footer[data-animate] .nav-footer__slot:not(:empty) {
|
||||
animation: nav-footer-row-in var(--motion-enter) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-footer[data-animate] .nav-footer__slot:not(:empty) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Rows (link-account, credits, switch, account) ---- */
|
||||
|
||||
.nav-footer__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: none;
|
||||
color: var(--c-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-footer__row:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nav-footer__row:not(:disabled):hover {
|
||||
background-color: var(--c-hover);
|
||||
}
|
||||
|
||||
.nav-footer__row:focus-visible {
|
||||
outline: 2px solid var(--c-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.nav-footer__row-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.625rem;
|
||||
}
|
||||
|
||||
/* Hovering the switch row plays the mark's "thinking" drift once — the same
|
||||
motion the chat FAB loops, for a single beat, as a hint that the row hands
|
||||
off to the other app. One iteration only: it starts and ends at rest, so
|
||||
nothing snaps when it finishes, and re-entering the row replays it. */
|
||||
.nav-footer__row:hover .sui-brandmark__a {
|
||||
animation: sui-brandmark-drift-a 1.1s ease-in-out 1;
|
||||
}
|
||||
.nav-footer__row:hover .sui-brandmark__b {
|
||||
animation: sui-brandmark-drift-b 1.1s ease-in-out 1;
|
||||
}
|
||||
|
||||
.nav-footer__row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Trailing affordance on a row: the account row's gear, the switch row's
|
||||
leaving-this-app arrow. */
|
||||
.nav-footer__trailing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
/* Rows contributed by a caller (the link-account NavItem) sit in the same
|
||||
surface, so match this footer's row metrics rather than the nav rail's. */
|
||||
.nav-footer .sui-navitem {
|
||||
min-height: 2.25rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
margin: 0;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* ---- Collapsed icon rail ---- */
|
||||
|
||||
.nav-footer[data-collapsed] .nav-footer__slot {
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-footer[data-collapsed] .nav-footer__row {
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.nav-footer[data-collapsed] .sui-navitem {
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import { NavItem } from "@app/ui/NavItem";
|
||||
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
||||
|
||||
/** Stands in for a CTA that has decided it has nothing to show. */
|
||||
function RendersNothing() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta: Meta<typeof NavFooter> = {
|
||||
title: "Shared/NavFooter",
|
||||
component: NavFooter,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
displayName: "admin",
|
||||
onOpenSettings: () => {},
|
||||
credits: { remaining: 247, total: 500 },
|
||||
onOpenPlan: () => {},
|
||||
otherApp: { app: "processor", onOpen: () => {} },
|
||||
},
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div
|
||||
style={{
|
||||
width: "16.25rem",
|
||||
background: "var(--c-bg)",
|
||||
padding: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NavFooter>;
|
||||
|
||||
/** The editor's footer: credits, "Open PDF Processor", the account row. */
|
||||
export const InEditor: Story = {};
|
||||
|
||||
/** The processor's footer. Same three boxes, opposite switch target. */
|
||||
export const InProcessor: Story = {
|
||||
args: { otherApp: { app: "editor", onOpen: () => {} } },
|
||||
};
|
||||
|
||||
/** Self-hosted processor: no wallet, so no meter, and the link-account CTA
|
||||
* rides along in the account box. */
|
||||
export const WithLinkAccountCta: Story = {
|
||||
args: {
|
||||
credits: null,
|
||||
otherApp: { app: "editor", onOpen: () => {} },
|
||||
accountExtras: (
|
||||
<NavItem
|
||||
id="account-link"
|
||||
label="Link Stirling account"
|
||||
icon={<LinkIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** Regression guard: the processor always passes its link-account CTA, but that
|
||||
* component renders null once the org is linked. An element is truthy even
|
||||
* when it renders nothing, so this must not leave a separator above the first
|
||||
* visible row. */
|
||||
export const ExtrasThatRenderNothing: Story = {
|
||||
args: { accountExtras: <RendersNothing /> },
|
||||
};
|
||||
|
||||
/** A real profile picture replaces the initials disc. */
|
||||
export const WithProfilePicture: Story = {
|
||||
args: {
|
||||
displayName: "Ada Lovelace",
|
||||
profilePictureUrl:
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="black"/><circle cx="32" cy="24" r="12" fill="white"/><ellipse cx="32" cy="56" rx="20" ry="16" fill="white"/></svg>',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** Credits running low — the dot and bar shift to the warning tone at 20% left. */
|
||||
export const CreditsLow: Story = {
|
||||
args: { credits: { remaining: 42, total: 500 } },
|
||||
};
|
||||
|
||||
/** Allowance exhausted. */
|
||||
export const CreditsExhausted: Story = {
|
||||
args: { credits: { remaining: 0, total: 500 } },
|
||||
};
|
||||
|
||||
/** Core OSS: no wallet, no second app, settings only. */
|
||||
export const MinimalBuild: Story = {
|
||||
args: { credits: null, otherApp: null },
|
||||
};
|
||||
|
||||
/** No settings handler — the account row is inert identity, not a button. */
|
||||
export const NoSettings: Story = {
|
||||
args: { onOpenSettings: undefined },
|
||||
};
|
||||
|
||||
/** Collapsed icon rail: labels become tooltips. */
|
||||
export const Collapsed: Story = {
|
||||
args: { collapsed: true },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div
|
||||
style={{
|
||||
width: "3.5rem",
|
||||
background: "var(--c-bg)",
|
||||
padding: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
||||
|
||||
/** The footer's tooltips need Mantine's theme context. */
|
||||
function withProviders(ui: React.ReactNode) {
|
||||
return <MantineProvider>{ui}</MantineProvider>;
|
||||
}
|
||||
|
||||
function renderFooter() {
|
||||
const { container } = render(
|
||||
withProviders(
|
||||
<NavFooter
|
||||
displayName="admin"
|
||||
onOpenSettings={() => {}}
|
||||
credits={{ remaining: 247, total: 500 }}
|
||||
otherApp={{ app: "processor", onOpen: () => {} }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
return container.querySelector(".nav-footer") as HTMLElement;
|
||||
}
|
||||
|
||||
describe("NavFooter — enter animation", () => {
|
||||
it("plays once per page session, not on every remount", () => {
|
||||
// The rows are seeded from cache, so they're present at first paint. Every
|
||||
// later mount — switching apps, remounting a view — would otherwise replay
|
||||
// the fade on content that never changed, which reads as a twitch.
|
||||
expect(renderFooter().dataset.animate).toBe("true");
|
||||
cleanup();
|
||||
expect(renderFooter().dataset.animate).toBeUndefined();
|
||||
cleanup();
|
||||
expect(renderFooter().dataset.animate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavFooter — separators", () => {
|
||||
it("never renders a divider beside a row that renders nothing", () => {
|
||||
// Dividers are CSS between adjacent non-empty slots, so an extras element
|
||||
// that returns null (the linked org's link-account CTA) can't leave a line.
|
||||
const { container } = render(
|
||||
withProviders(
|
||||
<NavFooter
|
||||
displayName="admin"
|
||||
onOpenSettings={() => {}}
|
||||
credits={null}
|
||||
otherApp={null}
|
||||
accountExtras={<>{null}</>}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const slots = container.querySelectorAll(".nav-footer__slot");
|
||||
const filled = [...slots].filter((s) => s.childElementCount > 0);
|
||||
expect(filled).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".nav-footer__divider")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import { Avatar, NavSurface } from "@app/ui";
|
||||
import { BrandMark } from "@app/components/shared/BrandMark";
|
||||
import { type AppSwitchTarget } from "@app/components/shared/AppSwitch";
|
||||
import {
|
||||
NavFooterCreditsRow,
|
||||
type NavFooterCredits,
|
||||
} from "@app/components/shared/navFooter/NavFooterCreditsRow";
|
||||
import "@app/components/shared/navFooter/NavFooter.css";
|
||||
|
||||
export interface NavFooterAppLink {
|
||||
/** The app this footer is NOT in — the one the row opens. */
|
||||
app: AppSwitchTarget;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export interface NavFooterProps {
|
||||
/** Name shown next to the avatar, and the source of its initials fallback. */
|
||||
displayName: string;
|
||||
/** Profile picture; initials are drawn when absent or the URL fails to load. */
|
||||
profilePictureUrl?: string | null;
|
||||
/** Omit to render the account row as static text (no settings affordance). */
|
||||
onOpenSettings?: () => void;
|
||||
/** Null/undefined hides the meter — builds with no wallet never show it. */
|
||||
credits?: NavFooterCredits | null;
|
||||
/** Opens the plan surface from the credits row; omit to leave it inert. */
|
||||
onOpenPlan?: () => void;
|
||||
/** Null/undefined hides the switch row — e.g. no access to the other app. */
|
||||
otherApp?: NavFooterAppLink | null;
|
||||
/** Extra rows above the account row (the self-hosted link-account CTA). */
|
||||
accountExtras?: ReactNode;
|
||||
/** Icon-rail state: labels collapse to tooltips. */
|
||||
collapsed?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the enter animation has already played this page session. The rows
|
||||
* are seeded from cache now, so they're present from first paint and every
|
||||
* later mount — switching apps, remounting a view — would otherwise replay the
|
||||
* animation on content that never changed, which reads as the UI twitching.
|
||||
*/
|
||||
let hasPlayedEnter = false;
|
||||
|
||||
/**
|
||||
* The bottom section every sidebar ends with, shared by the editor and the
|
||||
* processor so both present the same rows. ONE surface, hairline-separated, in
|
||||
* this order:
|
||||
*
|
||||
* 1. caller-contributed rows (the self-hosted link-account CTA)
|
||||
* 2. free credits remaining
|
||||
* 3. "Open <the other app>"
|
||||
* 4. the account row — avatar, name, settings
|
||||
*
|
||||
* Purely presentational: each app resolves its own identity, wallet and
|
||||
* app-switch access and passes them in, so this file carries no build-specific
|
||||
* gating. A row whose data is absent is dropped, and so is the separator that
|
||||
* would have sat beside it.
|
||||
*/
|
||||
export function NavFooter({
|
||||
displayName,
|
||||
profilePictureUrl,
|
||||
onOpenSettings,
|
||||
credits,
|
||||
onOpenPlan,
|
||||
otherApp,
|
||||
accountExtras,
|
||||
collapsed = false,
|
||||
className,
|
||||
}: NavFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const [animate] = useState(() => {
|
||||
if (hasPlayedEnter) return false;
|
||||
hasPlayedEnter = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
const settingsLabel = t("fileSidebar.openSettings", "Open settings");
|
||||
const accountLabel = onOpenSettings
|
||||
? `${displayName} - ${settingsLabel}`
|
||||
: displayName;
|
||||
|
||||
// One surface, hairline-separated rows. Each row gets a slot; the separators
|
||||
// are drawn by CSS between adjacent NON-EMPTY slots (see NavFooter.css), so a
|
||||
// row that renders nothing — the link-account CTA returns null once the org is
|
||||
// linked, and an element is truthy even then — can't leave a line behind.
|
||||
const rows: Array<{ key: string; node: ReactNode }> = [];
|
||||
|
||||
if (accountExtras) rows.push({ key: "extras", node: accountExtras });
|
||||
|
||||
if (credits) {
|
||||
rows.push({
|
||||
key: "credits",
|
||||
node: (
|
||||
<NavFooterCreditsRow
|
||||
credits={credits}
|
||||
collapsed={collapsed}
|
||||
label={t("navFooter.credits.label", "Free credits")}
|
||||
onOpen={onOpenPlan}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (otherApp) {
|
||||
rows.push({
|
||||
key: "switch",
|
||||
node: (
|
||||
<Tooltip
|
||||
label={openAppLabel(otherApp.app, t)}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-footer__row"
|
||||
onClick={() => otherApp.onOpen()}
|
||||
// Collapsed drops the visible label, so name the button here too.
|
||||
aria-label={openAppLabel(otherApp.app, t)}
|
||||
>
|
||||
<span className="nav-footer__row-icon" aria-hidden>
|
||||
<BrandMark height="1.125rem" />
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="nav-footer__row-label">
|
||||
{openAppLabel(otherApp.app, t)}
|
||||
</span>
|
||||
{/* "Takes you there", not "opens a new tab" — both apps are
|
||||
one SPA, so this navigates in place. */}
|
||||
<span className="nav-footer__trailing" aria-hidden>
|
||||
<ArrowForwardIcon sx={{ fontSize: "1rem" }} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
rows.push({
|
||||
key: "account",
|
||||
node: (
|
||||
<Tooltip
|
||||
label={accountLabel}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-footer__row nav-footer__account"
|
||||
// Called with no args: handlers that take optional params (the
|
||||
// processor's openSettings(section?)) must not receive the event.
|
||||
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
|
||||
disabled={!onOpenSettings}
|
||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||
aria-label={accountLabel}
|
||||
>
|
||||
{/* Decorative: the button's own label already names the account, so
|
||||
an alt/label here would just repeat it to a screen reader. */}
|
||||
<span aria-hidden>
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={displayName}
|
||||
src={profilePictureUrl ?? undefined}
|
||||
/>
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="nav-footer__row-label sidebar-content-fade">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{onOpenSettings && !collapsed && (
|
||||
<span className="nav-footer__trailing" aria-hidden>
|
||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<NavSurface
|
||||
className={["nav-footer", className ?? ""].filter(Boolean).join(" ")}
|
||||
data-collapsed={collapsed || undefined}
|
||||
data-animate={animate || undefined}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="nav-footer__slot">
|
||||
{row.node}
|
||||
</div>
|
||||
))}
|
||||
</NavSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function openAppLabel(
|
||||
app: AppSwitchTarget,
|
||||
t: (key: string, fallback: string) => string,
|
||||
): string {
|
||||
return app === "editor"
|
||||
? t("navFooter.openEditor", "Open PDF Editor")
|
||||
: t("navFooter.openProcessor", "Open PDF Processor");
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/* Free-credits meter inside the sidebar footer. The row base (padding, hover,
|
||||
focus) comes from NavFooter.css; these rules are the meter itself. */
|
||||
|
||||
.nav-footer__credits {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.375rem;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Inert by default, so it must not read as hoverable; the actionable variant
|
||||
opts back into the shared row hover. */
|
||||
.nav-footer__credits:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.nav-footer__credits--actionable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.nav-footer__credits--actionable:hover {
|
||||
background-color: var(--c-hover);
|
||||
}
|
||||
|
||||
.nav-footer__credits-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.nav-footer__dot {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--c-success);
|
||||
}
|
||||
.nav-footer__dot[data-tone="warning"] {
|
||||
background-color: var(--c-warning);
|
||||
}
|
||||
.nav-footer__dot[data-tone="danger"] {
|
||||
background-color: var(--c-danger);
|
||||
}
|
||||
|
||||
.nav-footer__credits-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
color: var(--c-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nav-footer__credits-count {
|
||||
flex-shrink: 0;
|
||||
color: var(--c-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- Collapsed rail ---- */
|
||||
|
||||
/* Rotated so the fill starts at 12 o'clock and runs clockwise. */
|
||||
.nav-footer__credits-ring {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-inline: auto;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.nav-footer__credits-ring-track,
|
||||
.nav-footer__credits-ring-fill {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.nav-footer__credits-ring-track {
|
||||
stroke: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.nav-footer__credits-ring-fill {
|
||||
stroke-linecap: round;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { ProgressBar } from "@app/ui";
|
||||
import "@app/components/shared/navFooter/NavFooterCreditsRow.css";
|
||||
|
||||
export interface NavFooterCredits {
|
||||
/** Free credits still available to spend. */
|
||||
remaining: number;
|
||||
/** Size of the free allowance — the "of N" denominator. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Remaining-credit bands, mirroring the usage meters' 80% / 100% thresholds. */
|
||||
function creditsTone(remaining: number, total: number): string {
|
||||
if (remaining <= 0) return "danger";
|
||||
return total > 0 && remaining / total <= 0.2 ? "warning" : "success";
|
||||
}
|
||||
|
||||
interface NavFooterCreditsRowProps {
|
||||
credits: NavFooterCredits;
|
||||
/** Icon rail: the figures drop and the bar alone carries the state. */
|
||||
collapsed: boolean;
|
||||
/** Row label, passed in so the meter owns no copy of its own. */
|
||||
label: string;
|
||||
/** Opens the plan surface. Omit to render the meter as inert text. */
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The free-credits meter as it appears in the sidebar footer: a state dot, the
|
||||
* label, "X of Y" remaining, and a fill bar underneath. Figures are clamped
|
||||
* here so a wallet that reports more remaining than the allowance (or negative)
|
||||
* can't overflow the bar.
|
||||
*
|
||||
* Rendered as a {@code nav-footer__row}, so it inherits that row's metrics
|
||||
* from NavFooter.css and only brings its own meter styling.
|
||||
*/
|
||||
export function NavFooterCreditsRow({
|
||||
credits,
|
||||
collapsed,
|
||||
label,
|
||||
onOpen,
|
||||
}: NavFooterCreditsRowProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const total = Math.max(0, credits.total);
|
||||
const remaining = Math.min(Math.max(0, credits.remaining), total);
|
||||
const tone = creditsTone(remaining, total);
|
||||
const count = t("navFooter.credits.count", "{{remaining}} of {{total}}", {
|
||||
remaining: remaining.toLocaleString(),
|
||||
total: total.toLocaleString(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${label}: ${count}`}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<Row onOpen={onOpen} label={`${label}: ${count}`}>
|
||||
{collapsed ? (
|
||||
// The rail is one icon wide, so a full-width bar would read as a
|
||||
// stray line; a ring carries the same fraction at icon size.
|
||||
<CreditsRing
|
||||
fraction={total > 0 ? remaining / total : 0}
|
||||
tone={tone}
|
||||
label={`${label}: ${count}`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="nav-footer__credits-head">
|
||||
<span className="nav-footer__dot" data-tone={tone} aria-hidden />
|
||||
<span className="nav-footer__credits-label">{label}</span>
|
||||
<span className="nav-footer__credits-count">{count}</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={total > 0 ? remaining / total : 0}
|
||||
height={6}
|
||||
color={`var(--c-${tone})`}
|
||||
label={`${label}: ${count}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** Icon-sized donut carrying the same remaining fraction as the expanded bar. */
|
||||
function CreditsRing({
|
||||
fraction,
|
||||
tone,
|
||||
label,
|
||||
}: {
|
||||
fraction: number;
|
||||
tone: string;
|
||||
label: string;
|
||||
}) {
|
||||
const RADIUS = 8;
|
||||
const circumference = 2 * Math.PI * RADIUS;
|
||||
const filled = Math.min(1, Math.max(0, fraction)) * circumference;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="nav-footer__credits-ring"
|
||||
viewBox="0 0 20 20"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
<circle
|
||||
className="nav-footer__credits-ring-track"
|
||||
cx="10"
|
||||
cy="10"
|
||||
r={RADIUS}
|
||||
/>
|
||||
<circle
|
||||
className="nav-footer__credits-ring-fill"
|
||||
cx="10"
|
||||
cy="10"
|
||||
r={RADIUS}
|
||||
stroke={`var(--c-${tone})`}
|
||||
strokeDasharray={`${filled} ${circumference - filled}`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The meter is a button only where there is a plan surface to open — otherwise
|
||||
* it stays a plain div, so a build with nowhere to go doesn't advertise a
|
||||
* click that does nothing.
|
||||
*/
|
||||
function Row({
|
||||
onOpen,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
onOpen?: () => void;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const className = `nav-footer__row nav-footer__credits${
|
||||
onOpen ? " nav-footer__credits--actionable" : ""
|
||||
}`;
|
||||
if (!onOpen) return <div className={className}>{children}</div>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => onOpen()}
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+94
-59
@@ -1,5 +1,7 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Stack, Divider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ViewerContext } from "@app/contexts/ViewerContext";
|
||||
import {
|
||||
ChangeMetadataParameters,
|
||||
createCustomMetadataFunctions,
|
||||
@@ -19,6 +21,31 @@ interface ChangeMetadataSingleStepProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fills the form from the currently open document's existing metadata.
|
||||
* Isolated in its own component so it only mounts where a ViewerProvider exists
|
||||
* (the editor and the in-editor Automate modal). The pipeline builder has no
|
||||
* viewer and no single "current document", so it is skipped there rather than
|
||||
* crashing on useViewer.
|
||||
*/
|
||||
const MetadataPrefill = ({
|
||||
onParameterChange,
|
||||
onExtractingChange,
|
||||
}: {
|
||||
onParameterChange: ChangeMetadataSingleStepProps["onParameterChange"];
|
||||
onExtractingChange: (extracting: boolean) => void;
|
||||
}) => {
|
||||
const { isExtractingMetadata } = useMetadataExtraction({
|
||||
updateParameter: onParameterChange,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onExtractingChange(isExtractingMetadata);
|
||||
}, [isExtractingMetadata, onExtractingChange]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const ChangeMetadataSingleStep = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
@@ -26,77 +53,85 @@ const ChangeMetadataSingleStep = ({
|
||||
}: ChangeMetadataSingleStepProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Auto-prefill reads the viewer/file contexts, which only exist in the editor.
|
||||
// Gate on the viewer so the pipeline builder renders the fields without it.
|
||||
const hasViewerContext = useContext(ViewerContext) !== null;
|
||||
const [isExtractingMetadata, setIsExtractingMetadata] = useState(false);
|
||||
|
||||
// Get custom metadata functions using the utility
|
||||
const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } =
|
||||
createCustomMetadataFunctions(parameters, onParameterChange);
|
||||
|
||||
// Extract metadata from uploaded files
|
||||
const { isExtractingMetadata } = useMetadataExtraction({
|
||||
updateParameter: onParameterChange,
|
||||
});
|
||||
|
||||
const isDeleteAllEnabled = parameters.deleteAll;
|
||||
const fieldsDisabled = disabled || isDeleteAllEnabled || isExtractingMetadata;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Delete All */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.deleteAll.label", "Delete All Metadata")}
|
||||
</Text>
|
||||
<DeleteAllStep
|
||||
parameters={parameters}
|
||||
<>
|
||||
{hasViewerContext && (
|
||||
<MetadataPrefill
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
onExtractingChange={setIsExtractingMetadata}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Standard Metadata Fields */}
|
||||
)}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.standardFields.title", "Standard Metadata")}
|
||||
</Text>
|
||||
<StandardMetadataStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
/>
|
||||
{/* Delete All */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.deleteAll.label", "Delete All Metadata")}
|
||||
</Text>
|
||||
<DeleteAllStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Standard Metadata Fields */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.standardFields.title", "Standard Metadata")}
|
||||
</Text>
|
||||
<StandardMetadataStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Document Dates */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.dates.title", "Document Dates")}
|
||||
</Text>
|
||||
<DocumentDatesStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.advanced.title", "Advanced Options")}
|
||||
</Text>
|
||||
<AdvancedOptionsStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
addCustomMetadata={addCustomMetadata}
|
||||
removeCustomMetadata={removeCustomMetadata}
|
||||
updateCustomMetadata={updateCustomMetadata}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Document Dates */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.dates.title", "Document Dates")}
|
||||
</Text>
|
||||
<DocumentDatesStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("changeMetadata.advanced.title", "Advanced Options")}
|
||||
</Text>
|
||||
<AdvancedOptionsStep
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={fieldsDisabled}
|
||||
addCustomMetadata={addCustomMetadata}
|
||||
removeCustomMetadata={removeCustomMetadata}
|
||||
updateCustomMetadata={updateCustomMetadata}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useContext, useRef } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { FilePicker } from "@app/ui/FilePicker";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,7 +17,7 @@ import {
|
||||
type OverlayMode,
|
||||
} from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import { FilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import styles from "@app/components/tools/overlayPdfs/OverlayPdfsSettings.module.css";
|
||||
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
|
||||
|
||||
@@ -34,7 +36,12 @@ export default function OverlayPdfsSettings({
|
||||
disabled = false,
|
||||
}: OverlayPdfsSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
// Read optionally: the portal pipeline builder mounts no FilesModalProvider.
|
||||
// Present (editor tool + Automate modal) -> keep the workspace file picker;
|
||||
// absent (portal) -> fall back to the plain file input below.
|
||||
const filesModal = useContext(FilesModalContext);
|
||||
// Clears the FilePicker so the same file can be re-selected (Mantine resetRef).
|
||||
const resetOverlayPicker = useRef<() => void>(null);
|
||||
|
||||
const handleOverlayFilesChange = (files: File[]) => {
|
||||
onParameterChange("overlayFiles", files);
|
||||
@@ -66,8 +73,8 @@ export default function OverlayPdfsSettings({
|
||||
};
|
||||
|
||||
const handleOpenOverlayFilesModal = () => {
|
||||
if (disabled) return;
|
||||
openFilesModal({
|
||||
if (disabled || !filesModal) return;
|
||||
filesModal.openFilesModal({
|
||||
customHandler: (files: File[]) => {
|
||||
handleOverlayFilesChange([
|
||||
...(parameters.overlayFiles || []),
|
||||
@@ -77,6 +84,17 @@ export default function OverlayPdfsSettings({
|
||||
});
|
||||
};
|
||||
|
||||
const appendOverlayFiles = (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]);
|
||||
resetOverlayPicker.current?.();
|
||||
};
|
||||
|
||||
const overlayFilesButtonLabel =
|
||||
parameters.overlayFiles?.length > 0
|
||||
? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...")
|
||||
: t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)...");
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Stack gap="xs">
|
||||
@@ -183,17 +201,30 @@ export default function OverlayPdfsSettings({
|
||||
<Text size="sm" fw={500}>
|
||||
{t("overlay-pdfs.overlayFiles.label", "Overlay Files")}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleOpenOverlayFilesModal}
|
||||
disabled={disabled}
|
||||
leftSection={<LocalIcon icon="add" width="14" height="14" />}
|
||||
fullWidth
|
||||
>
|
||||
{parameters.overlayFiles?.length > 0
|
||||
? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...")
|
||||
: t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)...")}
|
||||
</Button>
|
||||
{filesModal ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleOpenOverlayFilesModal}
|
||||
disabled={disabled}
|
||||
leftSection={<LocalIcon icon="add" width="14" height="14" />}
|
||||
fullWidth
|
||||
>
|
||||
{overlayFilesButtonLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<FilePicker
|
||||
multiple
|
||||
accept="application/pdf"
|
||||
onChange={appendOverlayFiles}
|
||||
resetRef={resetOverlayPicker}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
leftSection={<LocalIcon icon="add" width="14" height="14" />}
|
||||
fullWidth
|
||||
>
|
||||
{overlayFilesButtonLabel}
|
||||
</FilePicker>
|
||||
)}
|
||||
|
||||
{parameters.overlayFiles?.length > 0 &&
|
||||
(() => {
|
||||
|
||||
@@ -41,7 +41,9 @@ interface FilesModalContextType {
|
||||
setOnModalClose: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||
export const FilesModalContext = createContext<FilesModalContextType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
|
||||
@@ -30,4 +30,15 @@ describe("automatable tools", () => {
|
||||
|
||||
expect(offeredWithoutConfig).toEqual([]);
|
||||
});
|
||||
|
||||
// Reorganize Pages has an automatable form (organization mode + page-order string) and a
|
||||
// context-free settings component, but its registry entry once left automationSettings null,
|
||||
// so both Automate and the pipeline builder showed "no configurable settings". Guard the wiring.
|
||||
test("Reorganize Pages exposes automation settings so it is configurable, not no-settings", () => {
|
||||
const { result } = renderHook(() => useTranslatedToolCatalog());
|
||||
|
||||
expect(
|
||||
result.current.regularTools.reorganizePages?.automationSettings,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -700,7 +700,10 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
endpoints: ["rearrange-pages"],
|
||||
operationConfig: asRegistryConfig(reorganizePagesOperationConfig),
|
||||
synonyms: getSynonyms(t, "reorganizePages"),
|
||||
automationSettings: null,
|
||||
automationSettings: lazySettings(
|
||||
() =>
|
||||
import("@app/components/tools/reorganizePages/ReorganizePagesSettings"),
|
||||
),
|
||||
},
|
||||
scalePages: {
|
||||
icon: (
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { accountService } from "@app/services/accountService";
|
||||
|
||||
export interface AccountIdentity {
|
||||
/** Never empty — falls back to a generic "User" so a row is never blank. */
|
||||
displayName: string;
|
||||
profilePictureUrl: string | null;
|
||||
isAnonymous: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in identity as the UI should draw it: one name and one picture,
|
||||
* resolved the same way everywhere. Every surface that shows "who am I" (the
|
||||
* editor and processor sidebar footers, the account settings page) reads this,
|
||||
* so a user can't see one initial in the sidebar and a different one in
|
||||
* settings.
|
||||
*
|
||||
* Resolution order for the name: the auth layer's own displayName (each layer
|
||||
* derives it from its native user shape), then the proprietary REST endpoint,
|
||||
* then a generic last resort.
|
||||
*/
|
||||
export function useAccountIdentity(): AccountIdentity {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { displayName: authDisplayName, isAnonymous } = useAuth();
|
||||
const profilePictureUrl = useProfilePictureUrl();
|
||||
const [accountUsername, setAccountUsername] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.enableLogin) {
|
||||
setAccountUsername(null);
|
||||
return;
|
||||
}
|
||||
if (authDisplayName) {
|
||||
// The auth context has a name; don't bother hitting the REST
|
||||
// endpoint, but clear any stale cached value from a prior call.
|
||||
setAccountUsername(null);
|
||||
return;
|
||||
}
|
||||
accountService
|
||||
.getAccountData()
|
||||
.then((data) => {
|
||||
// Always reflect the latest result - including clearing it on
|
||||
// sign-out, when the endpoint returns no username (or 401s into
|
||||
// the catch branch below). Without this, signing out would leave
|
||||
// the old username on screen.
|
||||
setAccountUsername(data?.username ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
setAccountUsername(null);
|
||||
});
|
||||
}, [config?.enableLogin, authDisplayName]);
|
||||
|
||||
return {
|
||||
displayName:
|
||||
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"),
|
||||
profilePictureUrl,
|
||||
isAnonymous,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
|
||||
|
||||
/**
|
||||
* Free credits left on this team's allowance, for the sidebar footer meter.
|
||||
* Null hides the meter entirely.
|
||||
*
|
||||
* Core has no wallet — self-hosted installs aren't metered — so there is
|
||||
* nothing to show. Cloud builds override this with the live wallet figure.
|
||||
*/
|
||||
export function useFreeCreditsSummary(): NavFooterCredits | null {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Opens the plan surface behind the sidebar footer's free-credits row, or null
|
||||
* when this build has none (the row is then inert text rather than a button).
|
||||
*
|
||||
* Core ships no wallet and no plan section, so there is nothing to open. Builds
|
||||
* that meter usage override this with their own surface.
|
||||
*/
|
||||
export function useOpenPlan(): (() => void) | null {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
|
||||
|
||||
/**
|
||||
* The sibling app this build can switch to (editor ⇄ processor), or null when
|
||||
* there is none. The single gate behind both the brand switcher and the
|
||||
* sidebar footer's "Open ..." row, so the two can never disagree about access.
|
||||
*
|
||||
* Core ships no processor, so there is nothing to switch to.
|
||||
*/
|
||||
export function useOtherAppSwitch(): NavFooterAppLink | null {
|
||||
return null;
|
||||
}
|
||||
@@ -6,5 +6,8 @@ export const qk = {
|
||||
["editor", "endpointEnabled", endpoint] as const,
|
||||
footerInfo: () => ["editor", "footerInfo"] as const,
|
||||
groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const,
|
||||
/** Keyed on the asking identity: two users must never share one answer. */
|
||||
portalAccess: (userId: string | null) =>
|
||||
["editor", "portalAccess", userId] as const,
|
||||
users: () => ["editor", "users"] as const,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Last-known sidebar-footer state, so the rows are correct at first paint
|
||||
* instead of arriving a request later.
|
||||
*
|
||||
* The footer is mounted by both apps, and the editor and processor are separate
|
||||
* React trees with separate query caches — so without this, every navigation
|
||||
* between them (and every remount inside them) re-ran the fetches and the rows
|
||||
* visibly popped in and shoved each other around. Persisting to storage rather
|
||||
* than to an in-memory cache is what makes it survive that boundary, and a
|
||||
* reload.
|
||||
*
|
||||
* Deliberately stale-then-revalidate: what's stored is only ever what the
|
||||
* backend last said, every reader refetches immediately and overwrites, and
|
||||
* nothing is gated on it — the processor enforces its own access server-side,
|
||||
* and a stale credit figure is replaced within a second of the wallet landing.
|
||||
*/
|
||||
const CREDITS_KEY = "stirling.navFooter.credits";
|
||||
const OTHER_APP_KEY = "stirling.navFooter.otherApp";
|
||||
|
||||
/** Figures, or null for a team that sees no meter at all (a paying one). */
|
||||
export type CachedCredits = { remaining: number; total: number } | null;
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
// Private mode / storage disabled — behave as a first-ever load.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Nothing to do: the cache is an optimisation, never a correctness input.
|
||||
}
|
||||
}
|
||||
|
||||
/** `undefined` when this browser has never seen an answer. */
|
||||
export function readCachedCredits(): CachedCredits | undefined {
|
||||
const raw = read(CREDITS_KEY);
|
||||
if (raw === null) return undefined;
|
||||
if (raw === "none") return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
typeof (parsed as CachedCredits & object).remaining === "number" &&
|
||||
typeof (parsed as CachedCredits & object).total === "number"
|
||||
) {
|
||||
return parsed as CachedCredits;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt entry — fall through and treat it as never-seen.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function writeCachedCredits(credits: CachedCredits): void {
|
||||
write(CREDITS_KEY, credits === null ? "none" : JSON.stringify(credits));
|
||||
}
|
||||
|
||||
/** `undefined` when this browser has never seen an answer. */
|
||||
export function readCachedOtherApp(): boolean | undefined {
|
||||
const raw = read(OTHER_APP_KEY);
|
||||
return raw === null ? undefined : raw === "true";
|
||||
}
|
||||
|
||||
export function writeCachedOtherApp(canOpen: boolean): void {
|
||||
write(OTHER_APP_KEY, String(canOpen));
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export class UpdateService {
|
||||
*/
|
||||
async getCurrentVersionFromGitHub(): Promise<string> {
|
||||
const url =
|
||||
"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/V2-master/build.gradle";
|
||||
"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/release/build.gradle";
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
@@ -32,6 +32,9 @@ html[data-app-theme="light"] {
|
||||
--c-primary: var(--p-blue-500);
|
||||
--c-primary-hover: var(--p-blue-600);
|
||||
--c-primary-subtle: color-mix(in srgb, var(--p-blue-500) 10%, transparent);
|
||||
/* Accent used as TEXT (links, toggles): a deeper hue that clears 4.5:1 on
|
||||
light surfaces, where --c-primary itself does not. */
|
||||
--c-accent-text: var(--p-blue-700);
|
||||
|
||||
--c-success: var(--p-green-600);
|
||||
--c-danger: var(--p-red-600);
|
||||
@@ -68,6 +71,11 @@ html[data-app-theme="light"] {
|
||||
var(--c-success) 10%,
|
||||
var(--c-surface)
|
||||
);
|
||||
--c-warning-subtle: color-mix(
|
||||
in srgb,
|
||||
var(--c-warning) 10%,
|
||||
var(--c-surface)
|
||||
);
|
||||
|
||||
/* ── Decorative / brand / categorical palette ──────────────────────────
|
||||
Fixed hues that intentionally do NOT follow the chosen accent: brand
|
||||
@@ -144,6 +152,8 @@ html[data-app-theme="midnight"] {
|
||||
--c-text-muted: var(--p-zinc-200);
|
||||
--c-text-subtle: var(--p-zinc-250);
|
||||
--c-text-on-primary: var(--p-white);
|
||||
/* Accent used as TEXT on dark surfaces: a lighter step for 4.5:1. */
|
||||
--c-accent-text: var(--p-blue-400);
|
||||
--c-btn-solid: var(--c-text);
|
||||
--c-btn-inverse: var(--p-ink);
|
||||
--c-btn-secondary: var(--p-c-1a1a1d);
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
/* ── Z-index ladder ── */
|
||||
--z-dropdown: 25;
|
||||
--z-drawer: 50;
|
||||
--z-popover: 150;
|
||||
--z-toast: 200;
|
||||
/* Fullscreen tool-picker surfaces (editor) */
|
||||
--z-fullscreen-icon-svg: 1;
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
height: 2.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
/* Account-settings hero disc. */
|
||||
.sui-avatar--xl {
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.sui-avatar__img {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import "@app/ui/Avatar.css";
|
||||
|
||||
export type AvatarSize = "xs" | "sm" | "md" | "lg";
|
||||
export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
|
||||
export type AvatarTone =
|
||||
| "blue"
|
||||
| "purple"
|
||||
@@ -23,10 +24,12 @@ export interface AvatarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
function avatarInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
// Single word (a username or an email) reads as one letter — two letters of
|
||||
// "admin" ("AD") looks like a different person's initials, not a truncation.
|
||||
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
@@ -43,6 +46,13 @@ export function Avatar({
|
||||
ariaLabel,
|
||||
className,
|
||||
}: AvatarProps) {
|
||||
// A picture URL that 404s (expired signed URL, deleted upload) must not leave
|
||||
// an empty disc — fall back to the same initials the no-picture case shows, so
|
||||
// every surface rendering this identity agrees on what it draws.
|
||||
const [srcFailed, setSrcFailed] = useState(false);
|
||||
useEffect(() => setSrcFailed(false), [src]);
|
||||
const showImage = Boolean(src) && !srcFailed;
|
||||
|
||||
const classes = [
|
||||
"sui-avatar",
|
||||
`sui-avatar--${size}`,
|
||||
@@ -53,11 +63,16 @@ export function Avatar({
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const content = src ? (
|
||||
<img src={src} alt={ariaLabel ?? name} className="sui-avatar__img" />
|
||||
const content = showImage ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={ariaLabel ?? name}
|
||||
className="sui-avatar__img"
|
||||
onError={() => setSrcFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="sui-avatar__initials" aria-hidden>
|
||||
{initialsOf(name)}
|
||||
{avatarInitials(name)}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
/*
|
||||
* DataTable: the one Stirling table. Appearance is owned entirely here: the
|
||||
* canonical surface, the header/row grammar, the standardized loading / empty /
|
||||
* error states, and every cell KIND (`sui-dtc__*`). Call-sites choose a kind and
|
||||
* supply data; they never style a cell. This is what lets every table look and
|
||||
* behave the same.
|
||||
*/
|
||||
|
||||
.sui-datatable {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The one canonical surface: every table wears it; call-sites don't wrap it. */
|
||||
.sui-datatable__frame {
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--c-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sui-datatable__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.sui-datatable__scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sui-datatable__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.sui-datatable__caption {
|
||||
caption-side: top;
|
||||
text-align: left;
|
||||
padding: 0.625rem 0.875rem;
|
||||
color: var(--c-text-subtle);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.sui-datatable__th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--c-text-subtle);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sui-datatable__th--right {
|
||||
text-align: right;
|
||||
}
|
||||
.sui-datatable__th--fit {
|
||||
width: 1%;
|
||||
}
|
||||
/* Accessible-only header text for blank affordance/action columns. */
|
||||
.sui-datatable__th-sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sui-datatable__sort {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.sui-datatable__th--right .sui-datatable__sort {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.sui-datatable__sort:hover {
|
||||
color: var(--c-text);
|
||||
}
|
||||
.sui-datatable__sort:focus-visible {
|
||||
outline: 0.125rem solid var(--c-primary);
|
||||
outline-offset: 0.125rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.sui-datatable__sort-icon {
|
||||
display: inline-flex;
|
||||
color: var(--c-text-subtle);
|
||||
opacity: 0;
|
||||
transition: opacity var(--motion-fast);
|
||||
}
|
||||
.sui-datatable__sort:hover .sui-datatable__sort-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.sui-datatable__sort-icon--asc,
|
||||
.sui-datatable__sort-icon--desc {
|
||||
opacity: 1;
|
||||
color: var(--c-primary);
|
||||
}
|
||||
.sui-datatable__sort-icon--desc svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.sui-datatable__td {
|
||||
padding: 0.625rem 0.875rem;
|
||||
color: var(--c-text-muted);
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sui-datatable__table tbody tr:last-child .sui-datatable__td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.sui-datatable__td--right {
|
||||
text-align: right;
|
||||
}
|
||||
.sui-datatable__td--nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sui-datatable__td--fit {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Density (the compact variant) */
|
||||
.sui-datatable--compact .sui-datatable__th,
|
||||
.sui-datatable--compact .sui-datatable__td {
|
||||
padding: 0.375rem 0.625rem;
|
||||
}
|
||||
|
||||
/* Interactive rows */
|
||||
.sui-datatable__row--interactive {
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.sui-datatable__row--interactive:hover {
|
||||
background: var(--c-hover);
|
||||
}
|
||||
.sui-datatable__row--interactive:focus-visible {
|
||||
outline: 0.125rem solid var(--c-primary);
|
||||
outline-offset: -0.125rem;
|
||||
}
|
||||
|
||||
/* Non-actionable rows (e.g. coming-soon): de-emphasized to read as disabled.
|
||||
Muting is done with accessible colours + a faded (non-text) icon rather than
|
||||
row opacity, which would blend text under the WCAG contrast floor. */
|
||||
.sui-datatable__row--muted .sui-dtc__entity-name {
|
||||
color: var(--c-text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sui-datatable__row--muted .sui-dtc__entity-icon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sui-datatable__chevron {
|
||||
display: inline-flex;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
/* Full-width state cell (empty / error) */
|
||||
.sui-datatable__state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
.sui-datatable__state--error {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
.sui-datatable__state--node {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Cell kinds (sui-dtc__*): the locked cell vocabulary. These absorb the
|
||||
* per-namespace portal-*__ cell styles (mono / muted / cell-stack / name-cell /
|
||||
* caret …) that every table used to re-implement.
|
||||
*/
|
||||
.sui-dtc__text {
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
/* Labeled value ("Category: action"): full-strength text, bold label. */
|
||||
.sui-dtc__text--labeled {
|
||||
color: var(--c-text);
|
||||
}
|
||||
.sui-dtc__text-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
.sui-dtc__mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
.sui-dtc__muted {
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
.sui-dtc__num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sui-dtc__labels {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* entity: leading icon + bold name + optional muted note */
|
||||
.sui-dtc__entity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.sui-dtc__entity-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
/* The component sizes bare icons; self-styled marks (Avatar) render as-is. */
|
||||
.sui-dtc__entity-icon svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
.sui-dtc__entity-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1875rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.sui-dtc__entity-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.sui-dtc__entity-name {
|
||||
font-weight: 600;
|
||||
color: var(--c-text);
|
||||
}
|
||||
.sui-dtc__entity-suffix {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
.sui-dtc__note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
.sui-dtc__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.sui-dtc__menu-item--danger {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
/* Grouped tables: section header row + per-group "show all" toggle. */
|
||||
.sui-datatable__group-cell {
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: var(--c-surface-sunken, var(--c-hover));
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
.sui-datatable__table
|
||||
tbody
|
||||
tr.sui-datatable__group:not(:first-child)
|
||||
.sui-datatable__group-cell {
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
.sui-datatable__group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.sui-datatable__group-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.sui-datatable__group-title strong {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--c-text);
|
||||
}
|
||||
.sui-datatable__group-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
.sui-datatable__group-more {
|
||||
padding: 0.5rem 0.875rem;
|
||||
}
|
||||
.sui-datatable__show-all {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
.sui-datatable__show-all:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sui-dtc__progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.sui-dtc__progress-bar {
|
||||
display: block;
|
||||
width: 5rem;
|
||||
}
|
||||
.sui-dtc__progress-pct {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--c-text-muted);
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
|
||||
.sui-dtc__links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
/* Neutral chip-style link (matches the invoice-link look): the colour is not an
|
||||
* accent - the trailing glyph + hover fill carry the affordance. */
|
||||
.sui-dtc__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--c-text);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
border-color var(--motion-fast);
|
||||
}
|
||||
.sui-dtc__link:hover {
|
||||
background: var(--c-surface-sunken);
|
||||
border-color: var(--c-border);
|
||||
}
|
||||
.sui-dtc__link:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.sui-dtc__select {
|
||||
min-width: 13rem;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { column, DataTable, type DataTableColumn } from "@app/ui/DataTable";
|
||||
|
||||
interface Region {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
status: "healthy" | "degraded";
|
||||
docs: number;
|
||||
latency: number;
|
||||
auto: boolean;
|
||||
}
|
||||
|
||||
const REGIONS: Region[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "US East",
|
||||
code: "us-east-1",
|
||||
status: "healthy",
|
||||
docs: 12481,
|
||||
latency: 41,
|
||||
auto: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "US West",
|
||||
code: "us-west-2",
|
||||
status: "healthy",
|
||||
docs: 8210,
|
||||
latency: 63,
|
||||
auto: false,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "EU West",
|
||||
code: "eu-west-1",
|
||||
status: "degraded",
|
||||
docs: 3044,
|
||||
latency: 190,
|
||||
auto: true,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "AP South",
|
||||
code: "ap-south-1",
|
||||
status: "healthy",
|
||||
docs: 5622,
|
||||
latency: 88,
|
||||
auto: false,
|
||||
},
|
||||
];
|
||||
|
||||
const tone = (r: Region) => ({
|
||||
tone: r.status === "healthy" ? ("success" as const) : ("warning" as const),
|
||||
label: r.status,
|
||||
});
|
||||
|
||||
const COLUMNS: DataTableColumn<Region>[] = [
|
||||
column.entity({
|
||||
key: "name",
|
||||
header: "Region",
|
||||
primary: (r) => r.name,
|
||||
}),
|
||||
column.mono({ key: "code", header: "Code", get: (r) => r.code }),
|
||||
column.badge({ key: "status", header: "Status", get: tone }),
|
||||
column.number({
|
||||
key: "docs",
|
||||
header: "Docs 24h",
|
||||
get: (r) => r.docs,
|
||||
format: (n) => n.toLocaleString(),
|
||||
}),
|
||||
column.number({
|
||||
key: "latency",
|
||||
header: "P95",
|
||||
get: (r) => r.latency,
|
||||
format: (n) => `${n} ms`,
|
||||
}),
|
||||
];
|
||||
|
||||
const SORTABLE_COLUMNS: DataTableColumn<Region>[] = [
|
||||
column.entity({
|
||||
key: "name",
|
||||
header: "Region",
|
||||
primary: (r) => r.name,
|
||||
sortable: true,
|
||||
}),
|
||||
column.mono({
|
||||
key: "code",
|
||||
header: "Code",
|
||||
get: (r) => r.code,
|
||||
sortable: true,
|
||||
}),
|
||||
column.badge({ key: "status", header: "Status", get: tone, sortable: true }),
|
||||
column.number({
|
||||
key: "docs",
|
||||
header: "Docs 24h",
|
||||
get: (r) => r.docs,
|
||||
format: (n) => n.toLocaleString(),
|
||||
sortable: true,
|
||||
}),
|
||||
column.number({
|
||||
key: "latency",
|
||||
header: "P95",
|
||||
get: (r) => r.latency,
|
||||
format: (n) => `${n} ms`,
|
||||
sortable: true,
|
||||
}),
|
||||
];
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "Compound/DataTable",
|
||||
component: DataTable,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
/** Columns come from the `column` vocabulary; call-sites never style a cell. */
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region> columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} />
|
||||
),
|
||||
};
|
||||
|
||||
/** Sorting is opt-in per column (`sortable: true`); click a header. */
|
||||
export const Sortable: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={SORTABLE_COLUMNS}
|
||||
rows={REGIONS}
|
||||
rowKey={(r) => r.id}
|
||||
defaultSort={{ key: "docs", direction: "desc" }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** `onRowClick` + a chevron affordance. The line above shows the click land. */
|
||||
export const Interactive: Story = {
|
||||
render: () => {
|
||||
function Bound() {
|
||||
const [clicked, setClicked] = useState<Region | null>(null);
|
||||
return (
|
||||
<>
|
||||
<p
|
||||
style={{
|
||||
marginBottom: "0.75rem",
|
||||
fontSize: "0.8125rem",
|
||||
color: "var(--c-text-muted)",
|
||||
}}
|
||||
>
|
||||
{clicked
|
||||
? `Clicked: ${clicked.name} (${clicked.code})`
|
||||
: "Click a row to fire onRowClick."}
|
||||
</p>
|
||||
<DataTable<Region>
|
||||
columns={COLUMNS}
|
||||
rows={REGIONS}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={setClicked}
|
||||
rowAffordance="chevron"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <Bound />;
|
||||
},
|
||||
};
|
||||
|
||||
/** A trailing action column (icon-only kebab), locked to the design system. */
|
||||
export const WithActions: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={[
|
||||
...COLUMNS,
|
||||
column.actions({
|
||||
key: "actions",
|
||||
get: () => [
|
||||
{
|
||||
label: "Row actions",
|
||||
glyph: "kebab",
|
||||
iconOnly: true,
|
||||
onClick: () => {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]}
|
||||
rows={REGIONS}
|
||||
rowKey={(r) => r.id}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** First-load skeleton mirrors the real column layout. */
|
||||
export const Loading: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={COLUMNS}
|
||||
rows={[]}
|
||||
rowKey={(r) => r.id}
|
||||
loading
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Standardized empty slot. */
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={COLUMNS}
|
||||
rows={[]}
|
||||
rowKey={(r) => r.id}
|
||||
empty="No regions deployed yet."
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Standardized error slot (announced as an alert). */
|
||||
export const ErrorState: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={COLUMNS}
|
||||
rows={[]}
|
||||
rowKey={(r) => r.id}
|
||||
error="Couldn't load regions. Try again."
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Optional toolbar slot above the table, inside the surface. */
|
||||
export const WithToolbar: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={COLUMNS}
|
||||
rows={REGIONS}
|
||||
rowKey={(r) => r.id}
|
||||
toolbar={
|
||||
<>
|
||||
<strong style={{ fontSize: "0.8125rem" }}>Regions</strong>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button variant="secondary" size="sm">
|
||||
Export
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** The one look choice: the `compact` variant. */
|
||||
export const Compact: Story = {
|
||||
render: () => (
|
||||
<DataTable<Region>
|
||||
columns={COLUMNS}
|
||||
rows={REGIONS}
|
||||
rowKey={(r) => r.id}
|
||||
variant="compact"
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,516 @@
|
||||
import { type KeyboardEvent, type ReactNode, useMemo, useState } from "react";
|
||||
import {
|
||||
type ColumnDef,
|
||||
createColumnHelper,
|
||||
createSortedRowModel,
|
||||
flexRender,
|
||||
type RowData,
|
||||
rowSortingFeature,
|
||||
sortFn_alphanumeric,
|
||||
sortFn_basic,
|
||||
type SortingState,
|
||||
tableFeatures,
|
||||
useTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Skeleton } from "@app/ui/Skeleton";
|
||||
import {
|
||||
type CellAction,
|
||||
type DataTableColumn,
|
||||
renderCellActions,
|
||||
} from "@app/ui/dataTableColumns";
|
||||
import "@app/ui/DataTable.css";
|
||||
|
||||
export * from "@app/ui/dataTableColumns";
|
||||
|
||||
/** Per-column presentation carried through TanStack's typed `meta` slot. */
|
||||
interface ColumnMeta {
|
||||
align: "left" | "right";
|
||||
nowrap: boolean;
|
||||
fit: boolean;
|
||||
/** Visually-hidden header text for blank affordance/action columns, so the
|
||||
* column still has an accessible name (avoids axe `empty-table-header`). */
|
||||
srHeader?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature registry for every DataTable, built once. Sorting is always
|
||||
* registered so any column can opt in; the core row model defaults in.
|
||||
*/
|
||||
const DATA_TABLE_FEATURES = tableFeatures({
|
||||
rowSortingFeature,
|
||||
sortedRowModel: createSortedRowModel(),
|
||||
columnMeta: {} as ColumnMeta,
|
||||
// Register the comparators the column vocabulary uses. Without this v9 falls
|
||||
// back to a case-sensitive `basic` sort and warns per column.
|
||||
sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
|
||||
});
|
||||
type DataTableFeatures = typeof DATA_TABLE_FEATURES;
|
||||
|
||||
/** Closed appearance dial — the only look choice a call-site may make. */
|
||||
export type DataTableVariant = "default" | "compact";
|
||||
|
||||
/**
|
||||
* A collapsible section of rows under a locked header. Group headers are
|
||||
* structured (title + muted meta + optional right-aligned actions), never raw
|
||||
* markup, so grouped tables stay as opinionated as flat ones. Provide `groups`
|
||||
* instead of `rows`.
|
||||
*/
|
||||
export interface DataTableGroup<T> {
|
||||
key: string;
|
||||
title: string;
|
||||
/** Muted sub-text on the header (e.g. "5 people · led by Dana"). */
|
||||
meta?: string;
|
||||
/** Right-aligned header actions (e.g. "Add to team", a kebab menu). */
|
||||
actions?: CellAction[];
|
||||
rows: T[];
|
||||
/** Collapse rows past this count behind a "Show all N" toggle. */
|
||||
collapseAfter?: number;
|
||||
/** Render the group's rows greyed/disabled (non-actionable, e.g. coming-soon). */
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
/** Columns built with the `column` vocabulary — never raw JSX. */
|
||||
columns: DataTableColumn<T>[];
|
||||
/** Flat rows. Provide this OR `groups`, not both. */
|
||||
rows?: T[];
|
||||
/** Grouped rows with section headers. Takes precedence over `rows`. */
|
||||
groups?: DataTableGroup<T>[];
|
||||
rowKey: (row: T) => string;
|
||||
|
||||
/** Makes rows interactive (hover + click + keyboard). */
|
||||
onRowClick?: (row: T) => void;
|
||||
/** Per-row interactivity gate, checked only when `onRowClick` is set. */
|
||||
isRowInteractive?: (row: T) => boolean;
|
||||
/** Trailing affordance drawn on interactive rows. */
|
||||
rowAffordance?: "none" | "chevron";
|
||||
|
||||
/** Initial sort, applied to the matching sortable column. */
|
||||
defaultSort?: { key: string; direction?: "asc" | "desc" };
|
||||
|
||||
/** First-load state: renders column-shaped skeleton rows under the header. */
|
||||
loading?: boolean;
|
||||
/** Skeleton row count while loading. Defaults to 6. */
|
||||
skeletonRows?: number;
|
||||
/** Error slot — replaces the rows with an alert message row. */
|
||||
error?: ReactNode;
|
||||
/** Shown when there are no rows (and not loading / no error). Text or a node. */
|
||||
empty?: ReactNode;
|
||||
|
||||
/** Content above the table (filters, search, actions), inside the surface. */
|
||||
toolbar?: ReactNode;
|
||||
/** The only appearance choice. */
|
||||
variant?: DataTableVariant;
|
||||
/** Accessible caption for the table. */
|
||||
caption?: string;
|
||||
/** Labels for a group's "show all / show less" toggle (pass translated).
|
||||
* `showAll` receives the group's total row count. */
|
||||
collapseLabels?: { showAll: (total: number) => string; showLess: string };
|
||||
}
|
||||
|
||||
function ChevronGlyph() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="m9 6 6 6-6 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SortGlyph() {
|
||||
return (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M12 8 6 15h12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const CHEVRON_COLUMN_KEY = "__affordance";
|
||||
|
||||
/**
|
||||
* The shared Stirling table. Call-sites supply data + behaviour; the component
|
||||
* owns 100% of the appearance. Columns come from the `column` vocabulary (typed
|
||||
* cell kinds, no raw markup), the surface / density / states are standardized
|
||||
* here, and the only look choice exposed is the closed `variant`. Behaviour -
|
||||
* sorting today, more later - is opt-in per column or via props.
|
||||
*/
|
||||
export function DataTable<T extends RowData>({
|
||||
columns,
|
||||
rows = [],
|
||||
groups,
|
||||
rowKey,
|
||||
onRowClick,
|
||||
isRowInteractive,
|
||||
rowAffordance = "none",
|
||||
defaultSort,
|
||||
loading = false,
|
||||
skeletonRows = 6,
|
||||
error,
|
||||
empty,
|
||||
toolbar,
|
||||
variant = "default",
|
||||
caption,
|
||||
collapseLabels = {
|
||||
showAll: (n) => `Show all ${n}`,
|
||||
showLess: "Show less",
|
||||
},
|
||||
}: DataTableProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>(
|
||||
defaultSort
|
||||
? [{ id: defaultSort.key, desc: defaultSort.direction === "desc" }]
|
||||
: [],
|
||||
);
|
||||
const [openGroups, setOpenGroups] = useState<Set<string>>(new Set());
|
||||
const toggleGroup = (key: string) =>
|
||||
setOpenGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
// The data source is either grouped or flat; TanStack (headers, sorting for
|
||||
// the flat path) is fed the flattened rows.
|
||||
const flatRows = useMemo(
|
||||
() => (groups ? groups.flatMap((g) => g.rows) : rows),
|
||||
[groups, rows],
|
||||
);
|
||||
|
||||
const interactive = Boolean(onRowClick);
|
||||
const showChevron = interactive && rowAffordance === "chevron";
|
||||
// A row that holds its own controls (actions/links/select/caps) can't also be
|
||||
// a `role="button"` (a button may not contain interactive descendants); it
|
||||
// keeps the click as a mouse shortcut, and the inner control is the keyboard path.
|
||||
const rowsContainControls = columns.some((c) => c.interactive);
|
||||
|
||||
const effectiveColumns = useMemo<DataTableColumn<T>[]>(() => {
|
||||
if (!showChevron) return columns;
|
||||
return [
|
||||
...columns,
|
||||
{
|
||||
key: CHEVRON_COLUMN_KEY,
|
||||
header: "",
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
fit: true,
|
||||
sortable: false,
|
||||
renderCell: (row) =>
|
||||
(isRowInteractive?.(row) ?? true) ? (
|
||||
<span className="sui-datatable__chevron" aria-hidden>
|
||||
<ChevronGlyph />
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
}, [columns, showChevron, isRowInteractive]);
|
||||
|
||||
const tanstackColumns = useMemo<ColumnDef<DataTableFeatures, T>[]>(() => {
|
||||
const helper = createColumnHelper<DataTableFeatures, T>();
|
||||
return effectiveColumns.map((c) => {
|
||||
// A blank header (trailing affordance/action columns) still needs an
|
||||
// accessible name for assistive tech.
|
||||
const srHeader = c.header
|
||||
? undefined
|
||||
: c.key === CHEVRON_COLUMN_KEY
|
||||
? t("common.open", "Open")
|
||||
: t("common.actions", "Actions");
|
||||
const meta: ColumnMeta = {
|
||||
align: c.align,
|
||||
nowrap: c.nowrap,
|
||||
fit: c.fit,
|
||||
srHeader,
|
||||
};
|
||||
if (c.sortable && c.sortValue) {
|
||||
const sortValue = c.sortValue;
|
||||
return helper.accessor((row: T): unknown => sortValue(row), {
|
||||
id: c.key,
|
||||
header: () => c.header,
|
||||
cell: (ctx) => c.renderCell(ctx.row.original),
|
||||
enableSorting: true,
|
||||
sortUndefined: "last",
|
||||
sortFn: c.sortFn ?? "basic",
|
||||
meta,
|
||||
});
|
||||
}
|
||||
return helper.display({
|
||||
id: c.key,
|
||||
header: () => c.header,
|
||||
cell: (ctx) => c.renderCell(ctx.row.original),
|
||||
meta,
|
||||
});
|
||||
});
|
||||
}, [effectiveColumns, t]);
|
||||
|
||||
const table = useTable({
|
||||
features: DATA_TABLE_FEATURES,
|
||||
data: flatRows,
|
||||
columns: tanstackColumns,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getRowId: (row) => rowKey(row),
|
||||
});
|
||||
|
||||
const colCount = effectiveColumns.length;
|
||||
|
||||
// Shared row wiring so grouped rows behave like flat ones (interactivity +
|
||||
// the affordance column) instead of being a second-class path.
|
||||
const rowProps = (original: T, muted?: boolean) => {
|
||||
const rowInteractive =
|
||||
interactive && (isRowInteractive?.(original) ?? true);
|
||||
// A row that owns the whole interaction takes the button role + keyboard
|
||||
// handling; a row with its own controls keeps just the mouse click.
|
||||
const asButton = rowInteractive && !rowsContainControls;
|
||||
return {
|
||||
className: [
|
||||
"sui-datatable__row",
|
||||
rowInteractive ? "sui-datatable__row--interactive" : "",
|
||||
muted ? "sui-datatable__row--muted" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
onClick: rowInteractive ? () => onRowClick?.(original) : undefined,
|
||||
tabIndex: asButton ? 0 : undefined,
|
||||
role: asButton ? ("button" as const) : undefined,
|
||||
onKeyDown: asButton
|
||||
? (e: KeyboardEvent<HTMLTableRowElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRowClick?.(original);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// No rows at all - covers a grouped table whose groups are all empty (or an
|
||||
// empty groups list), which would otherwise render a header-only table.
|
||||
const noRows = groups
|
||||
? groups.every((g) => g.rows.length === 0)
|
||||
: rows.length === 0;
|
||||
|
||||
let body: ReactNode;
|
||||
if (loading) {
|
||||
body = Array.from({ length: skeletonRows }).map((_, r) => (
|
||||
<tr key={`skeleton-${r}`} className="sui-datatable__row">
|
||||
{effectiveColumns.map((c) => (
|
||||
<td key={c.key} className={cellClass(c.align, c.nowrap, c.fit)}>
|
||||
<Skeleton
|
||||
height="0.75rem"
|
||||
width={c.align === "right" || c.fit ? "40%" : "70%"}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
));
|
||||
} else if (error != null) {
|
||||
body = (
|
||||
<tr>
|
||||
<td
|
||||
className="sui-datatable__state sui-datatable__state--error"
|
||||
colSpan={colCount}
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
} else if (noRows) {
|
||||
const isNode = typeof empty === "object" && empty !== null;
|
||||
body = (
|
||||
<tr>
|
||||
<td
|
||||
className={
|
||||
isNode
|
||||
? "sui-datatable__state sui-datatable__state--node"
|
||||
: "sui-datatable__state"
|
||||
}
|
||||
colSpan={colCount}
|
||||
>
|
||||
{empty ?? "No data"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
} else if (groups) {
|
||||
body = groups.flatMap((g) => {
|
||||
const limit = g.collapseAfter ?? Infinity;
|
||||
const open = openGroups.has(g.key);
|
||||
const overflow = g.rows.length > limit;
|
||||
const shown = overflow && !open ? g.rows.slice(0, limit) : g.rows;
|
||||
const header = (
|
||||
<tr key={`group-${g.key}`} className="sui-datatable__group">
|
||||
<td colSpan={colCount} className="sui-datatable__group-cell">
|
||||
<div className="sui-datatable__group-head">
|
||||
<div className="sui-datatable__group-title">
|
||||
<strong>{g.title}</strong>
|
||||
{g.meta && (
|
||||
<span className="sui-datatable__group-meta">{g.meta}</span>
|
||||
)}
|
||||
</div>
|
||||
{g.actions &&
|
||||
g.actions.length > 0 &&
|
||||
renderCellActions(g.actions)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
const rowEls = shown.map((row) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
data-row-key={rowKey(row)}
|
||||
{...rowProps(row, g.muted)}
|
||||
>
|
||||
{effectiveColumns.map((c) => (
|
||||
<td key={c.key} className={cellClass(c.align, c.nowrap, c.fit)}>
|
||||
{c.renderCell(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
));
|
||||
const moreEl = overflow ? (
|
||||
<tr key={`more-${g.key}`}>
|
||||
<td colSpan={colCount} className="sui-datatable__group-more">
|
||||
<button
|
||||
type="button"
|
||||
className="sui-datatable__show-all"
|
||||
onClick={() => toggleGroup(g.key)}
|
||||
>
|
||||
{open
|
||||
? collapseLabels.showLess
|
||||
: collapseLabels.showAll(g.rows.length)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : null;
|
||||
return moreEl ? [header, ...rowEls, moreEl] : [header, ...rowEls];
|
||||
});
|
||||
} else {
|
||||
body = table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} data-row-key={row.id} {...rowProps(row.original)}>
|
||||
{row.getAllCells().map((cell) => {
|
||||
const meta = cell.column.columnDef.meta;
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cellClass(
|
||||
meta?.align ?? "left",
|
||||
meta?.nowrap ?? false,
|
||||
meta?.fit ?? false,
|
||||
)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`sui-datatable sui-datatable--${variant}`}>
|
||||
<div className="sui-datatable__frame">
|
||||
{toolbar && <div className="sui-datatable__toolbar">{toolbar}</div>}
|
||||
<div className="sui-datatable__scroll">
|
||||
<table className="sui-datatable__table">
|
||||
{caption && (
|
||||
<caption className="sui-datatable__caption">{caption}</caption>
|
||||
)}
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const meta = header.column.columnDef.meta;
|
||||
const align = meta?.align ?? "left";
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
const label = header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
);
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
scope="col"
|
||||
className={headerClass(align, meta?.fit ?? false)}
|
||||
aria-sort={
|
||||
canSort
|
||||
? sorted === "asc"
|
||||
? "ascending"
|
||||
: sorted === "desc"
|
||||
? "descending"
|
||||
: "none"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{canSort ? (
|
||||
<button
|
||||
type="button"
|
||||
className="sui-datatable__sort"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{label}
|
||||
<span
|
||||
className={`sui-datatable__sort-icon sui-datatable__sort-icon--${sorted || "none"}`}
|
||||
>
|
||||
<SortGlyph />
|
||||
</span>
|
||||
</button>
|
||||
) : meta?.srHeader ? (
|
||||
<span className="sui-datatable__th-sr">
|
||||
{meta.srHeader}
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>{body}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cellClass(
|
||||
align: "left" | "right",
|
||||
nowrap: boolean,
|
||||
fit: boolean,
|
||||
): string {
|
||||
return [
|
||||
"sui-datatable__td",
|
||||
`sui-datatable__td--${align}`,
|
||||
nowrap ? "sui-datatable__td--nowrap" : "",
|
||||
fit ? "sui-datatable__td--fit" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function headerClass(align: "left" | "right", fit: boolean): string {
|
||||
return [
|
||||
"sui-datatable__th",
|
||||
`sui-datatable__th--${align}`,
|
||||
fit ? "sui-datatable__th--fit" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -4,28 +4,20 @@
|
||||
}
|
||||
|
||||
.sui-dd__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-1));
|
||||
/* Positioned (fixed, portaled to <body>) entirely by the Menu component. */
|
||||
min-width: 12rem;
|
||||
padding: var(--space-1);
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: var(--z-dropdown);
|
||||
z-index: var(--z-popover);
|
||||
animation: fadeInUp var(--motion-enter) both;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.sui-dd__menu--start {
|
||||
left: 0;
|
||||
}
|
||||
.sui-dd__menu--end {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sui-dd__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import "@app/ui/Dropdown.css";
|
||||
|
||||
type Alignment = "start" | "end";
|
||||
@@ -20,6 +22,8 @@ interface DropdownContextValue {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.RefObject<HTMLElement | null>;
|
||||
/** The portaled menu element, so click-outside can exclude it. */
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
menuId: string;
|
||||
align: Alignment;
|
||||
}
|
||||
@@ -68,15 +72,20 @@ function Root({
|
||||
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuId = useId();
|
||||
|
||||
// Click-outside + Escape close.
|
||||
// Click-outside + Escape close. The menu is portaled to <body>, so it is not
|
||||
// inside containerRef - check it separately or a click on it would close the
|
||||
// menu before the item's handler runs.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onDocClick(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
!containerRef.current.contains(target) &&
|
||||
!(menuRef.current && menuRef.current.contains(target))
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
@@ -96,7 +105,7 @@ function Root({
|
||||
}, [open, setOpen]);
|
||||
|
||||
const value = useMemo<DropdownContextValue>(
|
||||
() => ({ open, setOpen, triggerRef, menuId, align }),
|
||||
() => ({ open, setOpen, triggerRef, menuRef, menuId, align }),
|
||||
[open, setOpen, menuId, align],
|
||||
);
|
||||
|
||||
@@ -150,23 +159,79 @@ export interface DropdownMenuProps {
|
||||
}
|
||||
|
||||
function Menu({ children, className, width }: DropdownMenuProps) {
|
||||
const { open, menuId, align } = useDropdownCtx();
|
||||
if (!open) return null;
|
||||
const style =
|
||||
width !== undefined
|
||||
const { open, menuId, align, triggerRef, menuRef } = useDropdownCtx();
|
||||
// Fixed position tracked to the trigger. Portaling to <body> keeps the menu
|
||||
// out of any `overflow` ancestor (e.g. a table's horizontal scroll area),
|
||||
// which would otherwise clip it and add a scrollbar.
|
||||
const [pos, setPos] = useState<{
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const place = () => {
|
||||
const el = triggerRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const margin = 8;
|
||||
const spaceBelow = window.innerHeight - r.bottom - margin;
|
||||
const spaceAbove = r.top - margin;
|
||||
// Flip above when there's more room there, so a trigger near the viewport
|
||||
// bottom doesn't open a fixed menu that runs off-screen and can't scroll.
|
||||
const below = spaceBelow >= spaceAbove;
|
||||
const horizontal =
|
||||
align === "end"
|
||||
? { right: window.innerWidth - r.right }
|
||||
: { left: r.left };
|
||||
setPos({
|
||||
...horizontal,
|
||||
...(below
|
||||
? { top: r.bottom + gap }
|
||||
: { bottom: window.innerHeight - r.top + gap }),
|
||||
maxHeight: Math.max(0, (below ? spaceBelow : spaceAbove) - gap),
|
||||
});
|
||||
};
|
||||
place();
|
||||
// Track the trigger while scrolling/resizing (capture catches inner scrollers).
|
||||
window.addEventListener("scroll", place, true);
|
||||
window.addEventListener("resize", place);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", place, true);
|
||||
window.removeEventListener("resize", place);
|
||||
};
|
||||
}, [open, align, triggerRef]);
|
||||
|
||||
if (!open || !pos) return null;
|
||||
const style: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
// Explicit auto (not undefined) so the CSS fallback `top`/`left` can't leak
|
||||
// in on the axis this placement isn't pinning.
|
||||
top: pos.top ?? "auto",
|
||||
bottom: pos.bottom ?? "auto",
|
||||
left: pos.left ?? "auto",
|
||||
right: pos.right ?? "auto",
|
||||
maxHeight: pos.maxHeight,
|
||||
overflowY: "auto",
|
||||
...(width !== undefined
|
||||
? { minWidth: typeof width === "number" ? `${width}px` : width }
|
||||
: undefined;
|
||||
return (
|
||||
: {}),
|
||||
};
|
||||
return createPortal(
|
||||
<div
|
||||
id={menuId}
|
||||
role="menu"
|
||||
className={["sui-dd__menu", `sui-dd__menu--${align}`, className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
ref={menuRef}
|
||||
className={["sui-dd__menu", className ?? ""].filter(Boolean).join(" ")}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
.sui-table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sui-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Header text kept for assistive tech only, so a column of controls can be named
|
||||
without putting a heading above it. Defined here rather than borrowing a global
|
||||
utility, since the portal loads its own stylesheet. */
|
||||
.sui-table__th-sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sui-table__th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--c-text-subtle);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sui-table__th--right,
|
||||
.sui-table__td--right {
|
||||
text-align: right;
|
||||
}
|
||||
.sui-table__th--center,
|
||||
.sui-table__td--center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sui-table__td {
|
||||
padding: 0.625rem 0.875rem;
|
||||
color: var(--c-text-muted);
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sui-table tbody tr:last-child .sui-table__td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sui-table__row--interactive {
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.sui-table__row--interactive:hover {
|
||||
background: var(--c-hover);
|
||||
}
|
||||
.sui-table__row--interactive:focus-visible {
|
||||
outline: 0.125rem solid var(--c-primary);
|
||||
outline-offset: -0.125rem;
|
||||
}
|
||||
|
||||
.sui-table__empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Table, type TableColumn } from "@app/ui/Table";
|
||||
import { StatusBadge } from "@app/ui/StatusBadge";
|
||||
|
||||
interface Region {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
status: "healthy" | "degraded";
|
||||
docs: number;
|
||||
latency: string;
|
||||
}
|
||||
|
||||
const REGIONS: Region[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "US East",
|
||||
code: "us-east-1",
|
||||
status: "healthy",
|
||||
docs: 12481,
|
||||
latency: "41 ms",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "US West",
|
||||
code: "us-west-2",
|
||||
status: "healthy",
|
||||
docs: 8210,
|
||||
latency: "63 ms",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "EU West",
|
||||
code: "eu-west-1",
|
||||
status: "degraded",
|
||||
docs: 3044,
|
||||
latency: "190 ms",
|
||||
},
|
||||
];
|
||||
|
||||
const COLUMNS: TableColumn<Region>[] = [
|
||||
{ key: "name", header: "Region", render: (r) => r.name },
|
||||
{
|
||||
key: "code",
|
||||
header: "Code",
|
||||
render: (r) => (
|
||||
<code style={{ fontFamily: "var(--font-mono)" }}>{r.code}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (r) => (
|
||||
<StatusBadge
|
||||
tone={r.status === "healthy" ? "success" : "warning"}
|
||||
size="sm"
|
||||
>
|
||||
{r.status}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "docs",
|
||||
header: "Docs 24h",
|
||||
align: "right",
|
||||
render: (r) => r.docs.toLocaleString(),
|
||||
},
|
||||
{ key: "latency", header: "P95", align: "right", render: (r) => r.latency },
|
||||
];
|
||||
|
||||
const meta: Meta<typeof Table> = {
|
||||
title: "Compound/Table",
|
||||
component: Table,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Table>;
|
||||
|
||||
/** Presentational table — columns own their cell renderers; pass pre-sorted rows. */
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<Table<Region> columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} />
|
||||
),
|
||||
};
|
||||
|
||||
/** With `onRowClick`, rows become focusable + hoverable (keyboard: Enter/Space). */
|
||||
export const Interactive: Story = {
|
||||
render: () => (
|
||||
<Table<Region>
|
||||
columns={COLUMNS}
|
||||
rows={REGIONS}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={() => {}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Empty body slot. */
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<Table<Region>
|
||||
columns={COLUMNS}
|
||||
rows={[]}
|
||||
rowKey={(r) => r.id}
|
||||
empty="No regions deployed yet."
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@app/ui/Surface.css";
|
||||
import "@app/ui/Table.css";
|
||||
|
||||
export interface TableColumn<T> {
|
||||
/** Stable column id. */
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
/**
|
||||
* Hides the header visually but keeps it for assistive tech. For a trailing column of controls
|
||||
* or chevrons, where a visible heading would be noise but a blank one leaves the cells below it
|
||||
* unlabelled.
|
||||
*/
|
||||
headerHidden?: boolean;
|
||||
/** Cell renderer for a row. */
|
||||
render: (row: T) => ReactNode;
|
||||
align?: "left" | "right" | "center";
|
||||
/** Optional fixed/min width (any CSS length). */
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export interface TableProps<T> {
|
||||
columns: TableColumn<T>[];
|
||||
rows: T[];
|
||||
/** Stable key per row. */
|
||||
rowKey: (row: T) => string;
|
||||
/** Makes rows interactive (hover + click + keyboard). */
|
||||
onRowClick?: (row: T) => void;
|
||||
/**
|
||||
* Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which
|
||||
* this returns false is inert: no click/keyboard, and not announced as a button. Defaults to
|
||||
* all rows interactive.
|
||||
*/
|
||||
isRowInteractive?: (row: T) => boolean;
|
||||
/**
|
||||
* Set when rows render controls of their own. The row keeps its click as a mouse shortcut but
|
||||
* stops announcing itself as a button, because a button may not contain other controls and a
|
||||
* {@code <tr role="button">} is no longer a row to a screen reader. That row control is then the
|
||||
* keyboard path to the same action, so nothing is lost by leaving the row itself inert.
|
||||
*/
|
||||
rowsContainControls?: boolean;
|
||||
/** Rendered in place of the body when there are no rows. */
|
||||
empty?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal data table primitive. Columns own their own cell renderers, so the
|
||||
* table stays presentational — callers pre-sort/filter and pass the rows they
|
||||
* want shown. Rows become focusable buttons-in-disguise when `onRowClick` is
|
||||
* set.
|
||||
*/
|
||||
export function Table<T>({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
onRowClick,
|
||||
isRowInteractive,
|
||||
rowsContainControls = false,
|
||||
empty,
|
||||
className,
|
||||
}: TableProps<T>) {
|
||||
const interactive = Boolean(onRowClick);
|
||||
return (
|
||||
<div
|
||||
className={["sui-surface", "sui-table-wrap", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<table className="sui-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
scope="col"
|
||||
className={`sui-table__th sui-table__th--${c.align ?? "left"}`}
|
||||
style={c.width ? { width: c.width } : undefined}
|
||||
>
|
||||
{c.headerHidden ? (
|
||||
<span className="sui-table__th-sr">{c.header}</span>
|
||||
) : (
|
||||
c.header
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td className="sui-table__empty" colSpan={columns.length}>
|
||||
{empty ?? "No data"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const rowInteractive =
|
||||
interactive && (isRowInteractive?.(row) ?? true);
|
||||
// Only a row that owns the whole interaction takes the button role and the keyboard
|
||||
// handling that goes with it; see rowsContainControls.
|
||||
const rowIsControl = rowInteractive && !rowsContainControls;
|
||||
return (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className={
|
||||
rowInteractive
|
||||
? "sui-table__row sui-table__row--interactive"
|
||||
: "sui-table__row"
|
||||
}
|
||||
onClick={rowInteractive ? () => onRowClick?.(row) : undefined}
|
||||
tabIndex={rowIsControl ? 0 : undefined}
|
||||
role={rowIsControl ? "button" : undefined}
|
||||
onKeyDown={
|
||||
rowIsControl
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRowClick?.(row);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.key}
|
||||
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
|
||||
>
|
||||
{c.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { StatusBadge, type StatusTone } from "@app/ui/StatusBadge";
|
||||
import { Chip, type ChipAccent } from "@app/ui/Chip";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { Dropdown } from "@app/ui/Dropdown";
|
||||
import { ProgressBar } from "@app/ui/ProgressBar";
|
||||
import { Select, type SelectOption } from "@app/ui/Select";
|
||||
|
||||
/**
|
||||
* The column vocabulary for {@link DataTable}. Call-sites pick a cell KIND and
|
||||
* supply the data + semantics; the component owns 100% of the appearance. There
|
||||
* is no raw-JSX / className escape hatch by design; a cell can only look the way
|
||||
* the design system draws its kind, so every table looks and behaves the same.
|
||||
*/
|
||||
|
||||
type Align = "left" | "right";
|
||||
type SortValue = string | number | boolean | null | undefined;
|
||||
|
||||
/**
|
||||
* Which built-in comparator sorts a column. Set by the builder from the cell's
|
||||
* data type - `alphanumeric` (case-insensitive, natural: `v2` before `v10`) for
|
||||
* text, `basic` (raw numeric) for numbers. Call-sites never choose this.
|
||||
*/
|
||||
export type DataTableSortFn = "alphanumeric" | "basic";
|
||||
|
||||
/** Opaque, fully-resolved column. Produced only by the {@link column} builders. */
|
||||
export interface DataTableColumn<T> {
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
align: Align;
|
||||
/** Prevent wrapping (mono/number values). */
|
||||
nowrap: boolean;
|
||||
/** Shrink the column to its content (actions / affordances). */
|
||||
fit: boolean;
|
||||
sortable: boolean;
|
||||
sortValue?: (row: T) => SortValue;
|
||||
/** Comparator kind, derived from the cell type. Only set when sortable. */
|
||||
sortFn?: DataTableSortFn;
|
||||
/** Cell renders its own interactive control (button/link/select/chip). Rows
|
||||
* containing one drop their `role="button"` so a button never nests inside a
|
||||
* button - the control is the keyboard path instead. */
|
||||
interactive?: boolean;
|
||||
/** Internal, design-system-owned renderer. Call-sites never supply this. */
|
||||
renderCell: (row: T) => ReactNode;
|
||||
}
|
||||
|
||||
/** The only design-system glyph a cell may use (icon-only actions). */
|
||||
export type CellGlyph = "kebab";
|
||||
|
||||
function KebabGlyph() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="12" cy="5" r="1.6" />
|
||||
<circle cx="12" cy="12" r="1.6" />
|
||||
<circle cx="12" cy="19" r="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** An item in a kebab action menu. */
|
||||
export interface CellMenuItem {
|
||||
label: string;
|
||||
tone?: "default" | "danger";
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
/** Draw a divider above this item. */
|
||||
dividerBefore?: boolean;
|
||||
}
|
||||
|
||||
/** A row/group action. A locked button, or a kebab menu when `menu` is set. */
|
||||
export interface CellAction {
|
||||
label: string;
|
||||
glyph?: CellGlyph;
|
||||
/** Icon-only (uses `label` as the accessible name). */
|
||||
iconOnly?: boolean;
|
||||
tone?: "default" | "danger";
|
||||
onClick?: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
/** When set, the button opens this menu instead of firing `onClick`. */
|
||||
menu?: CellMenuItem[];
|
||||
}
|
||||
|
||||
/** Renders a row of locked action buttons / kebab menus. Shared by the
|
||||
* `actions` cell kind and grouped-table headers. */
|
||||
export function renderCellActions(actions: CellAction[]): ReactNode {
|
||||
return (
|
||||
<div className="sui-dtc__actions" onClick={(e) => e.stopPropagation()}>
|
||||
{actions.map((a) =>
|
||||
a.menu ? (
|
||||
<Dropdown.Root key={a.label}>
|
||||
<Dropdown.Trigger>
|
||||
<Button
|
||||
variant={a.iconOnly ? "tertiary" : "secondary"}
|
||||
size="sm"
|
||||
shape={a.iconOnly ? "circle" : undefined}
|
||||
leftSection={a.glyph ? <KebabGlyph /> : undefined}
|
||||
loading={a.loading}
|
||||
disabled={a.disabled}
|
||||
aria-label={a.iconOnly ? a.label : undefined}
|
||||
>
|
||||
{a.iconOnly ? undefined : a.label}
|
||||
</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width={210}>
|
||||
{a.menu.map((m) => (
|
||||
<Fragment key={m.label}>
|
||||
{m.dividerBefore && <Dropdown.Divider />}
|
||||
<Dropdown.Item
|
||||
onSelect={m.onClick}
|
||||
disabled={m.disabled}
|
||||
className={
|
||||
m.tone === "danger"
|
||||
? "sui-dtc__menu-item--danger"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{m.label}
|
||||
</Dropdown.Item>
|
||||
</Fragment>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
) : (
|
||||
<Button
|
||||
key={a.label}
|
||||
variant={a.iconOnly ? "tertiary" : "secondary"}
|
||||
accent={a.tone === "danger" ? "danger" : undefined}
|
||||
size="sm"
|
||||
shape={a.iconOnly ? "circle" : undefined}
|
||||
leftSection={a.glyph ? <KebabGlyph /> : undefined}
|
||||
loading={a.loading}
|
||||
disabled={a.disabled}
|
||||
aria-label={a.iconOnly ? a.label : undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
a.onClick?.();
|
||||
}}
|
||||
>
|
||||
{a.iconOnly ? undefined : a.label}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An external link inside a cell. */
|
||||
export interface CellLink {
|
||||
label: string;
|
||||
href: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
interface Common {
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
function base<T>(
|
||||
o: Common,
|
||||
extra: Pick<DataTableColumn<T>, "align" | "nowrap" | "fit" | "renderCell"> & {
|
||||
sortValue?: (row: T) => SortValue;
|
||||
sortFn?: DataTableSortFn;
|
||||
interactive?: boolean;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return {
|
||||
key: o.key,
|
||||
header: o.header,
|
||||
align: extra.align,
|
||||
nowrap: extra.nowrap,
|
||||
fit: extra.fit,
|
||||
sortable: !!o.sortable,
|
||||
sortValue: o.sortable ? extra.sortValue : undefined,
|
||||
sortFn: o.sortable ? extra.sortFn : undefined,
|
||||
interactive: extra.interactive,
|
||||
renderCell: extra.renderCell,
|
||||
};
|
||||
}
|
||||
|
||||
function text<T>(
|
||||
o: Common & {
|
||||
get: (row: T) => string;
|
||||
/** Optional bold label rendered before the value as "Label: value". */
|
||||
label?: (row: T) => string | null | undefined;
|
||||
sortBy?: (row: T) => SortValue;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: false,
|
||||
fit: false,
|
||||
sortValue: o.sortBy ?? ((r) => o.get(r)),
|
||||
sortFn: "alphanumeric",
|
||||
renderCell: (r) => {
|
||||
const label = o.label?.(r);
|
||||
return label ? (
|
||||
<span className="sui-dtc__text sui-dtc__text--labeled">
|
||||
<strong className="sui-dtc__text-label">{label}:</strong> {o.get(r)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="sui-dtc__text">{o.get(r)}</span>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mono<T>(
|
||||
o: Common & { get: (row: T) => string; sortBy?: (row: T) => SortValue },
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: true,
|
||||
fit: false,
|
||||
sortValue: o.sortBy ?? ((r) => o.get(r)),
|
||||
sortFn: "alphanumeric",
|
||||
renderCell: (r) => <code className="sui-dtc__mono">{o.get(r)}</code>,
|
||||
});
|
||||
}
|
||||
|
||||
function muted<T>(
|
||||
o: Common & {
|
||||
get: (row: T) => string | null | undefined;
|
||||
placeholder?: string;
|
||||
/** Override the sort key (e.g. an ISO date behind a "3 days ago" label). */
|
||||
sortBy?: (row: T) => SortValue;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: false,
|
||||
fit: false,
|
||||
sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined),
|
||||
sortFn: "alphanumeric",
|
||||
renderCell: (r) => (
|
||||
<span className="sui-dtc__muted">
|
||||
{o.get(r) || (o.placeholder ?? "-")}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function number<T>(
|
||||
o: Common & {
|
||||
get: (row: T) => number | null | undefined;
|
||||
format?: (n: number, row: T) => string;
|
||||
placeholder?: string;
|
||||
/** Override the sort key (e.g. a raw count behind a formatted label). */
|
||||
sortBy?: (row: T) => SortValue;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
fit: false,
|
||||
sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined),
|
||||
sortFn: "basic",
|
||||
renderCell: (r) => {
|
||||
const n = o.get(r);
|
||||
if (n == null) {
|
||||
return (
|
||||
<span className="sui-dtc__num sui-dtc__muted">
|
||||
{o.placeholder ?? "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="sui-dtc__num">
|
||||
{o.format ? o.format(n, r) : String(n)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function badge<T>(
|
||||
o: Common & {
|
||||
get: (row: T) => { tone: StatusTone; label: string };
|
||||
sortBy?: (row: T) => SortValue;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: true,
|
||||
fit: false,
|
||||
sortValue: o.sortBy ?? ((r) => o.get(r).label),
|
||||
sortFn: "alphanumeric",
|
||||
renderCell: (r) => {
|
||||
const b = o.get(r);
|
||||
return (
|
||||
<StatusBadge tone={b.tone} size="sm">
|
||||
{b.label}
|
||||
</StatusBadge>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A user-defined label, rendered as a dot-less pill. Use this ONLY for labels
|
||||
* that come from data / the user (e.g. a document's classification). Values from
|
||||
* a fixed set we define (types, environments, providers) are `text`, not pills.
|
||||
*/
|
||||
export interface CellLabel {
|
||||
label: string;
|
||||
accent?: ChipAccent;
|
||||
}
|
||||
|
||||
function labels<T>(
|
||||
o: Common & { get: (row: T) => CellLabel[] },
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: false,
|
||||
fit: false,
|
||||
sortValue: (r) => o.get(r)[0]?.label ?? undefined,
|
||||
sortFn: "alphanumeric",
|
||||
renderCell: (r) => (
|
||||
<div className="sui-dtc__labels">
|
||||
{o.get(r).map((l) => (
|
||||
<Chip
|
||||
key={l.label}
|
||||
accent={l.accent ?? "neutral"}
|
||||
size="sm"
|
||||
showDot={false}
|
||||
>
|
||||
{l.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An interactive capability chip: click to grant, remove to revoke, dashed to
|
||||
* offer adding. A functional cell (it toggles state), distinct from static
|
||||
* `labels`.
|
||||
*/
|
||||
export interface CellCap {
|
||||
label: string;
|
||||
accent?: ChipAccent;
|
||||
onClick?: () => void;
|
||||
onRemove?: () => void;
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
function caps<T>(
|
||||
o: Common & { get: (row: T) => CellCap[] },
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: false,
|
||||
fit: false,
|
||||
interactive: true,
|
||||
renderCell: (r) => (
|
||||
<div className="sui-dtc__labels">
|
||||
{o.get(r).map((c) => (
|
||||
<Chip
|
||||
key={c.label}
|
||||
accent={c.accent ?? "neutral"}
|
||||
size="sm"
|
||||
showDot={false}
|
||||
dashed={c.dashed}
|
||||
onClick={c.onClick}
|
||||
onRemove={c.onRemove}
|
||||
>
|
||||
{c.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function entity<T>(
|
||||
o: Common & {
|
||||
/** Semantic leading icon (component owns its size + colour container). */
|
||||
icon?: (row: T) => ReactNode;
|
||||
primary: (row: T) => string;
|
||||
/** Muted inline suffix after the name, its own node (e.g. "(you)"). */
|
||||
suffix?: (row: T) => string | null | undefined;
|
||||
/** Secondary muted line under the name. */
|
||||
note?: (row: T) => string | null | undefined;
|
||||
sortBy?: (row: T) => SortValue;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: false,
|
||||
fit: false,
|
||||
sortValue: o.sortBy ?? ((r) => o.primary(r)),
|
||||
sortFn: "alphanumeric",
|
||||
renderCell: (r) => {
|
||||
const icon = o.icon?.(r);
|
||||
const suffix = o.suffix?.(r);
|
||||
const note = o.note?.(r);
|
||||
return (
|
||||
<div className="sui-dtc__entity">
|
||||
{icon != null && (
|
||||
<span className="sui-dtc__entity-icon" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="sui-dtc__entity-body">
|
||||
<span className="sui-dtc__entity-head">
|
||||
<span className="sui-dtc__entity-name">{o.primary(r)}</span>
|
||||
{suffix && (
|
||||
<span className="sui-dtc__entity-suffix">{suffix}</span>
|
||||
)}
|
||||
</span>
|
||||
{note && <span className="sui-dtc__note">{note}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function actions<T>(o: {
|
||||
key: string;
|
||||
header?: ReactNode;
|
||||
get: (row: T) => CellAction[];
|
||||
}): DataTableColumn<T> {
|
||||
return {
|
||||
key: o.key,
|
||||
header: o.header ?? "",
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
fit: true,
|
||||
sortable: false,
|
||||
interactive: true,
|
||||
renderCell: (r) => renderCellActions(o.get(r)),
|
||||
};
|
||||
}
|
||||
|
||||
function progress<T>(
|
||||
o: Common & {
|
||||
get: (row: T) => { value: number; label?: string };
|
||||
/** Accessible name for the bar (it has no visible text). Defaults to the
|
||||
* shown percent; pass a description like "Load for us-east-1" when useful. */
|
||||
ariaLabel?: (row: T) => string;
|
||||
},
|
||||
): DataTableColumn<T> {
|
||||
return base<T>(o, {
|
||||
align: "left",
|
||||
nowrap: true,
|
||||
fit: false,
|
||||
sortValue: (r) => o.get(r).value,
|
||||
sortFn: "basic",
|
||||
renderCell: (r) => {
|
||||
const p = o.get(r);
|
||||
const shown = p.label ?? `${Math.round(p.value * 100)}%`;
|
||||
return (
|
||||
<div className="sui-dtc__progress">
|
||||
<span className="sui-dtc__progress-bar">
|
||||
<ProgressBar
|
||||
value={p.value}
|
||||
thresholded
|
||||
height={6}
|
||||
label={o.ariaLabel?.(r) ?? shown}
|
||||
/>
|
||||
</span>
|
||||
<span className="sui-dtc__progress-pct">{shown}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function links<T>(o: {
|
||||
key: string;
|
||||
header?: ReactNode;
|
||||
get: (row: T) => CellLink[];
|
||||
}): DataTableColumn<T> {
|
||||
return {
|
||||
key: o.key,
|
||||
header: o.header ?? "",
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
fit: true,
|
||||
sortable: false,
|
||||
interactive: true,
|
||||
renderCell: (r) => (
|
||||
<div className="sui-dtc__links">
|
||||
{o.get(r).map((l) => (
|
||||
<a
|
||||
key={l.label}
|
||||
className="sui-dtc__link"
|
||||
href={l.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={l.ariaLabel}
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function select<T>(o: {
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
get: (row: T) => {
|
||||
value?: string | null;
|
||||
defaultValue?: string;
|
||||
options: SelectOption[];
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
/** Omit for an uncontrolled select (local UI state only). */
|
||||
onChange?: (row: T, value: string | null) => void;
|
||||
}): DataTableColumn<T> {
|
||||
return {
|
||||
key: o.key,
|
||||
header: o.header,
|
||||
align: "left",
|
||||
nowrap: true,
|
||||
fit: false,
|
||||
sortable: false,
|
||||
interactive: true,
|
||||
renderCell: (r) => {
|
||||
const s = o.get(r);
|
||||
const change = o.onChange;
|
||||
return (
|
||||
<div className="sui-dtc__select">
|
||||
<Select
|
||||
options={s.options}
|
||||
value={s.value}
|
||||
defaultValue={s.defaultValue}
|
||||
onChange={change ? (v) => change(r, v) : undefined}
|
||||
aria-label={s.ariaLabel}
|
||||
disabled={s.disabled}
|
||||
inputSize="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The DataTable column vocabulary. Each builder produces a locked-appearance
|
||||
* column; call-sites choose the kind + supply data, never styling.
|
||||
*/
|
||||
export const column = {
|
||||
text,
|
||||
mono,
|
||||
muted,
|
||||
number,
|
||||
badge,
|
||||
labels,
|
||||
caps,
|
||||
entity,
|
||||
actions,
|
||||
progress,
|
||||
links,
|
||||
select,
|
||||
};
|
||||
@@ -40,7 +40,7 @@ export * from "@app/ui/Collapsible";
|
||||
export * from "@app/ui/Tabs";
|
||||
export * from "@app/ui/Dropdown";
|
||||
export * from "@app/ui/Drawer";
|
||||
export * from "@app/ui/Table";
|
||||
export * from "@app/ui/DataTable";
|
||||
|
||||
// Forms
|
||||
export * from "@app/ui/FormField";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
import { useDefaultApp } from "@app/hooks/useDefaultApp";
|
||||
|
||||
export const DefaultAppBanner: React.FC = () => {
|
||||
@@ -15,7 +15,7 @@ export const DefaultAppBanner: React.FC = () => {
|
||||
const [sessionDismissed, setSessionDismissed] = useState(false);
|
||||
|
||||
return (
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="picture-as-pdf-rounded"
|
||||
message={t(
|
||||
"defaultApp.prompt.message",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* SaaS has no link concept — the signed-in account IS the SaaS account, and the
|
||||
* editor's cloud wallet hook is already in this build's {@code @app/*} cascade.
|
||||
* Delegating to it means the processor footer and the editor footer share one
|
||||
* wallet fetch and can't disagree, so there is nothing portal-specific to do.
|
||||
*/
|
||||
export { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useCallback } from "react";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
|
||||
/**
|
||||
* SaaS processor: the settings modal it hosts carries the same Plan section the
|
||||
* editor opens, so the footer's credits row lands both apps in one place.
|
||||
*/
|
||||
export function useOpenPlan(): (() => void) | null {
|
||||
const { openSettings } = useUI();
|
||||
return useCallback(() => openSettings("plan"), [openSettings]);
|
||||
}
|
||||
@@ -95,12 +95,6 @@ export const DOCUMENT_STATUS_TONE: Record<DocumentStatus, StatusTone> = {
|
||||
error: "danger",
|
||||
};
|
||||
|
||||
export const PRODUCT_CHIP_TONE: Record<ProductType, ChipAccent> = {
|
||||
API: "brand",
|
||||
Editor: "success",
|
||||
Automation: "warning",
|
||||
};
|
||||
|
||||
/** Classification chip accent: danger when unclassified, warning when it needs a look. */
|
||||
export function classificationTone(doc: ReviewDocument): ChipAccent {
|
||||
if (doc.classification === "Unclassified") return "danger";
|
||||
|
||||
@@ -123,8 +123,6 @@
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__footer {
|
||||
margin-inline: 0.375rem;
|
||||
padding-inline: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portal-sidebar__logo {
|
||||
@@ -179,10 +177,8 @@
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
/* The shared <NavFooter> brings its own boxes, padding and gap; the sidebar
|
||||
only positions it. */
|
||||
.portal-sidebar__footer {
|
||||
margin: 0 0.625rem 0.75rem;
|
||||
padding: 0.5rem 0.375rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { useMediaQuery } from "@mantine/hooks";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, NavItem, NavSurface } from "@app/ui";
|
||||
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
|
||||
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
||||
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
|
||||
import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary";
|
||||
import { useOpenPlan } from "@portal/hooks/useOpenPlan";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -10,7 +14,7 @@ import { useUI } from "@portal/contexts/UIContext";
|
||||
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
|
||||
import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
|
||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||
import { CloseIcon, SettingsIcon } from "@portal/components/icons";
|
||||
import { CloseIcon } from "@portal/components/icons";
|
||||
import {
|
||||
GROUP_PROCESSOR,
|
||||
GROUP_PLATFORM,
|
||||
@@ -41,6 +45,9 @@ export function Sidebar() {
|
||||
const isMobile = useMediaQuery(MOBILE_QUERY, false, {
|
||||
getInitialValueInEffect: false,
|
||||
});
|
||||
const { displayName, profilePictureUrl } = useAccountIdentity();
|
||||
const credits = useFreeCreditsSummary();
|
||||
const openPlan = useOpenPlan();
|
||||
|
||||
// Collapse is a desktop-only affordance: on mobile the sidebar is an
|
||||
// off-canvas drawer, so the icon-rail state never applies there.
|
||||
@@ -146,15 +153,17 @@ export function Sidebar() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<NavSurface className="portal-sidebar__footer">
|
||||
<LinkAccountFooterItem />
|
||||
<NavItem
|
||||
id="settings"
|
||||
label={t("portal.nav.settings")}
|
||||
icon={<SettingsIcon />}
|
||||
onClick={() => openSettings()}
|
||||
/>
|
||||
</NavSurface>
|
||||
<NavFooter
|
||||
className="portal-sidebar__footer"
|
||||
displayName={displayName}
|
||||
profilePictureUrl={profilePictureUrl}
|
||||
onOpenSettings={openSettings}
|
||||
credits={credits}
|
||||
onOpenPlan={openPlan ?? undefined}
|
||||
otherApp={{ app: "editor", onOpen: goToEditor }}
|
||||
accountExtras={<LinkAccountFooterItem />}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import { column, DataTable, type DataTableColumn, EmptyState } from "@app/ui";
|
||||
import type { LinkedInstanceRow } from "@portal/api/link";
|
||||
|
||||
interface Props {
|
||||
@@ -45,78 +38,67 @@ export function LinkedInstancesTable({
|
||||
revokingId,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const cols: TableColumn<LinkedInstanceRow>[] = [
|
||||
{
|
||||
const cols: DataTableColumn<LinkedInstanceRow>[] = [
|
||||
column.entity({
|
||||
key: "name",
|
||||
header: t("portal.accountLink.instances.columns.instance", "Instance"),
|
||||
render: (i) => (
|
||||
<div className="portal-link__cell-stack">
|
||||
<span className="portal-link__cell-strong">
|
||||
{i.name ??
|
||||
t("portal.accountLink.instances.unnamed", "Unnamed instance")}
|
||||
</span>
|
||||
<code className="portal-link__device-id">{i.deviceId}</code>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
primary: (i) =>
|
||||
i.name ?? t("portal.accountLink.instances.unnamed", "Unnamed instance"),
|
||||
note: (i) => i.deviceId,
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.accountLink.instances.columns.status", "Status"),
|
||||
render: (i) =>
|
||||
i.revoked ? (
|
||||
<StatusBadge tone="danger" size="sm">
|
||||
{t("portal.accountLink.instances.revoked", "Revoked")}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="success" size="sm">
|
||||
{t("portal.accountLink.instances.active", "Active")}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (i) =>
|
||||
i.revoked
|
||||
? {
|
||||
tone: "danger",
|
||||
label: t("portal.accountLink.instances.revoked", "Revoked"),
|
||||
}
|
||||
: {
|
||||
tone: "success",
|
||||
label: t("portal.accountLink.instances.active", "Active"),
|
||||
},
|
||||
}),
|
||||
column.muted({
|
||||
key: "lastSeen",
|
||||
header: t("portal.accountLink.instances.columns.lastSeen", "Last seen"),
|
||||
render: (i) => (
|
||||
<span className="portal-link__muted">
|
||||
{relativeTime(i.lastSeenAt, t)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
// Sort on the real ISO timestamp, not the "3d ago" label.
|
||||
sortBy: (i) => i.lastSeenAt ?? undefined,
|
||||
get: (i) => relativeTime(i.lastSeenAt, t),
|
||||
}),
|
||||
column.muted({
|
||||
key: "created",
|
||||
header: t("portal.accountLink.instances.columns.linked", "Linked"),
|
||||
render: (i) => (
|
||||
<span className="portal-link__muted">
|
||||
{relativeTime(i.createdAt, t)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
sortBy: (i) => i.createdAt ?? undefined,
|
||||
get: (i) => relativeTime(i.createdAt, t),
|
||||
}),
|
||||
column.actions({
|
||||
key: "actions",
|
||||
header: (
|
||||
<span className="sr-only">
|
||||
{t("portal.accountLink.instances.columns.actions", "Actions")}
|
||||
</span>
|
||||
),
|
||||
align: "right",
|
||||
render: (i) =>
|
||||
i.revoked ? null : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
loading={revokingId === i.instanceId}
|
||||
onClick={() => onRevoke(i)}
|
||||
>
|
||||
{t("portal.accountLink.instances.revoke", "Revoke")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
get: (i) =>
|
||||
i.revoked
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t("portal.accountLink.instances.revoke", "Revoke"),
|
||||
tone: "danger",
|
||||
loading: revokingId === i.instanceId,
|
||||
onClick: () => onRevoke(i),
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<Card padding="none">
|
||||
{instances.length === 0 ? (
|
||||
<DataTable<LinkedInstanceRow>
|
||||
columns={cols}
|
||||
rows={instances}
|
||||
rowKey={(i) => String(i.instanceId)}
|
||||
empty={
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t(
|
||||
@@ -128,13 +110,7 @@ export function LinkedInstancesTable({
|
||||
"Link this org's account, then register your self-hosted instances to see them here.",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
columns={cols}
|
||||
rows={instances}
|
||||
rowKey={(i) => String(i.instanceId)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
type CellLink,
|
||||
column,
|
||||
DataTable,
|
||||
type DataTableColumn,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import { formatMinor, formatPeriodDate } from "@app/billing";
|
||||
import { fetchInvoices, type Invoice } from "@portal/api/billing";
|
||||
@@ -80,96 +81,78 @@ export function InvoicesList() {
|
||||
// Date · Amount · Status · Description (product name) · Actions
|
||||
// The monospace invoice id is dropped — users care about "what was it for",
|
||||
// not the internal id.
|
||||
const columns: TableColumn<Invoice>[] = [
|
||||
{
|
||||
const columns: DataTableColumn<Invoice>[] = [
|
||||
column.text({
|
||||
key: "date",
|
||||
header: t("portal.billing.invoices.columnDate", "Date"),
|
||||
render: (inv) =>
|
||||
inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "—",
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
// Sort chronologically on the raw ISO timestamp, not the formatted label.
|
||||
sortBy: (inv) => inv.createdAt ?? undefined,
|
||||
get: (inv) =>
|
||||
inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "-",
|
||||
}),
|
||||
column.number({
|
||||
key: "pdfs",
|
||||
header: t(
|
||||
"portal.billing.invoices.columnPdfsProcessed",
|
||||
"PDFs processed",
|
||||
),
|
||||
align: "right",
|
||||
// Billed units on the invoice's metered line item; "—" when the
|
||||
sortable: true,
|
||||
// Billed units on the invoice's metered line item; blank when the
|
||||
// line-item table isn't synced into the Stripe mirror.
|
||||
render: (inv) =>
|
||||
inv.pdfsProcessed == null ? "—" : inv.pdfsProcessed.toLocaleString(),
|
||||
},
|
||||
{
|
||||
get: (inv) => inv.pdfsProcessed,
|
||||
format: (n) => n.toLocaleString(),
|
||||
}),
|
||||
column.number({
|
||||
key: "amount",
|
||||
header: t("portal.billing.invoices.columnAmount", "Amount"),
|
||||
align: "right",
|
||||
render: (inv) =>
|
||||
inv.totalMinor == null
|
||||
? "—"
|
||||
: formatMinor(inv.totalMinor, inv.currency),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (inv) => inv.totalMinor,
|
||||
format: (n, inv) => formatMinor(n, inv.currency),
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.billing.invoices.columnStatus", "Status"),
|
||||
render: (inv) => (
|
||||
<StatusBadge tone={statusTone(inv.status)} size="sm">
|
||||
{inv.status}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (inv) => ({ tone: statusTone(inv.status), label: inv.status }),
|
||||
}),
|
||||
column.text({
|
||||
key: "description",
|
||||
header: t("portal.billing.invoices.columnDescription", "Description"),
|
||||
render: (inv) => (
|
||||
<span className="portal-billing__invoice-desc">
|
||||
{inv.description ??
|
||||
t("portal.billing.invoices.descriptionFallback", "Invoice")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (inv) =>
|
||||
inv.description ??
|
||||
t("portal.billing.invoices.descriptionFallback", "Invoice"),
|
||||
}),
|
||||
column.links({
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
render: (inv) => (
|
||||
<div className="portal-billing__invoice-actions">
|
||||
{inv.hostedInvoiceUrl && (
|
||||
<a
|
||||
className="portal-billing__invoice-link"
|
||||
href={inv.hostedInvoiceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t(
|
||||
"portal.billing.invoices.viewAriaLabel",
|
||||
"View invoice {{number}} in Stripe",
|
||||
{
|
||||
number: inv.number ?? inv.id,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{t("portal.billing.invoices.viewLink", "View ↗")}
|
||||
</a>
|
||||
)}
|
||||
{inv.invoicePdf && (
|
||||
<a
|
||||
className="portal-billing__invoice-link"
|
||||
href={inv.invoicePdf}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t(
|
||||
"portal.billing.invoices.downloadAriaLabel",
|
||||
"Download invoice {{number}} as PDF",
|
||||
{
|
||||
number: inv.number ?? inv.id,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{t("portal.billing.invoices.pdfLink", "PDF ↓")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
get: (inv) => {
|
||||
const out: CellLink[] = [];
|
||||
if (inv.hostedInvoiceUrl) {
|
||||
out.push({
|
||||
label: t("portal.billing.invoices.viewLink", "View ↗"),
|
||||
href: inv.hostedInvoiceUrl,
|
||||
ariaLabel: t(
|
||||
"portal.billing.invoices.viewAriaLabel",
|
||||
"View invoice {{number}} in Stripe",
|
||||
{ number: inv.number ?? inv.id },
|
||||
),
|
||||
});
|
||||
}
|
||||
if (inv.invoicePdf) {
|
||||
out.push({
|
||||
label: t("portal.billing.invoices.pdfLink", "PDF ↓"),
|
||||
href: inv.invoicePdf,
|
||||
ariaLabel: t(
|
||||
"portal.billing.invoices.downloadAriaLabel",
|
||||
"Download invoice {{number}} as PDF",
|
||||
{ number: inv.number ?? inv.id },
|
||||
),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -209,11 +192,11 @@ export function InvoicesList() {
|
||||
|
||||
{invoices !== null && invoices.length > 0 && (
|
||||
<>
|
||||
<Table
|
||||
className="portal-billing__flush-table"
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={visibleRows}
|
||||
rowKey={(inv) => inv.id}
|
||||
defaultSort={{ key: "date", direction: "desc" }}
|
||||
/>
|
||||
{hasMore && (
|
||||
<div className="portal-billing__invoice-footer">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import { formatPeriodDate, MeterBar, meterState } from "@app/billing";
|
||||
import { formatPeriodDate, MeterBar, remainingMeter } from "@app/billing";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
|
||||
/**
|
||||
@@ -10,8 +10,8 @@ import type { Wallet } from "@portal/api/billing";
|
||||
* - No bundle → a slim "Get 12 months for the price of 10" offer nudge with a
|
||||
* "Review offer" CTA (the demo's commit-nudge card), shown only when a buyer
|
||||
* ({@code onBuy}, leader) is present.
|
||||
* - Bundle held → the capacity meter (fills as the pool is drawn down, so it
|
||||
* warns as capacity runs low) plus a "Top up" action for the leader.
|
||||
* - Bundle held → the capacity meter (drains towards empty as the pool is drawn
|
||||
* down, so it warns as capacity runs low) plus a "Top up" action for the leader.
|
||||
*
|
||||
* Prepaid is consumed before metered billing and sits outside the spend limit, so
|
||||
* it reads as its own dimension. Buying/topping up opens {@code BundleCheckoutModal}
|
||||
@@ -55,8 +55,7 @@ export function PrepaidCapacityCard({
|
||||
|
||||
const remaining = wallet.prepaidUnitsRemaining;
|
||||
const total = wallet.prepaidUnitsTotal;
|
||||
const used = Math.max(0, total - remaining);
|
||||
const { state, pct } = meterState(used, total);
|
||||
const { state, pct } = remainingMeter(remaining, total);
|
||||
const stateLabel =
|
||||
state === "DEGRADED"
|
||||
? t("portal.billing.prepaid.state.exhausted", "Used up")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card } from "@app/ui";
|
||||
import { formatMinor, MeterBar, meterState } from "@app/billing";
|
||||
import { formatMinor, MeterBar, remainingMeter } from "@app/billing";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
import type { LocalUsage } from "@portal/api/link";
|
||||
|
||||
@@ -15,8 +15,10 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* The free Processor-trial meter — "X / N free PDFs used" against the one-time
|
||||
* grant. Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
|
||||
* The free Processor-trial meter — "X of N free PDFs left" against the one-time
|
||||
* grant, with what has been used alongside as the status badge. The bar shows what
|
||||
* is left, so it drains towards empty as the grant is spent.
|
||||
* Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
|
||||
* cloud plan page). The subscribed spend-vs-cap meter is a separate surface
|
||||
* ({@code SpendLimitCard}); this card is only the free face.
|
||||
*
|
||||
@@ -30,7 +32,7 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
|
||||
const pending = unsynced?.totalUnsyncedUnits ?? 0;
|
||||
const used = wallet.billableUsed + pending;
|
||||
const remaining = Math.max(0, wallet.freeRemaining - pending);
|
||||
const { state, pct } = meterState(used, wallet.freeAllowance);
|
||||
const { state, pct } = remainingMeter(remaining, wallet.freeAllowance);
|
||||
const rate =
|
||||
wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0
|
||||
? wallet.pricePerDocMinor
|
||||
@@ -76,11 +78,14 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
|
||||
<MeterBar
|
||||
state={state}
|
||||
pct={pct}
|
||||
barLabel={t("portal.billing.walletMeter.barAria", "Free PDFs used")}
|
||||
figure={used.toLocaleString()}
|
||||
barLabel={t(
|
||||
"portal.billing.walletMeter.barAria",
|
||||
"Free PDFs remaining",
|
||||
)}
|
||||
figure={remaining.toLocaleString()}
|
||||
capSuffix={t(
|
||||
"portal.billing.walletMeter.capSuffix",
|
||||
"of {{allowance}} free PDFs used",
|
||||
"of {{allowance}} free PDFs left",
|
||||
{
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
@@ -88,11 +93,8 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
|
||||
)}
|
||||
statusLabel={t(
|
||||
"portal.billing.walletMeter.statusLabel",
|
||||
"{{remaining}} left",
|
||||
{
|
||||
count: remaining,
|
||||
remaining: remaining.toLocaleString(),
|
||||
},
|
||||
"{{used}} used",
|
||||
{ count: used, used: used.toLocaleString() },
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Extraction } from "@portal/api/documents";
|
||||
import { documentsFor } from "@portal/mocks/documents";
|
||||
import { DocumentExtractions } from "@portal/components/documents/DocumentExtractions";
|
||||
import "@portal/views/Documents.css";
|
||||
|
||||
const ALL = documentsFor("enterprise");
|
||||
const NON_SENSITIVE = ALL.find((d) => !d.sensitive)!;
|
||||
const SENSITIVE = ALL.find((d) => d.sensitive)!;
|
||||
|
||||
// The mock documents ship without extractions, so seed a realistic set here -
|
||||
// a spread of confidence levels so the table (and its confidence sort) is
|
||||
// actually reviewable.
|
||||
const EXTRACTIONS: Extraction[] = [
|
||||
{ field: "Counterparty", value: "Acme Services LLC", confidence: 0.98 },
|
||||
{ field: "Effective date", value: "2026-01-14", confidence: 0.94 },
|
||||
{ field: "Contract value", value: "$248,000.00", confidence: 0.87 },
|
||||
{ field: "Governing law", value: "Delaware", confidence: 0.72 },
|
||||
{ field: "Auto-renewal", value: "Yes (12 months)", confidence: 0.55 },
|
||||
];
|
||||
|
||||
const NON_SENSITIVE = {
|
||||
...ALL.find((d) => !d.sensitive)!,
|
||||
extractions: EXTRACTIONS,
|
||||
fieldsExtracted: EXTRACTIONS.length,
|
||||
};
|
||||
const SENSITIVE = {
|
||||
...ALL.find((d) => d.sensitive)!,
|
||||
extractions: EXTRACTIONS,
|
||||
fieldsExtracted: EXTRACTIONS.length,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof DocumentExtractions> = {
|
||||
title: "Portal/Documents/DocumentExtractions",
|
||||
@@ -23,7 +44,7 @@ const meta: Meta<typeof DocumentExtractions> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DocumentExtractions>;
|
||||
|
||||
/** No extraction data exists yet - the table shows its empty state. */
|
||||
/** Extracted fields with a mix of confidence levels; click a header to sort. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Sensitive doc with no active grant — content stays masked. */
|
||||
@@ -35,3 +56,10 @@ export const Masked: Story = {
|
||||
export const Unlocked: Story = {
|
||||
args: { doc: SENSITIVE, unlocked: true },
|
||||
};
|
||||
|
||||
/** No extraction data yet — the empty state. */
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
doc: { ...NON_SENSITIVE, extractions: [], fieldsExtracted: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LockRounded from "@mui/icons-material/LockRounded";
|
||||
import { StatusBadge, Table, type TableColumn } from "@app/ui";
|
||||
import { column, DataTable, type DataTableColumn } from "@app/ui";
|
||||
import { type Extraction, type ReviewDocument } from "@portal/api/documents";
|
||||
import {
|
||||
confidencePct,
|
||||
@@ -24,32 +24,30 @@ export function DocumentExtractions({
|
||||
}: DocumentExtractionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cols: TableColumn<Extraction>[] = [
|
||||
{
|
||||
const cols: DataTableColumn<Extraction>[] = [
|
||||
column.text({
|
||||
key: "field",
|
||||
header: t("portal.documents.extractions.columns.field"),
|
||||
render: (e) => <span className="portal-documents__field">{e.field}</span>,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (e) => e.field,
|
||||
}),
|
||||
column.mono({
|
||||
key: "value",
|
||||
header: t("portal.documents.extractions.columns.value"),
|
||||
render: (e) => <span className="portal-documents__mono">{e.value}</span>,
|
||||
},
|
||||
{
|
||||
get: (e) => e.value,
|
||||
}),
|
||||
column.badge({
|
||||
key: "confidence",
|
||||
header: t("portal.documents.extractions.columns.confidence"),
|
||||
align: "right",
|
||||
width: "7rem",
|
||||
render: (e) => (
|
||||
<StatusBadge
|
||||
tone={confidenceTone(e.confidence)}
|
||||
size="sm"
|
||||
showDot={false}
|
||||
>
|
||||
{confidencePct(e.confidence)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
sortable: true,
|
||||
// Sort on the whole-percent integer, not the "92%" label (keeps decimals
|
||||
// out of the natural-sort comparator).
|
||||
sortBy: (e) => Math.round(e.confidence * 100),
|
||||
get: (e) => ({
|
||||
tone: confidenceTone(e.confidence),
|
||||
label: confidencePct(e.confidence),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
if (doc.sensitive && !unlocked) {
|
||||
@@ -66,7 +64,7 @@ export function DocumentExtractions({
|
||||
}
|
||||
|
||||
return (
|
||||
<Table<Extraction>
|
||||
<DataTable<Extraction>
|
||||
columns={cols}
|
||||
rows={doc.extractions}
|
||||
rowKey={(e) => e.field}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LockRounded from "@mui/icons-material/LockRounded";
|
||||
import { Button, Chip, StatusBadge, Table, type TableColumn } from "@app/ui";
|
||||
import {
|
||||
type CellLabel,
|
||||
column,
|
||||
DataTable,
|
||||
type DataTableColumn,
|
||||
} from "@app/ui";
|
||||
import {
|
||||
classificationTone,
|
||||
DOCUMENT_STATUS_LABEL,
|
||||
DOCUMENT_STATUS_TONE,
|
||||
PRODUCT_CHIP_TONE,
|
||||
type ReviewDocument,
|
||||
} from "@portal/api/documents";
|
||||
|
||||
@@ -15,160 +18,105 @@ interface ReviewQueueTableProps {
|
||||
onRowClick: (doc: ReviewDocument) => void;
|
||||
}
|
||||
|
||||
function BoltIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function KebabIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="12" cy="5" r="1.6" />
|
||||
<circle cx="12" cy="12" r="1.6" />
|
||||
<circle cx="12" cy="19" r="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** The document stream - one row per document your org has processed. */
|
||||
export function ReviewQueueTable({
|
||||
documents,
|
||||
onRowClick,
|
||||
}: ReviewQueueTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<ReviewDocument>[]>(
|
||||
const columns = useMemo<DataTableColumn<ReviewDocument>[]>(
|
||||
() => [
|
||||
{
|
||||
column.entity({
|
||||
key: "document",
|
||||
header: t("portal.documents.table.columns.document"),
|
||||
render: (d) => (
|
||||
<div className="portal-documents__doc-cell">
|
||||
<div className="portal-documents__doc-head">
|
||||
<span className="portal-documents__name">{d.name}</span>
|
||||
{d.classification && (
|
||||
<Chip accent={classificationTone(d)} size="sm">
|
||||
{d.classification}
|
||||
</Chip>
|
||||
)}
|
||||
{d.auto && (
|
||||
<Chip accent="success" size="sm" leadingIcon={<BoltIcon />}>
|
||||
{t("portal.documents.table.auto")}
|
||||
</Chip>
|
||||
)}
|
||||
{d.sensitive && (
|
||||
// role="img" so the label is allowed and the icon reads as one thing: aria-label
|
||||
// is ignored on a bare span, leaving the padlock silent.
|
||||
<span
|
||||
className="portal-documents__lock"
|
||||
role="img"
|
||||
title={t("portal.documents.table.sensitiveTitle")}
|
||||
aria-label={t("portal.documents.table.sensitiveLabel")}
|
||||
>
|
||||
<LockRounded style={{ fontSize: "0.95rem" }} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{d.note && <span className="portal-documents__note">{d.note}</span>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
primary: (d) => d.name,
|
||||
note: (d) => d.note,
|
||||
}),
|
||||
column.labels({
|
||||
key: "labels",
|
||||
header: t("portal.documents.table.columns.labels", "Labels"),
|
||||
get: (d) => {
|
||||
const out: CellLabel[] = [];
|
||||
if (d.classification) {
|
||||
out.push({
|
||||
label: d.classification,
|
||||
accent: classificationTone(d),
|
||||
});
|
||||
}
|
||||
if (d.auto) {
|
||||
out.push({
|
||||
label: t("portal.documents.table.auto"),
|
||||
accent: "success",
|
||||
});
|
||||
}
|
||||
if (d.sensitive) {
|
||||
out.push({
|
||||
label: t("portal.documents.table.sensitiveLabel"),
|
||||
accent: "warning",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
}),
|
||||
column.text({
|
||||
key: "product",
|
||||
header: t("portal.documents.table.columns.product"),
|
||||
width: "7rem",
|
||||
render: (d) => (
|
||||
<Chip accent={PRODUCT_CHIP_TONE[d.product]} size="sm" showDot={false}>
|
||||
{d.product}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (d) => d.product,
|
||||
}),
|
||||
column.text({
|
||||
key: "action",
|
||||
header: t("portal.documents.table.columns.action"),
|
||||
width: "12rem",
|
||||
render: (d) =>
|
||||
d.product === "Editor" || !d.action ? (
|
||||
<span className="portal-documents__editor-action">
|
||||
{t("portal.documents.table.editorAction")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="portal-documents__action">{d.action}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (d) =>
|
||||
d.product === "Editor" || !d.action
|
||||
? t("portal.documents.table.editorAction")
|
||||
: d.action,
|
||||
}),
|
||||
column.muted({
|
||||
key: "user",
|
||||
header: t("portal.documents.table.columns.user"),
|
||||
width: "8rem",
|
||||
render: (d) => (
|
||||
<span className="portal-documents__muted">{d.user || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (d) => d.user,
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.documents.table.columns.status"),
|
||||
width: "10rem",
|
||||
render: (d) => (
|
||||
<StatusBadge tone={DOCUMENT_STATUS_TONE[d.status]} size="sm">
|
||||
{t(DOCUMENT_STATUS_LABEL[d.status])}
|
||||
{d.status === "in-review" && d.reviewer ? ` · ${d.reviewer}` : ""}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (d) => ({
|
||||
tone: DOCUMENT_STATUS_TONE[d.status],
|
||||
label:
|
||||
t(DOCUMENT_STATUS_LABEL[d.status]) +
|
||||
(d.status === "in-review" && d.reviewer ? ` · ${d.reviewer}` : ""),
|
||||
}),
|
||||
}),
|
||||
column.muted({
|
||||
key: "time",
|
||||
header: t("portal.documents.table.columns.time"),
|
||||
width: "7rem",
|
||||
render: (d) => (
|
||||
<span className="portal-documents__muted">{d.time}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
get: (d) => d.time,
|
||||
}),
|
||||
column.actions({
|
||||
key: "actions",
|
||||
header: t("portal.documents.table.columns.actions"),
|
||||
headerHidden: true,
|
||||
width: "3rem",
|
||||
render: (d) => (
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
shape="circle"
|
||||
leftSection={<KebabIcon />}
|
||||
aria-label={t("portal.documents.table.rowActions")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRowClick(d);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
get: (d) => [
|
||||
{
|
||||
label: t("portal.documents.table.rowActions"),
|
||||
glyph: "kebab",
|
||||
iconOnly: true,
|
||||
onClick: () => onRowClick(d),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[t, onRowClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<ReviewDocument>
|
||||
className="portal-documents__table"
|
||||
<DataTable<ReviewDocument>
|
||||
columns={columns}
|
||||
rows={documents}
|
||||
rowKey={(d) => d.id}
|
||||
onRowClick={onRowClick}
|
||||
// Every row carries its own actions button, which opens the same document.
|
||||
rowsContainControls
|
||||
empty={t("portal.documents.table.empty")}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Card,
|
||||
Chip,
|
||||
type ChipAccent,
|
||||
EmptyState,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import { column, DataTable, type DataTableColumn, EmptyState } from "@app/ui";
|
||||
import {
|
||||
INSTANCE_STATUS_LABEL,
|
||||
INSTANCE_STATUS_TONE,
|
||||
TARGET_META,
|
||||
type EditorInstance,
|
||||
} from "@portal/api/editorDeploy";
|
||||
|
||||
@@ -22,13 +13,6 @@ const TARGET_LABEL: Record<EditorInstance["target"], string> = {
|
||||
kubernetes: "K8s",
|
||||
};
|
||||
|
||||
/** Map the target palette tone onto the shared Chip accent set. */
|
||||
const TARGET_CHIP_ACCENT: Record<"neutral" | "blue" | "purple", ChipAccent> = {
|
||||
neutral: "neutral",
|
||||
blue: "default",
|
||||
purple: "premium",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
instances: EditorInstance[];
|
||||
}
|
||||
@@ -37,69 +21,65 @@ interface Props {
|
||||
export function InstanceHealthTable({ instances }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cols: TableColumn<EditorInstance>[] = [
|
||||
{
|
||||
const cols: DataTableColumn<EditorInstance>[] = [
|
||||
column.entity({
|
||||
key: "host",
|
||||
header: t("portal.editorAdmin.health.columns.host"),
|
||||
render: (i) => (
|
||||
<div className="portal-editor__cell-stack">
|
||||
<span className="portal-editor__cell-strong">{i.host}</span>
|
||||
<span className="portal-editor__cell-muted">
|
||||
<Chip
|
||||
size="sm"
|
||||
accent={TARGET_CHIP_ACCENT[TARGET_META[i.target].tone]}
|
||||
>
|
||||
{TARGET_LABEL[i.target]}
|
||||
</Chip>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
primary: (i) => i.host,
|
||||
}),
|
||||
column.text({
|
||||
key: "target",
|
||||
header: t("portal.editorAdmin.health.columns.target", "Target"),
|
||||
sortable: true,
|
||||
get: (i) => TARGET_LABEL[i.target],
|
||||
}),
|
||||
column.mono({
|
||||
key: "version",
|
||||
header: t("portal.editorAdmin.health.columns.version"),
|
||||
render: (i) => <code className="portal-editor__mono">{i.version}</code>,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (i) => i.version,
|
||||
}),
|
||||
column.mono({
|
||||
key: "region",
|
||||
header: t("portal.editorAdmin.health.columns.region"),
|
||||
render: (i) => <span className="portal-editor__mono">{i.region}</span>,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (i) => i.region,
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.editorAdmin.health.columns.status"),
|
||||
render: (i) => (
|
||||
<StatusBadge tone={INSTANCE_STATUS_TONE[i.status]} size="sm">
|
||||
{t(INSTANCE_STATUS_LABEL[i.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (i) => ({
|
||||
tone: INSTANCE_STATUS_TONE[i.status],
|
||||
label: t(INSTANCE_STATUS_LABEL[i.status]),
|
||||
}),
|
||||
}),
|
||||
column.muted({
|
||||
key: "lastSeen",
|
||||
header: t("portal.editorAdmin.health.columns.lastSeen"),
|
||||
render: (i) => <span className="portal-editor__muted">{i.lastSeen}</span>,
|
||||
},
|
||||
{
|
||||
get: (i) => i.lastSeen,
|
||||
}),
|
||||
column.number({
|
||||
key: "activeUsers",
|
||||
header: t("portal.editorAdmin.health.columns.activeUsers"),
|
||||
align: "right",
|
||||
render: (i) => (
|
||||
<span className="portal-editor__mono">{i.activeUsers}</span>
|
||||
),
|
||||
},
|
||||
sortable: true,
|
||||
get: (i) => i.activeUsers,
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<Card padding="none">
|
||||
{instances.length === 0 ? (
|
||||
<DataTable<EditorInstance>
|
||||
columns={cols}
|
||||
rows={instances}
|
||||
rowKey={(i) => i.id}
|
||||
empty={
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.editorAdmin.health.empty.title")}
|
||||
description={t("portal.editorAdmin.health.empty.description")}
|
||||
/>
|
||||
) : (
|
||||
<Table columns={cols} rows={instances} rowKey={(i) => i.id} />
|
||||
)}
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function FileRunEventList() {
|
||||
const debugPanel = !import.meta.env.DEV ? null : (
|
||||
<div className="portal-failures__debug">
|
||||
<Button variant="secondary" size="sm" onClick={() => void refresh()}>
|
||||
Refresh failures
|
||||
{t("portal.failures.debug.refresh", "Refresh failures")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -77,14 +77,24 @@ export function FileRunEventList() {
|
||||
disabled={clearing || (events?.length ?? 0) === 0}
|
||||
onClick={() => void dismissAll()}
|
||||
>
|
||||
{clearing ? "Dismissing..." : `Dismiss all (${events?.length ?? 0})`}
|
||||
{clearing
|
||||
? t("portal.failures.debug.dismissing", "Dismissing...")
|
||||
: t("portal.failures.debug.dismissAll", "Dismiss all ({{total}})", {
|
||||
total: events?.length ?? 0,
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowJson((shown) => !shown)}
|
||||
>
|
||||
{showJson ? "Hide" : "Show"} raw JSON ({events?.length ?? 0})
|
||||
{showJson
|
||||
? t("portal.failures.debug.hideJson", "Hide raw JSON ({{total}})", {
|
||||
total: events?.length ?? 0,
|
||||
})
|
||||
: t("portal.failures.debug.showJson", "Show raw JSON ({{total}})", {
|
||||
total: events?.length ?? 0,
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -95,7 +105,7 @@ export function FileRunEventList() {
|
||||
)
|
||||
}
|
||||
>
|
||||
Copy JSON
|
||||
{t("portal.failures.debug.copyJson", "Copy JSON")}
|
||||
</Button>
|
||||
{showJson && (
|
||||
<pre className="portal-failures__debug-json">
|
||||
|
||||
@@ -2,15 +2,14 @@ import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
column,
|
||||
DataTable,
|
||||
type DataTableColumn,
|
||||
EmptyState,
|
||||
MetricCard,
|
||||
MetricStrip,
|
||||
StatusBadge,
|
||||
Table,
|
||||
Tabs,
|
||||
type TabItem,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useSectionFlags } from "@portal/hooks/useAsync";
|
||||
@@ -22,10 +21,8 @@ import {
|
||||
} from "@portal/api/infrastructure";
|
||||
import { AuditExportModal } from "@portal/components/infrastructure/AuditExportModal";
|
||||
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
|
||||
import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton";
|
||||
import {
|
||||
AUDIT_CAT_LABEL,
|
||||
AUDIT_CAT_TONE,
|
||||
AUDIT_STATUS_LABEL,
|
||||
AUDIT_TONE,
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
@@ -57,55 +54,52 @@ export function AuditTab() {
|
||||
},
|
||||
];
|
||||
|
||||
const cols: TableColumn<AuditEvent>[] = [
|
||||
{
|
||||
const cols: DataTableColumn<AuditEvent>[] = [
|
||||
column.mono({
|
||||
key: "timestamp",
|
||||
header: t("portal.infrastructure.audit.columns.timestamp"),
|
||||
render: (e) => <span className="portal-infra__mono">{e.timestamp}</span>,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (e) => e.timestamp,
|
||||
}),
|
||||
column.text({
|
||||
key: "event",
|
||||
header: t("portal.infrastructure.audit.columns.event"),
|
||||
render: (e) => (
|
||||
<div className="portal-infra__event">
|
||||
<StatusBadge tone={AUDIT_CAT_TONE[e.category]} size="sm">
|
||||
{t(AUDIT_CAT_LABEL[e.category])}
|
||||
</StatusBadge>
|
||||
<span>{e.action}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
// "Category: action" on one line - the category as a bold label, no
|
||||
// status-coloured dot. Colour is reserved for the Status column, where it
|
||||
// actually signals an outcome.
|
||||
label: (e) => t(AUDIT_CAT_LABEL[e.category]),
|
||||
get: (e) => e.action,
|
||||
}),
|
||||
column.mono({
|
||||
key: "actor",
|
||||
header: t("portal.infrastructure.audit.columns.actor"),
|
||||
render: (e) => <span className="portal-infra__mono">{e.actor}</span>,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (e) => e.actor,
|
||||
}),
|
||||
column.text({
|
||||
key: "target",
|
||||
header: t("portal.infrastructure.audit.columns.target"),
|
||||
render: (e) => e.target,
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (e) => e.target,
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.infrastructure.audit.columns.status"),
|
||||
render: (e) => (
|
||||
<StatusBadge tone={AUDIT_TONE[e.status]} size="sm">
|
||||
{t(AUDIT_STATUS_LABEL[e.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (e) => ({
|
||||
tone: AUDIT_TONE[e.status],
|
||||
label: t(AUDIT_STATUS_LABEL[e.status]),
|
||||
}),
|
||||
}),
|
||||
column.number({
|
||||
key: "latency",
|
||||
header: t("portal.infrastructure.audit.columns.latency"),
|
||||
align: "right",
|
||||
render: (e) => (
|
||||
<span className="portal-infra__mono">
|
||||
{t("portal.infrastructure.audit.latencyValue", {
|
||||
value: e.latencyMs,
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
sortable: true,
|
||||
get: (e) => e.latencyMs,
|
||||
format: (n) =>
|
||||
t("portal.infrastructure.audit.latencyValue", { value: n }),
|
||||
}),
|
||||
];
|
||||
|
||||
const state = useAuditLog(tier);
|
||||
@@ -171,31 +165,31 @@ export function AuditTab() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card padding="none">
|
||||
{isLoading && <TableSkeleton rows={6} cols={6} />}
|
||||
{!isLoading && forbidden && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.infrastructure.audit.forbidden.title")}
|
||||
description={t("portal.infrastructure.audit.forbidden.description")}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !forbidden && isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.infrastructure.audit.empty.title")}
|
||||
description={t("portal.infrastructure.audit.empty.description")}
|
||||
/>
|
||||
)}
|
||||
{!isEmpty && data && (
|
||||
<Table
|
||||
columns={cols}
|
||||
rows={rows}
|
||||
rowKey={(e) => e.id}
|
||||
empty={t("portal.infrastructure.audit.noEventsInCategory")}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<DataTable
|
||||
columns={cols}
|
||||
rows={rows}
|
||||
rowKey={(e) => e.id}
|
||||
loading={isLoading}
|
||||
empty={
|
||||
forbidden ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.infrastructure.audit.forbidden.title")}
|
||||
description={t(
|
||||
"portal.infrastructure.audit.forbidden.description",
|
||||
)}
|
||||
/>
|
||||
) : isEmpty ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.infrastructure.audit.empty.title")}
|
||||
description={t("portal.infrastructure.audit.empty.description")}
|
||||
/>
|
||||
) : (
|
||||
t("portal.infrastructure.audit.noEventsInCategory")
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Card } from "@app/ui";
|
||||
import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton";
|
||||
import "@portal/views/Infrastructure.css";
|
||||
|
||||
const meta: Meta<typeof TableSkeleton> = {
|
||||
title: "Portal/Infrastructure/TableSkeleton",
|
||||
component: TableSkeleton,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<Card padding="none" style={{ maxWidth: "60rem" }}>
|
||||
<S />
|
||||
</Card>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TableSkeleton>;
|
||||
|
||||
export const Regions: Story = { args: { rows: 3, cols: 9 } };
|
||||
|
||||
export const AuditLog: Story = { args: { rows: 6, cols: 6 } };
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Skeleton } from "@app/ui";
|
||||
|
||||
/** Placeholder grid shown inside a table card while rows are loading. */
|
||||
export function TableSkeleton({ rows, cols }: { rows: number; cols: number }) {
|
||||
return (
|
||||
<div className="portal-infra__table-skel" aria-hidden>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div key={r} className="portal-infra__table-skel-row">
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<Skeleton key={c} height="0.75rem" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,12 +39,3 @@ export const AUDIT_CAT_LABEL: Record<AuditCategory, string> = {
|
||||
processing: "portal.infrastructure.auditCatLabel.processing",
|
||||
security: "portal.infrastructure.auditCatLabel.security",
|
||||
};
|
||||
|
||||
export const AUDIT_CAT_TONE: Record<AuditCategory, StatusTone> = {
|
||||
auth: "info",
|
||||
config: "neutral",
|
||||
elevation: "purple",
|
||||
policy: "purple",
|
||||
processing: "success",
|
||||
security: "warning",
|
||||
};
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useEffect, useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
Component,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
|
||||
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
|
||||
import {
|
||||
getExecutableTools,
|
||||
type WorkingToolStep,
|
||||
} from "@app/hooks/tools/shared/toolAutomation";
|
||||
import {
|
||||
asRegistryConfig,
|
||||
type ErasedToolParams,
|
||||
@@ -13,6 +26,10 @@ import {
|
||||
import ConvertSettings from "@app/components/tools/convert/ConvertSettings";
|
||||
import { convertOperationConfig } from "@app/hooks/tools/convert/useConvertOperation";
|
||||
import { defaultParameters as convertDefaults } from "@app/hooks/tools/convert/useConvertParameters";
|
||||
import ChangeMetadataSingleStep from "@app/components/tools/changeMetadata/ChangeMetadataSingleStep";
|
||||
import { defaultParameters as changeMetadataDefaults } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings";
|
||||
import { defaultParameters as overlayDefaults } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
|
||||
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
|
||||
|
||||
// Override only useTranslation; keep the rest of react-i18next (initReactI18next et al.) real, so
|
||||
@@ -21,6 +38,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
i18n: { language: "en-US", changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -63,6 +81,32 @@ const convertRegistry = {
|
||||
},
|
||||
} as unknown as Partial<ToolRegistry>;
|
||||
|
||||
// The real Change Metadata automation settings. Its editor variant auto-prefills the
|
||||
// form from the open document via useViewer; that path is now gated on a ViewerProvider
|
||||
// so it renders here (the portal mounts none) instead of crashing on useViewer.
|
||||
const changeMetadataStep = {
|
||||
support: "editable",
|
||||
toolId: "changeMetadata",
|
||||
params: changeMetadataDefaults,
|
||||
} as unknown as WorkingToolStep;
|
||||
|
||||
const changeMetadataRegistry = {
|
||||
changeMetadata: { automationSettings: ChangeMetadataSingleStep },
|
||||
} as unknown as Partial<ToolRegistry>;
|
||||
|
||||
// The real Overlay PDFs automation settings. Its overlay-file picker uses the
|
||||
// editor FilesModal when present; that read is now optional so the portal (which
|
||||
// mounts no FilesModalProvider) renders a plain file input instead of crashing.
|
||||
const overlayStep = {
|
||||
support: "editable",
|
||||
toolId: "overlayPdfs",
|
||||
params: overlayDefaults,
|
||||
} as unknown as WorkingToolStep;
|
||||
|
||||
const overlayRegistry = {
|
||||
overlayPdfs: { automationSettings: OverlayPdfsSettings },
|
||||
} as unknown as Partial<ToolRegistry>;
|
||||
|
||||
describe("PipelineStepSettings", () => {
|
||||
it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
|
||||
expect(() =>
|
||||
@@ -94,6 +138,36 @@ describe("PipelineStepSettings", () => {
|
||||
expect(screen.getByText(/Convert from/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Change Metadata tool's fields in the portal, with no ViewerProvider mounted", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<PortalTestProviders>
|
||||
<PipelineStepSettings
|
||||
step={changeMetadataStep}
|
||||
registry={changeMetadataRegistry}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</PortalTestProviders>,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(screen.getByText("Standard Metadata")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Overlay PDFs tool's fields in the portal, with no FilesModalProvider mounted", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<PortalTestProviders>
|
||||
<PipelineStepSettings
|
||||
step={overlayStep}
|
||||
registry={overlayRegistry}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</PortalTestProviders>,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(screen.getByText("Overlay Mode")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reproduces the convert-in-pipeline bug: picking a source format fires several onParameterChange
|
||||
// calls in one tick (set fromExtension, auto-target, reset options). If each rebuilt from the
|
||||
// step snapshot captured at render they'd clobber each other and the earlier field would be lost.
|
||||
@@ -148,3 +222,91 @@ describe("PipelineStepSettings", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Records a render crash and swallows it (renders nothing), so one broken tool is attributed by id
|
||||
// instead of aborting the whole sweep - mirroring the portal's own ErrorBoundary around the builder.
|
||||
class CaptureBoundary extends Component<
|
||||
{ onError: (error: Error) => void; children: ReactNode },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
componentDidCatch(error: Error) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? null : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Automated version of the manual "add every tool" sweep: render each tool's real automation
|
||||
// settings in a portal-only context (the same Preferences + Sidebar + Suspense wrappers
|
||||
// PipelineStepSettings uses, and NO editor providers) and fail listing any that throw. This is the
|
||||
// guard that would have caught Change Metadata (useViewer) and Overlay PDFs (useFilesModalContext).
|
||||
describe("PipelineStepSettings: every tool's settings render in the portal", () => {
|
||||
it("renders each tool's automation settings without throwing", async () => {
|
||||
const { result } = renderHook(() => useTranslatedToolCatalog());
|
||||
const catalog = result.current.allTools;
|
||||
// getExecutableTools is exactly what PipelineBuilder feeds its "Add a tool" picker, so this
|
||||
// sweeps precisely the tools a user can add. Narrow to "editable" (renders a settings
|
||||
// component); "noSettings"/"unsupported" steps show a Banner instead and can't crash.
|
||||
const editableTools = getExecutableTools(catalog)
|
||||
.filter((tool) => tool.support === "editable")
|
||||
.map((tool) => [tool.toolId, catalog[tool.toolId]] as const)
|
||||
.filter(([, entry]) => Boolean(entry?.automationSettings));
|
||||
// Guard against the filter silently matching nothing (e.g. a registry-shape change).
|
||||
expect(editableTools.length).toBeGreaterThan(10);
|
||||
|
||||
const failures: { toolId: string; message: string }[] = [];
|
||||
|
||||
for (const [toolId, entry] of editableTools) {
|
||||
const Settings = entry.automationSettings as ComponentType<
|
||||
ToolAutomationSettingsProps<ErasedToolParams>
|
||||
>;
|
||||
const params = (entry.operationConfig?.defaultParameters ??
|
||||
{}) as ErasedToolParams;
|
||||
|
||||
const caught: { error: Error | null } = { error: null };
|
||||
// The sentinel sibling commits only once the lazy Settings actually renders, so we wait for a
|
||||
// real render (or a caught throw) - not just the providers' wrapper DOM.
|
||||
const { unmount } = render(
|
||||
<PortalTestProviders>
|
||||
<PreferencesProvider>
|
||||
<SidebarProvider>
|
||||
<CaptureBoundary
|
||||
onError={(error) => {
|
||||
caught.error = error;
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Settings
|
||||
parameters={params}
|
||||
onParameterChange={() => {}}
|
||||
disabled={false}
|
||||
/>
|
||||
<span data-testid={`rendered-${toolId}`} />
|
||||
</Suspense>
|
||||
</CaptureBoundary>
|
||||
</SidebarProvider>
|
||||
</PreferencesProvider>
|
||||
</PortalTestProviders>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
caught.error !== null ||
|
||||
screen.queryByTestId(`rendered-${toolId}`) !== null,
|
||||
).toBe(true),
|
||||
);
|
||||
|
||||
if (caught.error) {
|
||||
failures.push({ toolId, message: caught.error.message });
|
||||
}
|
||||
unmount();
|
||||
}
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AccountTreeRounded from "@mui/icons-material/AccountTreeRounded";
|
||||
import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded";
|
||||
import {
|
||||
Chip,
|
||||
StatusBadge,
|
||||
column,
|
||||
DataTable,
|
||||
type DataTableColumn,
|
||||
type StatusTone,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import type { PipelineStatus, PipelineView } from "@portal/api/pipelines";
|
||||
|
||||
@@ -24,87 +22,56 @@ interface PipelinesTableProps {
|
||||
|
||||
export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<PipelineView>[]>(
|
||||
const columns = useMemo<DataTableColumn<PipelineView>[]>(
|
||||
() => [
|
||||
{
|
||||
column.entity({
|
||||
key: "name",
|
||||
header: t("portal.pipelines.table.name"),
|
||||
render: (p) => (
|
||||
<div className="portal-pipelines__name-cell">
|
||||
<span className="portal-pipelines__pipe-dot" aria-hidden>
|
||||
<AccountTreeRounded style={{ fontSize: "1.2rem" }} />
|
||||
</span>
|
||||
<div className="portal-pipelines__name-text">
|
||||
<strong>{p.name}</strong>
|
||||
<Chip accent="neutral" size="sm">
|
||||
{t(`portal.pipelines.trigger.${p.trigger}`, {
|
||||
defaultValue: p.trigger,
|
||||
})}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
icon: () => <AccountTreeRounded />,
|
||||
primary: (p) => p.name,
|
||||
}),
|
||||
column.text({
|
||||
key: "trigger",
|
||||
header: t("portal.pipelines.table.trigger", "Trigger"),
|
||||
sortable: true,
|
||||
get: (p) =>
|
||||
t(`portal.pipelines.trigger.${p.trigger}`, {
|
||||
defaultValue: p.trigger,
|
||||
}),
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.pipelines.table.status"),
|
||||
render: (p) => (
|
||||
<StatusBadge tone={STATUS_TONE[p.status]} size="sm">
|
||||
{t(`portal.pipelines.status.${p.status}`)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (p) => ({
|
||||
tone: STATUS_TONE[p.status],
|
||||
label: t(`portal.pipelines.status.${p.status}`),
|
||||
}),
|
||||
}),
|
||||
column.number({
|
||||
key: "steps",
|
||||
header: t("portal.pipelines.table.steps"),
|
||||
align: "right",
|
||||
render: (p) => (
|
||||
<span
|
||||
className={
|
||||
p.steps.length === 0 ? "portal-pipelines__muted" : undefined
|
||||
}
|
||||
>
|
||||
{p.steps.length}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (p) => p.steps.length,
|
||||
}),
|
||||
column.number({
|
||||
key: "sources",
|
||||
header: t("portal.pipelines.table.sources"),
|
||||
align: "right",
|
||||
render: (p) => (
|
||||
<span
|
||||
className={
|
||||
p.sources.length === 0 ? "portal-pipelines__muted" : undefined
|
||||
}
|
||||
>
|
||||
{p.sources.length}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "open",
|
||||
header: t("portal.pipelines.table.open"),
|
||||
headerHidden: true,
|
||||
align: "right",
|
||||
width: "2.5rem",
|
||||
render: () => (
|
||||
<span className="portal-pipelines__caret" aria-hidden>
|
||||
<ChevronRightRoundedIcon style={{ fontSize: "1.25rem" }} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
sortable: true,
|
||||
get: (p) => p.sources.length,
|
||||
}),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<PipelineView>
|
||||
className="portal-pipelines__table"
|
||||
<DataTable<PipelineView>
|
||||
columns={columns}
|
||||
rows={pipelines}
|
||||
rowKey={(p) => p.id}
|
||||
onRowClick={onRowClick}
|
||||
rowAffordance="chevron"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Chip, StatusBadge, Table, type TableColumn } from "@app/ui";
|
||||
import { column, DataTable, type DataTableColumn } from "@app/ui";
|
||||
import type { CatalogueEntry } from "@portal/api/policies";
|
||||
import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryIcon";
|
||||
import "@portal/views/Policies.css";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
|
||||
interface PolicyCatalogueTableProps {
|
||||
entries: CatalogueEntry[];
|
||||
@@ -15,10 +14,9 @@ interface PolicyCatalogueTableProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy catalogue as a proper data table (Policy / Enforces / Applies to /
|
||||
* Docs / Status), replacing the stacked full-width cards that read as "blocky".
|
||||
* Same shared Table + StatusBadge + Chip primitives the Sources, Documents and
|
||||
* Home policy tables use, so every list page in the portal now reads alike.
|
||||
* The policy catalogue as a data table (Policy / Enforces / Applies to / Docs /
|
||||
* Status). The status column resolves per row to a state badge, an info chip
|
||||
* (coming soon / requires AI), or a "Set up" call to action.
|
||||
*/
|
||||
export function PolicyCatalogueTable({
|
||||
entries,
|
||||
@@ -28,109 +26,81 @@ export function PolicyCatalogueTable({
|
||||
}: PolicyCatalogueTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns = useMemo<TableColumn<CatalogueEntry>[]>(
|
||||
const columns = useMemo<DataTableColumn<CatalogueEntry>[]>(
|
||||
() => [
|
||||
{
|
||||
column.entity({
|
||||
key: "policy",
|
||||
header: t("portal.policies.table.policy", "Policy"),
|
||||
render: (entry) => (
|
||||
<div className="portal-policies__cell">
|
||||
<PolicyCategoryBadge category={entry.category} />
|
||||
<strong className="portal-policies__cell-name">
|
||||
{t(entry.category.label)}
|
||||
</strong>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
icon: (entry) => policyCategoryIcon(entry.category.id),
|
||||
primary: (entry) => t(entry.category.label),
|
||||
}),
|
||||
column.text({
|
||||
key: "enforces",
|
||||
header: t("portal.policies.table.enforces", "Enforces"),
|
||||
render: (entry) => (
|
||||
<div className="portal-policies__rulechips">
|
||||
{entry.config.rules.map((r) => (
|
||||
<Chip key={r} accent="neutral" size="sm">
|
||||
{t(r)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
get: (entry) => entry.config.rules.map((r) => t(r)).join(", "),
|
||||
}),
|
||||
column.muted({
|
||||
key: "scope",
|
||||
header: t("portal.policies.table.appliesTo", "Applies to"),
|
||||
render: (entry) => (
|
||||
<span className="portal-policies__muted">
|
||||
{t(entry.config.scopeLabel)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (entry) => t(entry.config.scopeLabel),
|
||||
}),
|
||||
column.number({
|
||||
key: "docs",
|
||||
header: t("portal.policies.table.docs", "Docs enforced"),
|
||||
align: "right",
|
||||
width: "8rem",
|
||||
render: (entry) => (
|
||||
<span className="portal-policies__docs">
|
||||
{entry.policy ? entry.policy.stats.enforced.toLocaleString() : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
get: (entry) => (entry.policy ? entry.policy.stats.enforced : null),
|
||||
format: (n) => n.toLocaleString(),
|
||||
}),
|
||||
column.badge({
|
||||
key: "status",
|
||||
header: t("portal.policies.table.status", "Status"),
|
||||
align: "right",
|
||||
width: "8.5rem",
|
||||
render: (entry) => {
|
||||
sortable: true,
|
||||
// Every state reads as one badge; the "set up" affordance is the row
|
||||
// itself (click + chevron), so there's no bespoke button in the cell.
|
||||
get: (entry) => {
|
||||
if (entry.category.comingSoon) {
|
||||
// One consistent neutral chip for every "Upgrade to Enterprise" —
|
||||
// the same action should read the same on every row.
|
||||
return (
|
||||
<Chip accent="neutral" size="sm">
|
||||
{t("portal.policies.card.comingSoon")}
|
||||
</Chip>
|
||||
);
|
||||
return {
|
||||
tone: "neutral",
|
||||
label: t("portal.policies.card.comingSoon"),
|
||||
};
|
||||
}
|
||||
if (isLocked?.(entry)) {
|
||||
return (
|
||||
<Chip accent="neutral" size="sm">
|
||||
{lockedLabel ?? t("portal.policies.card.requiresAiEngine")}
|
||||
</Chip>
|
||||
);
|
||||
return {
|
||||
tone: "neutral",
|
||||
label: lockedLabel ?? t("portal.policies.card.requiresAiEngine"),
|
||||
};
|
||||
}
|
||||
if (entry.policy) {
|
||||
const paused = entry.policy.state.status === "paused";
|
||||
return (
|
||||
<StatusBadge tone={paused ? "warning" : "success"} size="sm">
|
||||
{paused
|
||||
? t("portal.policies.status.paused")
|
||||
: t("portal.policies.status.active")}
|
||||
</StatusBadge>
|
||||
);
|
||||
return {
|
||||
tone: paused ? "warning" : "success",
|
||||
label: paused
|
||||
? t("portal.policies.status.paused")
|
||||
: t("portal.policies.status.active"),
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button size="sm" variant="secondary" onClick={() => onOpen(entry)}>
|
||||
{t("portal.policySummary.action.setUp")}
|
||||
</Button>
|
||||
);
|
||||
return {
|
||||
tone: "neutral",
|
||||
label: t("portal.policySummary.action.setUp"),
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t, onOpen, isLocked, lockedLabel],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<CatalogueEntry>
|
||||
className="portal-policies__table"
|
||||
<DataTable<CatalogueEntry>
|
||||
columns={columns}
|
||||
rows={entries}
|
||||
rowKey={(e) => e.category.id}
|
||||
onRowClick={(entry) =>
|
||||
entry.category.comingSoon || isLocked?.(entry)
|
||||
? undefined
|
||||
: onOpen(entry)
|
||||
onRowClick={onOpen}
|
||||
isRowInteractive={(e) =>
|
||||
!(e.category.comingSoon || (isLocked?.(e) ?? false))
|
||||
}
|
||||
// A category with no policy yet renders a "set up" button, which opens the same thing.
|
||||
rowsContainControls
|
||||
rowAffordance="chevron"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user