mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-10-16 15:45:04 +08:00
Merge branch 'dev' into unify-root-uv-workspace
Resolve the Pydantic model conflict using annotation-only field definitions and preserve the typed TypeForm cast. Regenerate the workspace lock and requirements exports for the merged dependencies.
This commit is contained in:
@@ -113,6 +113,18 @@ jobs:
|
||||
echo "GALAXY_TEST_COVERAGE=1" >> $GITHUB_ENV
|
||||
# Skip outputs_to_working_directory: true in integration tests, doesn't work with pulsar
|
||||
# echo "GALAXY_CONFIG_OVERRIDE_OUTPUTS_TO_WORKING_DIRECTORY=true" >> $GITHUB_ENV
|
||||
- name: Pull Docker Hub images through mirror.gcr.io
|
||||
# Google's cache of Docker Hub, so that tests don't fail on Docker Hub
|
||||
# rate limits and outages. The daemon falls back to Docker Hub for images
|
||||
# that are not cached. Must run before anything starts containers, as
|
||||
# restarting the daemon stops them. Apptainer pulls are not affected.
|
||||
run: |
|
||||
[ -s /etc/docker/daemon.json ] || echo '{}' | sudo tee /etc/docker/daemon.json
|
||||
jq '."registry-mirrors" = ["https://mirror.gcr.io"]' /etc/docker/daemon.json > "$RUNNER_TEMP/daemon.json"
|
||||
sudo mv "$RUNNER_TEMP/daemon.json" /etc/docker/daemon.json
|
||||
cat /etc/docker/daemon.json
|
||||
sudo systemctl restart docker
|
||||
docker info --format '{{.RegistryConfig.Mirrors}}' | grep -q mirror.gcr.io
|
||||
- name: Prune unused docker image, volumes and containers
|
||||
run: docker system prune -a -f
|
||||
- name: Clean dotnet folder for space
|
||||
|
||||
@@ -40,6 +40,9 @@ jobs:
|
||||
- name: Run ESLint
|
||||
run: pnpm run eslint
|
||||
working-directory: client
|
||||
- name: Check component style imports
|
||||
run: pnpm run check-style-imports
|
||||
working-directory: client
|
||||
- name: Run prettier checks
|
||||
run: pnpm run format-check
|
||||
working-directory: client
|
||||
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
update-milestone:
|
||||
if: github.repository_owner == 'galaxyproject' && github.event.pull_request.merged == true
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
@@ -48,21 +49,40 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort the milestones by number in descending order
|
||||
const sortedMilestones = milestones.sort((a, b) => b.number - a.number);
|
||||
const parseVersion = (s) => {
|
||||
const match = /^(\d+)\.(\d+)$/.exec(s);
|
||||
return match ? [Number(match[1]), Number(match[2])] : null;
|
||||
};
|
||||
const compareVersions = (a, b) => a[0] - b[0] || a[1] - b[1];
|
||||
// Open release milestones, oldest version first
|
||||
const versionedMilestones = milestones
|
||||
.map((milestone) => ({ milestone, version: parseVersion(milestone.title) }))
|
||||
.filter(({ version }) => version)
|
||||
.sort((a, b) => compareVersions(a.version, b.version));
|
||||
// Every merged pull request should be assigned to the milestone
|
||||
// corresponding to the Galaxy release it will first appear in, see:
|
||||
// https://github.com/galaxyproject/galaxy/blob/dev/doc/source/project/issues.rst
|
||||
let targetMilestone = null;
|
||||
// If targeting 'dev', use the latest (highest number) open milestone
|
||||
// That is the oldest open milestone at or above the minimum version.
|
||||
let minVersion = null;
|
||||
if (targetBranch === 'dev') {
|
||||
targetMilestone = sortedMilestones[0];
|
||||
}
|
||||
// If targeting 'release_\d+.\d+' and there are multiple open
|
||||
// milestones, use the second latest open milestone
|
||||
else if (/^release_\d+\.\d+$/.test(targetBranch)) {
|
||||
targetMilestone = sortedMilestones[1] || sortedMilestones[0];
|
||||
// dev feeds the oldest release that has no release branch yet
|
||||
const { data: refs } = await github.rest.git.listMatchingRefs({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: 'heads/release_',
|
||||
});
|
||||
const branchVersions = refs
|
||||
.map((ref) => parseVersion(ref.ref.replace('refs/heads/release_', '')))
|
||||
.filter((version) => version)
|
||||
.sort(compareVersions);
|
||||
const newestBranch = branchVersions[branchVersions.length - 1];
|
||||
minVersion = newestBranch ? [newestBranch[0], newestBranch[1] + 1] : [0, 0];
|
||||
} else if (/^release_\d+\.\d+$/.test(targetBranch)) {
|
||||
minVersion = parseVersion(targetBranch.replace('release_', ''));
|
||||
}
|
||||
const targetMilestone = minVersion
|
||||
? versionedMilestones.find(({ version }) => compareVersions(version, minVersion) >= 0)?.milestone
|
||||
: null;
|
||||
|
||||
// Update the Pull Request with the selected milestone
|
||||
if (targetMilestone) {
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
MILESTONE_NUMBER: 34
|
||||
MILESTONE_NUMBER: 35
|
||||
steps:
|
||||
- name: Get latest pull request labels
|
||||
id: get_pr_labels
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
name: Selenium tests
|
||||
on:
|
||||
schedule:
|
||||
# Run at midnight UTC every Tuesday
|
||||
- cron: '0 0 * * 2'
|
||||
workflow_dispatch:
|
||||
env:
|
||||
GALAXY_CONFIG_GALAXY_URL_PREFIX: '/galaxypf'
|
||||
GALAXY_TEST_DBURI: 'postgresql+psycopg://postgres:postgres@localhost:5432/galaxy?client_encoding=utf8'
|
||||
GALAXY_TEST_RAISE_EXCEPTION_ON_HISTORYLESS_HDA: '1'
|
||||
GALAXY_TEST_SELENIUM_RETRIES: 1
|
||||
GALAXY_TEST_SKIP_FLAKEY_TESTS_ON_ERROR: 1
|
||||
YARN_INSTALL_OPTS: --frozen-lockfile
|
||||
GALAXY_CONFIG_SQLALCHEMY_WARN_20: '1'
|
||||
GALAXY_CONFIG_OVERRIDE_METADATA_STRATEGY: extended
|
||||
GALAXY_CONFIG_OVERRIDE_OUTPUTS_TO_WORKING_DIRECTORY: 'true'
|
||||
permissions: {}
|
||||
jobs:
|
||||
build-client:
|
||||
uses: $/.github/workflows/build_client.yaml
|
||||
test:
|
||||
name: Test
|
||||
needs: [build-client]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.10']
|
||||
chunk: [0, 1, 2]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: 'galaxy root'
|
||||
persist-credentials: false
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v10.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Python
|
||||
run: uv python install
|
||||
- name: Restore client cache
|
||||
uses: actions/cache@v6.1.0
|
||||
with:
|
||||
fail-on-cache-miss: true
|
||||
key: galaxy-static-${{ needs.build-client.outputs.commit-id }}
|
||||
path: 'galaxy root/static'
|
||||
- name: Run tests
|
||||
run: ./run_tests.sh --coverage -selenium lib/galaxy_test/selenium -- --num-shards=3 --shard-id=${{ matrix.chunk }}
|
||||
working-directory: 'galaxy root'
|
||||
- uses: codecov/codecov-action@v7.1.1
|
||||
with:
|
||||
flags: selenium
|
||||
working-directory: 'galaxy root'
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: Selenium test results (${{ matrix.python-version }}, ${{ matrix.chunk }})
|
||||
path: 'galaxy root/run_selenium_tests.html'
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: Selenium debug info (${{ matrix.python-version }}, ${{ matrix.chunk }})
|
||||
path: 'galaxy root/database/test_errors'
|
||||
report:
|
||||
name: Report scheduled result
|
||||
needs: [build-client, test]
|
||||
# Manual runs may target feature branches and forks shouldn't open issues upstream.
|
||||
if: >-
|
||||
!cancelled()
|
||||
&& github.repository_owner == 'galaxyproject'
|
||||
&& github.ref == 'refs/heads/dev'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Open, update, or close the tracking issue
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
BUILD_RESULT: ${{ needs.build-client.result }}
|
||||
TEST_RESULT: ${{ needs.test.result }}
|
||||
with:
|
||||
script: |
|
||||
const title = "Scheduled Selenium tests are failing";
|
||||
const labels = ["area/testing/selenium", "kind/bug"];
|
||||
const repo = { owner: context.repo.owner, repo: context.repo.repo };
|
||||
const runUrl = `${context.serverUrl}/${repo.owner}/${repo.repo}/actions/runs/${context.runId}`;
|
||||
const failed = process.env.BUILD_RESULT !== "success" || process.env.TEST_RESULT !== "success";
|
||||
|
||||
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
...repo,
|
||||
state: "open",
|
||||
labels: labels[0],
|
||||
creator: "github-actions[bot]",
|
||||
per_page: 100,
|
||||
});
|
||||
// Concurrent failing runs can each open an issue; a passing run closes all of them.
|
||||
const tracking = openIssues.filter((issue) => issue.title === title);
|
||||
|
||||
if (failed) {
|
||||
if (tracking.length) {
|
||||
await github.rest.issues.createComment({
|
||||
...repo,
|
||||
issue_number: tracking[0].number,
|
||||
body: `Still failing on ${context.sha}: ${runUrl} (attempt ${context.runAttempt})`,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.create({
|
||||
...repo,
|
||||
title,
|
||||
labels,
|
||||
body: [
|
||||
`The weekly run of \`lib/galaxy_test/selenium\` on the Selenium backend failed on ${context.sha}: ${runUrl}`,
|
||||
"",
|
||||
"Pull requests run these tests under Playwright only, so this run is the only CI coverage of the Selenium backend and of tests marked `@selenium_only`.",
|
||||
"",
|
||||
"Later failures are added as comments here; the next passing run closes this issue.",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const issue of tracking) {
|
||||
await github.rest.issues.createComment({
|
||||
...repo,
|
||||
issue_number: issue.number,
|
||||
body: `Passing again on ${context.sha}: ${runUrl}`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
...repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
state_reason: "completed",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -220,7 +220,10 @@ client-eslint: client-node-deps # Run client linting
|
||||
client-format-check: client-node-deps # Run client formatting check
|
||||
$(IN_VENV) cd client && pnpm run format-check
|
||||
|
||||
client-lint: client-eslint client-format-check ## ES lint and check format of client
|
||||
client-check-style-imports: client-node-deps # Check that components don't import base.scss or all of Bootstrap
|
||||
$(IN_VENV) cd client && pnpm run check-style-imports
|
||||
|
||||
client-lint: client-eslint client-check-style-imports client-format-check ## ES lint and check format of client
|
||||
|
||||
client-test-watch: client ## Watch and run all client unit tests on changes
|
||||
$(IN_VENV) cd client && pnpm run test:watch
|
||||
|
||||
+2
-3
@@ -104,7 +104,6 @@
|
||||
"diff": "^8.0.3",
|
||||
"dom-to-image": "^2.6.0",
|
||||
"dompurify": "^3.0.6",
|
||||
"echarts": "^5.5.1",
|
||||
"elkjs": "^0.11.1",
|
||||
"events": "^3.3.0",
|
||||
"file-saver": "^2.0.5",
|
||||
@@ -158,7 +157,6 @@
|
||||
"vscode-languageserver-types": "^3.17.5",
|
||||
"vue": "^3.5.43",
|
||||
"vue-demi": "^0.14.10",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-infinite-scroll": "^2.0.2",
|
||||
"vue-json-pretty": "^1.9.5",
|
||||
"vue-multiselect": "^3.3",
|
||||
@@ -187,7 +185,8 @@
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"eslint-precommit": "eslint --quiet src packages/ui/src",
|
||||
"eslint": "eslint src packages/ui/src",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"check-style-imports": "node scripts/check-style-imports.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
@@ -3754,6 +3754,30 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/jobs/{job_id}/files": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get a file required to stage a job.
|
||||
* @description Only for consumption by remote job runners (e.g. Pulsar) acting on behalf of a queued or running job, authorized by `job_key` - not part of Galaxy's stable, user facing API.
|
||||
*/
|
||||
get: operations["index_api_jobs__job_id__files_get"];
|
||||
put?: never;
|
||||
/**
|
||||
* Populate an output or working directory file of a job.
|
||||
* @description Only for consumption by remote job runners (e.g. Pulsar) acting on behalf of a queued or running job, authorized by `job_key` - not part of Galaxy's stable, user facing API.
|
||||
*/
|
||||
post: operations["create_api_jobs__job_id__files_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/jobs/{job_id}/inputs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -8858,22 +8882,109 @@ export interface components {
|
||||
*/
|
||||
type: "change_dbkey";
|
||||
};
|
||||
/**
|
||||
* ChatCompletionAllowedToolChoiceParam
|
||||
* @description Constrains the tools available to the model to a pre-defined set.
|
||||
*/
|
||||
ChatCompletionAllowedToolChoiceParam: {
|
||||
allowed_tools: components["schemas"]["ChatCompletionAllowedToolsParam"];
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "allowed_tools";
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* ChatCompletionAllowedToolsParam
|
||||
* @description Constrains the tools available to the model to a pre-defined set.
|
||||
*/
|
||||
ChatCompletionAllowedToolsParam: {
|
||||
/**
|
||||
* Mode
|
||||
* @enum {string}
|
||||
*/
|
||||
mode: "auto" | "required";
|
||||
/** Tools */
|
||||
tools: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* ChatCompletionNamedToolChoiceCustomParam
|
||||
* @description Specifies a tool the model should use.
|
||||
*
|
||||
* Use to force the model to call a specific custom tool.
|
||||
*/
|
||||
ChatCompletionNamedToolChoiceCustomParam: {
|
||||
custom: components["schemas"]["Custom"];
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "custom";
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* ChatCompletionNamedToolChoiceParam
|
||||
* @description Specifies a tool the model should use.
|
||||
*
|
||||
* Use to force the model to call a specific function.
|
||||
*/
|
||||
ChatCompletionNamedToolChoiceParam: {
|
||||
function: components["schemas"]["Function"];
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "function";
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** ChatCompletionRequest */
|
||||
ChatCompletionRequest: {
|
||||
/** Max Tokens */
|
||||
max_tokens?: number | null;
|
||||
/** Messages */
|
||||
messages: components["schemas"]["ChatMessage"][];
|
||||
/** Parallel Tool Calls */
|
||||
parallel_tool_calls?: boolean | null;
|
||||
/** Reasoning Effort */
|
||||
reasoning_effort?: ("none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max") | null;
|
||||
/**
|
||||
* Stream
|
||||
* @default false
|
||||
*/
|
||||
stream: boolean | null;
|
||||
stream_options?: components["schemas"]["ChatCompletionStreamOptionsParam"] | null;
|
||||
/** Tool Choice */
|
||||
tool_choice?:
|
||||
| ("none" | "auto" | "required")
|
||||
| components["schemas"]["ChatCompletionAllowedToolChoiceParam"]
|
||||
| components["schemas"]["ChatCompletionNamedToolChoiceParam"]
|
||||
| components["schemas"]["ChatCompletionNamedToolChoiceCustomParam"]
|
||||
| null;
|
||||
/** Tools */
|
||||
tools?: components["schemas"]["ChatTool"][] | null;
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* ChatCompletionStreamOptionsParam
|
||||
* @description Options for streaming response. Only set this when you set `stream: true`.
|
||||
*/
|
||||
ChatCompletionStreamOptionsParam: {
|
||||
/** Include Obfuscation */
|
||||
include_obfuscation?: boolean;
|
||||
/** Include Usage */
|
||||
include_usage?: boolean;
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** ChatEntityContext */
|
||||
ChatEntityContext: {
|
||||
/** Datasets */
|
||||
@@ -8936,7 +9047,7 @@ export interface components {
|
||||
/** ChatMessage */
|
||||
ChatMessage: {
|
||||
/** Content */
|
||||
content?: string | null;
|
||||
content?: string | components["schemas"]["ChatTextPart"][] | null;
|
||||
/**
|
||||
* Role
|
||||
* @enum {string}
|
||||
@@ -9018,6 +9129,18 @@ export interface components {
|
||||
*/
|
||||
response: string;
|
||||
};
|
||||
/** ChatTextPart */
|
||||
ChatTextPart: {
|
||||
/** Text */
|
||||
text: string;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "text";
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** ChatTool */
|
||||
ChatTool: {
|
||||
function: components["schemas"]["ChatToolFunction"];
|
||||
@@ -10599,6 +10722,13 @@ export interface components {
|
||||
*/
|
||||
workflows?: components["schemas"]["CuratedWorkflow"][];
|
||||
};
|
||||
/** Custom */
|
||||
Custom: {
|
||||
/** Name */
|
||||
name: string;
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* CustomArchivedHistoryView
|
||||
* @description Archived History Response with all optional fields.
|
||||
@@ -13685,6 +13815,7 @@ export interface components {
|
||||
| "github"
|
||||
| "iiif"
|
||||
| "ipfs"
|
||||
| "xrootd"
|
||||
| "mavedb"
|
||||
| "omero"
|
||||
| "ssh"
|
||||
@@ -14082,6 +14213,13 @@ export interface components {
|
||||
/** Tags */
|
||||
tags?: string[] | null;
|
||||
};
|
||||
/** Function */
|
||||
Function: {
|
||||
/** Name */
|
||||
name: string;
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** GenerateTourResponse */
|
||||
GenerateTourResponse: {
|
||||
/**
|
||||
@@ -27072,6 +27210,7 @@ export interface components {
|
||||
| "github"
|
||||
| "iiif"
|
||||
| "ipfs"
|
||||
| "xrootd"
|
||||
| "mavedb"
|
||||
| "omero"
|
||||
| "ssh"
|
||||
@@ -45266,6 +45405,131 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
index_api_jobs__job_id__files_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Path to file. */
|
||||
path?: string | null;
|
||||
/** @description A key used to authenticate this request as acting on behalf of a job runner for the job. */
|
||||
job_key?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Encoded id string of the job. */
|
||||
job_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Request Error */
|
||||
"4XX": {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageExceptionModel"];
|
||||
};
|
||||
};
|
||||
/** @description Server Error */
|
||||
"5XX": {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageExceptionModel"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_api_jobs__job_id__files_post: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Path to file. */
|
||||
path?: string | null;
|
||||
/** @description A key used to authenticate this request as acting on behalf of a job runner for the job. */
|
||||
job_key?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Encoded id string of the job. */
|
||||
job_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"application/x-www-form-urlencoded": {
|
||||
/** @description File stored by nginx_upload_module to use as contents. */
|
||||
__file_path?: string;
|
||||
/** @description Job key, if not given as a query parameter. */
|
||||
job_key?: string;
|
||||
/** @description Path to file to create, if not given as a query parameter. */
|
||||
path?: string;
|
||||
/** @description Completed job files TUS upload to use as contents. */
|
||||
session_id?: string;
|
||||
};
|
||||
"multipart/form-data": {
|
||||
/**
|
||||
* Format: binary
|
||||
* @description Alias of `file`.
|
||||
*/
|
||||
__file?: string;
|
||||
/** @description File stored by nginx_upload_module to use as contents. */
|
||||
__file_path?: string;
|
||||
/**
|
||||
* Format: binary
|
||||
* @description Contents of the file to create.
|
||||
*/
|
||||
file?: string;
|
||||
/** @description Job key, if not given as a query parameter. */
|
||||
job_key?: string;
|
||||
/** @description Path to file to create, if not given as a query parameter. */
|
||||
path?: string;
|
||||
/** @description Completed job files TUS upload to use as contents. */
|
||||
session_id?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Request Error */
|
||||
"4XX": {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageExceptionModel"];
|
||||
};
|
||||
};
|
||||
/** @description Server Error */
|
||||
"5XX": {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageExceptionModel"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_inputs_api_jobs__job_id__inputs_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defineComponent, h, ref } from "vue";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defineComponent, h, ref, withDirectives } from "vue";
|
||||
|
||||
import { vGTooltip } from "../directives/vGTooltip";
|
||||
|
||||
import GCheckbox from "./GCheckbox.vue";
|
||||
import GCheckboxGroup from "./GCheckboxGroup.vue";
|
||||
|
||||
function mountWithModel(initial: boolean) {
|
||||
function mountWithModel(initial: boolean, attrs = ref<Record<string, unknown>>({})) {
|
||||
const checked = ref(initial);
|
||||
const Parent = defineComponent({
|
||||
setup() {
|
||||
@@ -13,6 +16,7 @@ function mountWithModel(initial: boolean) {
|
||||
id: "test-checkbox",
|
||||
modelValue: checked.value,
|
||||
"onUpdate:modelValue": (value: boolean) => (checked.value = value),
|
||||
...attrs.value,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -26,6 +30,7 @@ describe("GCheckbox", () => {
|
||||
|
||||
expect(checkbox.props("modelValue")).toBe(true);
|
||||
expect(checkbox.attributes("value")).toBeUndefined();
|
||||
expect(wrapper.find("input").attributes("value")).toBeUndefined();
|
||||
expect((wrapper.find("input").element as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
@@ -59,10 +64,188 @@ describe("GCheckbox", () => {
|
||||
expect(input.indeterminate).toBe(false);
|
||||
});
|
||||
|
||||
it("renders as a switch in toggle mode", () => {
|
||||
const wrapper = mount(GCheckbox, { props: { modelValue: false, toggle: true } });
|
||||
it("keeps class, style and title on the label and puts other attributes on the input", () => {
|
||||
const wrapper = mount(GCheckbox, {
|
||||
props: { modelValue: false },
|
||||
attrs: {
|
||||
class: "extra-class",
|
||||
style: "margin-top: 4px;",
|
||||
"data-test-id": "my-checkbox",
|
||||
title: "Select row",
|
||||
"aria-label": "Select row",
|
||||
},
|
||||
});
|
||||
const label = wrapper.find("label");
|
||||
const input = wrapper.find("input");
|
||||
|
||||
expect(label.classes()).toContain("extra-class");
|
||||
expect(label.attributes("style")).toContain("margin-top: 4px");
|
||||
expect(label.attributes("data-test-id")).toBeUndefined();
|
||||
expect(label.attributes("title")).toBe("Select row");
|
||||
expect(input.attributes("data-test-id")).toBe("my-checkbox");
|
||||
expect(input.attributes("title")).toBeUndefined();
|
||||
expect(input.attributes("aria-label")).toBe("Select row");
|
||||
expect(input.classes()).not.toContain("extra-class");
|
||||
});
|
||||
|
||||
it("prefers a caller data-test-id over the one derived from id", () => {
|
||||
const wrapper = mount(GCheckbox, {
|
||||
props: { id: "box", modelValue: false },
|
||||
attrs: { "data-test-id": "custom" },
|
||||
});
|
||||
|
||||
expect(wrapper.find("label").attributes("data-test-id")).toBe("box-label");
|
||||
expect(wrapper.find("input").attributes("data-test-id")).toBe("custom");
|
||||
});
|
||||
|
||||
it("updates input attributes when the parent changes them", async () => {
|
||||
const attrs = ref<Record<string, unknown>>({ "data-description": "First" });
|
||||
const { wrapper } = mountWithModel(false, attrs);
|
||||
expect(wrapper.find("input").attributes("data-description")).toBe("First");
|
||||
|
||||
attrs.value = { "data-description": "Second" };
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("input").attributes("data-description")).toBe("Second");
|
||||
});
|
||||
|
||||
it("picks up attributes added after mounting without any", async () => {
|
||||
const attrs = ref<Record<string, unknown>>({});
|
||||
const { wrapper } = mountWithModel(false, attrs);
|
||||
|
||||
attrs.value = { class: "late-class", "data-description": "Late" };
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find("label").classes()).toContain("late-class");
|
||||
expect(wrapper.find("input").attributes("data-description")).toBe("Late");
|
||||
});
|
||||
|
||||
it("gives v-g-tooltip on the component its title", () => {
|
||||
const wrapper = mount(() =>
|
||||
withDirectives(h(GCheckbox, { modelValue: false, title: "Select all" }), [
|
||||
[vGTooltip, undefined, "", { hover: true }],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(wrapper.get("label").element.dataset.gTooltipTitle).toBe("Select all");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("binds native listeners to the label so click modifiers cover the whole control", async () => {
|
||||
const targets: Array<EventTarget | null> = [];
|
||||
const onClick = vi.fn((event: Event) => targets.push(event.currentTarget));
|
||||
const wrapper = mount(GCheckbox, {
|
||||
props: { modelValue: false },
|
||||
attrs: { onClick },
|
||||
slots: { default: "Label text" },
|
||||
});
|
||||
|
||||
await wrapper.find(".g-checkbox-label").trigger("click");
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
expect(targets.every((target) => target === wrapper.find("label").element)).toBe(true);
|
||||
});
|
||||
|
||||
it("renders as a switch in switch mode", () => {
|
||||
const wrapper = mount(GCheckbox, { props: { modelValue: false, switch: true } });
|
||||
|
||||
expect(wrapper.classes()).toContain("g-switch");
|
||||
expect(wrapper.find(".g-switch-slider").exists()).toBe(true);
|
||||
expect(wrapper.find("input").attributes("role")).toBe("switch");
|
||||
});
|
||||
|
||||
it("keeps the native checkbox role outside switch mode", () => {
|
||||
const wrapper = mount(GCheckbox, { props: { modelValue: false } });
|
||||
|
||||
expect(wrapper.classes()).not.toContain("g-switch");
|
||||
expect(wrapper.find("input").attributes("role")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders name, value and required on the input", () => {
|
||||
const wrapper = mount(GCheckbox, {
|
||||
props: { modelValue: false, name: "include", required: true, value: "hidden" },
|
||||
});
|
||||
const input = wrapper.find("input");
|
||||
|
||||
expect(input.attributes("name")).toBe("include");
|
||||
expect(input.attributes("value")).toBe("hidden");
|
||||
expect(input.attributes("required")).toBeDefined();
|
||||
expect(wrapper.find("label").attributes("name")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders non-string values as their string form", () => {
|
||||
const wrapper = mount(GCheckbox, { props: { modelValue: false, value: 0 } });
|
||||
|
||||
expect(wrapper.find("input").attributes("value")).toBe("0");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["small", "g-small"],
|
||||
["large", "g-large"],
|
||||
] as const)("applies the %s size class", (size, className) => {
|
||||
const wrapper = mount(GCheckbox, { props: { modelValue: false, size } });
|
||||
|
||||
expect(wrapper.find("label").classes()).toContain(className);
|
||||
});
|
||||
|
||||
it("adds no size class by default", () => {
|
||||
const wrapper = mount(GCheckbox, { props: { modelValue: false } });
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find("label")
|
||||
.classes()
|
||||
.filter((name) => /^g-(small|medium|large)$/.test(name)),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
describe("inside a group", () => {
|
||||
function mountInGroup(childProps: Record<string, unknown>, groupProps: Record<string, unknown> = {}) {
|
||||
const wrapper = mount(GCheckboxGroup, {
|
||||
props: { modelValue: [], ...groupProps },
|
||||
slots: { default: () => h(GCheckbox, childProps, () => "Child") },
|
||||
});
|
||||
return { checkbox: wrapper.findComponent(GCheckbox), wrapper };
|
||||
}
|
||||
|
||||
it("leaves the v-model to the group and still emits change", async () => {
|
||||
const { checkbox, wrapper } = mountInGroup({ value: "b" });
|
||||
|
||||
await wrapper.find("input").setValue(true);
|
||||
expect(checkbox.emitted("update:modelValue")).toBeUndefined();
|
||||
expect(checkbox.emitted("change")).toHaveLength(1);
|
||||
expect(wrapper.emitted("update:modelValue")).toEqual([[["b"]]]);
|
||||
});
|
||||
|
||||
it("lets its own name and size win over the group's", () => {
|
||||
const { wrapper } = mountInGroup(
|
||||
{ name: "own", size: "large", value: "b" },
|
||||
{ name: "shared", size: "small" },
|
||||
);
|
||||
|
||||
expect(wrapper.find("input").attributes("name")).toBe("own");
|
||||
expect(wrapper.find("label").classes()).toContain("g-large");
|
||||
expect(wrapper.find("label").classes()).not.toContain("g-small");
|
||||
});
|
||||
|
||||
it("always follows the group's disabled and switch mode", () => {
|
||||
const { wrapper } = mountInGroup(
|
||||
{ disabled: false, switch: false, value: "b" },
|
||||
{ disabled: true, switches: true },
|
||||
);
|
||||
const input = wrapper.find("input");
|
||||
|
||||
expect(input.attributes("disabled")).toBeDefined();
|
||||
expect(input.attributes("role")).toBe("switch");
|
||||
expect(wrapper.find("label").classes()).toEqual(expect.arrayContaining(["g-disabled", "g-switch"]));
|
||||
});
|
||||
|
||||
it("ignores its own switch prop but keeps its own disabled", () => {
|
||||
const { wrapper } = mountInGroup(
|
||||
{ disabled: true, switch: true, value: "b" },
|
||||
{ disabled: false, switches: false },
|
||||
);
|
||||
const input = wrapper.find("input");
|
||||
|
||||
expect(input.attributes("disabled")).toBeDefined();
|
||||
expect(input.attributes("role")).toBeUndefined();
|
||||
expect(wrapper.find("label").classes()).not.toContain("g-switch");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,27 @@
|
||||
/**
|
||||
* A checkbox/switch component with optional label.
|
||||
* Supports v-model for two-way binding.
|
||||
*
|
||||
* `class`, `style`, `title` and native listeners (`@click.stop`, ...) land on the root label, so
|
||||
* non-bubbling `@focus`/`@blur` never fire there (use `@focusin`/`@focusout`). Every other
|
||||
* attribute (`data-test-id`, `aria-*`) lands on the input, so give an unlabeled
|
||||
* checkbox an `aria-label`.
|
||||
*
|
||||
* Inside a GCheckboxGroup the group owns the state: the checkbox is checked when its `value` is in
|
||||
* the group's v-model and emits `change` but not `update:modelValue`. It takes the group's name and
|
||||
* size unless it sets its own; the group's disabled always applies and its `switches` alone sets
|
||||
* switch mode, as in BootstrapVue.
|
||||
*/
|
||||
|
||||
import { computed } from "vue";
|
||||
import { computed, inject, useAttrs } from "vue";
|
||||
|
||||
import { checkboxGroupKey } from "./checkboxGroupContext";
|
||||
import { type ComponentSize, prefix } from "./componentVariants";
|
||||
|
||||
defineOptions({
|
||||
// Under @vue/compat, keep v-model on modelValue instead of Vue 2's value/input
|
||||
compatConfig: { COMPONENT_V_MODEL: false },
|
||||
inheritAttrs: false,
|
||||
// Under @vue/compat: keep v-model on modelValue, and keep class/style/listeners in $attrs so they can be split below
|
||||
compatConfig: { COMPONENT_V_MODEL: false, INSTANCE_ATTRS_CLASS_STYLE: false, INSTANCE_LISTENERS: false },
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -20,10 +34,18 @@ const props = defineProps<{
|
||||
ariaLabel?: string;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
/** Render as a toggle switch instead of a checkbox */
|
||||
toggle?: boolean;
|
||||
/** Partly-checked state, e.g. a select-all box when only some rows are selected */
|
||||
indeterminate?: boolean;
|
||||
/** Name attribute for the checkbox input */
|
||||
name?: string;
|
||||
/** Required attribute for the checkbox input */
|
||||
required?: boolean;
|
||||
/** Displayed size; unset keeps the surrounding font size */
|
||||
size?: ComponentSize;
|
||||
/** Render as a toggle switch instead of a checkbox; inside a group, the group's `switches` decides */
|
||||
switch?: boolean;
|
||||
/** Value attribute for the checkbox input, rendered as a string whenever it is set */
|
||||
value?: unknown;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -31,38 +53,74 @@ const emit = defineEmits<{
|
||||
(e: "change", event: Event): void;
|
||||
}>();
|
||||
|
||||
const currentValue = computed({
|
||||
get() {
|
||||
return props.modelValue ?? false;
|
||||
},
|
||||
set(newValue: boolean) {
|
||||
emit("update:modelValue", newValue);
|
||||
},
|
||||
const attrs = useAttrs();
|
||||
|
||||
const group = inject(checkboxGroupKey, null);
|
||||
|
||||
// Plain functions, not computeds: $attrs is not reactive, so the render function must read it each time
|
||||
function rootAttrs() {
|
||||
return {
|
||||
"data-test-id": props.id ? `${props.id}-label` : undefined,
|
||||
...Object.fromEntries(Object.entries(attrs).filter(([key]) => isRootAttr(key))),
|
||||
};
|
||||
}
|
||||
|
||||
// Caller attributes win over the defaults derived from props
|
||||
function inputAttrs() {
|
||||
return {
|
||||
"data-test-id": props.id ? `${props.id}-input` : undefined,
|
||||
...Object.fromEntries(Object.entries(attrs).filter(([key]) => !isRootAttr(key))),
|
||||
};
|
||||
}
|
||||
|
||||
const isChecked = computed(() => (group ? group.isChecked(props.value) : (props.modelValue ?? false)));
|
||||
|
||||
const isDisabled = computed(() => props.disabled || Boolean(group?.disabled.value));
|
||||
|
||||
const isSwitch = computed(() => (group ? group.switches.value : props.switch));
|
||||
|
||||
const inputName = computed(() => props.name ?? group?.name.value);
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const size = props.size ?? group?.size.value;
|
||||
return size ? prefix(size) : undefined;
|
||||
});
|
||||
|
||||
// title stays on the label so v-g-tooltip, which binds to the root, can read and suppress it
|
||||
function isRootAttr(key: string) {
|
||||
return key === "class" || key === "style" || key === "title" || /^on[A-Z]/.test(key);
|
||||
}
|
||||
|
||||
function onChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
currentValue.value = target.checked;
|
||||
if (group) {
|
||||
group.toggle(props.value, target.checked);
|
||||
} else {
|
||||
emit("update:modelValue", target.checked);
|
||||
}
|
||||
emit("change", event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
v-bind="rootAttrs()"
|
||||
class="g-checkbox"
|
||||
:class="{ 'g-disabled': disabled, 'g-switch': toggle }"
|
||||
:data-test-id="id ? `${id}-label` : undefined">
|
||||
:class="[sizeClass, { 'g-disabled': isDisabled, 'g-switch': isSwitch }]">
|
||||
<input
|
||||
v-bind="inputAttrs()"
|
||||
:id="id"
|
||||
:aria-label="ariaLabel"
|
||||
type="checkbox"
|
||||
class="g-checkbox-input"
|
||||
:data-test-id="id ? `${id}-input` : undefined"
|
||||
:checked="currentValue"
|
||||
:checked="isChecked"
|
||||
:indeterminate="indeterminate"
|
||||
:disabled="disabled"
|
||||
:disabled="isDisabled"
|
||||
:name="inputName"
|
||||
:required="required"
|
||||
:role="isSwitch ? 'switch' : undefined"
|
||||
:value="value"
|
||||
@change="onChange" />
|
||||
<span v-if="toggle" class="g-switch-slider" />
|
||||
<span v-if="$slots.default" class="g-checkbox-label">
|
||||
<slot></slot>
|
||||
</span>
|
||||
@@ -91,13 +149,9 @@ function onChange(event: Event) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.g-switch & {
|
||||
// Visually hide the checkbox in switch mode, keeping it accessible
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-blue-500, #197cd2);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,46 +159,74 @@ function onChange(event: Event) {
|
||||
margin-left: var(--spacing-2, 0.5rem);
|
||||
}
|
||||
|
||||
// Switch/toggle styles
|
||||
.g-switch {
|
||||
position: relative;
|
||||
.g-small {
|
||||
font-size: var(--font-size-small, 0.75rem);
|
||||
|
||||
.g-switch-slider {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 1.125rem;
|
||||
background-color: var(--color-grey-400, #adb5bd);
|
||||
border-radius: 1rem;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0.125rem;
|
||||
left: 0.125rem;
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.g-checkbox-input:checked + .g-switch-slider {
|
||||
background-color: var(--color-blue-500, #007bff);
|
||||
|
||||
&::before {
|
||||
transform: translateX(0.875rem);
|
||||
}
|
||||
}
|
||||
|
||||
.g-checkbox-input:focus + .g-switch-slider {
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
&.g-disabled .g-switch-slider {
|
||||
opacity: 0.5;
|
||||
&:not(.g-switch) .g-checkbox-input {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.g-large {
|
||||
font-size: var(--font-size-large, 1rem);
|
||||
|
||||
&:not(.g-switch) .g-checkbox-input {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Switch: the input itself is the track, so it stays visible, clickable and focusable
|
||||
.g-switch .g-checkbox-input {
|
||||
--g-switch-width: 2rem;
|
||||
--g-switch-height: 1.125rem;
|
||||
--g-switch-knob-color: var(--color-grey-500, #63656d);
|
||||
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
width: var(--g-switch-width);
|
||||
height: var(--g-switch-height);
|
||||
// Off-state border keeps >= 3:1 against the page (WCAG 1.4.11) and survives forced-colors mode
|
||||
border: 1px solid var(--color-grey-500, #63656d);
|
||||
border-radius: calc(var(--g-switch-height) / 2);
|
||||
background-color: var(--background-color, #ffffff);
|
||||
background-image: radial-gradient(circle closest-side, var(--g-switch-knob-color) 75%, transparent 80%);
|
||||
background-position: left center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: calc(var(--g-switch-height) - 2px) calc(var(--g-switch-height) - 2px);
|
||||
transition:
|
||||
background-position 0.15s ease-in-out,
|
||||
background-color 0.15s ease-in-out;
|
||||
|
||||
&:checked {
|
||||
--g-switch-knob-color: #ffffff;
|
||||
|
||||
border-color: var(--color-blue-500, #197cd2);
|
||||
background-color: var(--color-blue-500, #197cd2);
|
||||
background-position: right center;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
// Forced colors drop the knob gradient, which would hide the on/off state; fall back to the native box
|
||||
@media (forced-colors: active) {
|
||||
appearance: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.g-switch.g-small .g-checkbox-input {
|
||||
--g-switch-width: 1.5rem;
|
||||
--g-switch-height: 0.875rem;
|
||||
}
|
||||
|
||||
.g-switch.g-large .g-checkbox-input {
|
||||
--g-switch-width: 2.5rem;
|
||||
--g-switch-height: 1.375rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defineComponent, h, ref } from "vue";
|
||||
|
||||
import type { CheckboxGroupOption } from "./checkboxGroupContext";
|
||||
|
||||
import GCheckbox from "./GCheckbox.vue";
|
||||
import GCheckboxGroup from "./GCheckboxGroup.vue";
|
||||
|
||||
const OPTIONS: CheckboxGroupOption[] = [
|
||||
{ text: "Alpha", value: "a" },
|
||||
{ text: "Bravo", value: "b" },
|
||||
{ text: "Charlie", value: "c" },
|
||||
];
|
||||
|
||||
function mountWithModel(initial: unknown[], groupProps: Record<string, unknown> = {}, slot?: () => unknown) {
|
||||
const selected = ref<unknown[]>(initial);
|
||||
const Parent = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
GCheckboxGroup,
|
||||
{
|
||||
modelValue: selected.value,
|
||||
"onUpdate:modelValue": (value: unknown[]) => (selected.value = value),
|
||||
...groupProps,
|
||||
},
|
||||
slot ? { default: slot } : undefined,
|
||||
);
|
||||
},
|
||||
});
|
||||
return { selected, wrapper: mount(Parent) };
|
||||
}
|
||||
|
||||
function inputs(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.findAll("input");
|
||||
}
|
||||
|
||||
describe("GCheckboxGroup", () => {
|
||||
it("checks the options whose values are in the v-model", () => {
|
||||
const { wrapper } = mountWithModel(["b"], { options: OPTIONS });
|
||||
const checked = inputs(wrapper).map((input) => (input.element as HTMLInputElement).checked);
|
||||
|
||||
expect(wrapper.findComponent(GCheckboxGroup).props()).toMatchObject({ modelValue: ["b"] });
|
||||
expect(checked).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it("appends values in click order and removes unchecked ones", async () => {
|
||||
const { selected, wrapper } = mountWithModel([], { options: OPTIONS });
|
||||
const [alpha, bravo, charlie] = inputs(wrapper);
|
||||
|
||||
await alpha!.setValue(true);
|
||||
await charlie!.setValue(true);
|
||||
await bravo!.setValue(true);
|
||||
expect(selected.value).toEqual(["a", "c", "b"]);
|
||||
|
||||
await charlie!.setValue(false);
|
||||
expect(selected.value).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("emits a new array instead of mutating the bound one", async () => {
|
||||
const initial = ["a"];
|
||||
const { wrapper } = mountWithModel(initial, { options: OPTIONS });
|
||||
|
||||
await inputs(wrapper)[1]!.setValue(true);
|
||||
const emitted = wrapper.findComponent(GCheckboxGroup).emitted("update:modelValue")!;
|
||||
expect(emitted[0]![0]).toEqual(["a", "b"]);
|
||||
expect(emitted[0]![0]).not.toBe(initial);
|
||||
expect(initial).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("matches values loosely, like BFormCheckboxGroup", () => {
|
||||
const { wrapper } = mountWithModel(["2"], {
|
||||
options: [
|
||||
{ text: "One", value: 1 },
|
||||
{ text: "Two", value: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(inputs(wrapper).map((input) => (input.element as HTMLInputElement).checked)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it("works with GCheckbox children in the default slot", async () => {
|
||||
const { selected, wrapper } = mountWithModel(["x"], {}, () => [
|
||||
h(GCheckbox, { value: "x" }, () => "X"),
|
||||
h(GCheckbox, { value: "y" }, () => "Y"),
|
||||
]);
|
||||
const [x, y] = inputs(wrapper);
|
||||
|
||||
expect((x!.element as HTMLInputElement).checked).toBe(true);
|
||||
await y!.setValue(true);
|
||||
await x!.setValue(false);
|
||||
expect(selected.value).toEqual(["y"]);
|
||||
});
|
||||
|
||||
it("renders each option's value and a shared generated name on the inputs", () => {
|
||||
const { wrapper } = mountWithModel([], { options: OPTIONS });
|
||||
const names = inputs(wrapper).map((input) => input.attributes("name"));
|
||||
|
||||
expect(inputs(wrapper).map((input) => input.attributes("value"))).toEqual(["a", "b", "c"]);
|
||||
expect(names[0]).toMatch(/^g-checkbox-group-\d+$/);
|
||||
expect(new Set(names).size).toBe(1);
|
||||
});
|
||||
|
||||
it("gives separate groups separate names and honors an explicit name", () => {
|
||||
const first = mountWithModel([], { options: OPTIONS }).wrapper;
|
||||
const second = mountWithModel([], { options: OPTIONS }).wrapper;
|
||||
const named = mountWithModel([], { name: "datatypes", options: OPTIONS }).wrapper;
|
||||
|
||||
expect(inputs(first)[0]!.attributes("name")).not.toBe(inputs(second)[0]!.attributes("name"));
|
||||
expect(inputs(named).map((input) => input.attributes("name"))).toEqual(["datatypes", "datatypes", "datatypes"]);
|
||||
});
|
||||
|
||||
it("disables single options or the whole group", () => {
|
||||
const single = mountWithModel([], {
|
||||
options: [
|
||||
{ text: "On", value: "on" },
|
||||
{ text: "Off", value: "off", disabled: true },
|
||||
],
|
||||
}).wrapper;
|
||||
expect(inputs(single).map((input) => input.attributes("disabled") !== undefined)).toEqual([false, true]);
|
||||
|
||||
const whole = mountWithModel([], { disabled: true, options: OPTIONS }).wrapper;
|
||||
expect(whole.find("fieldset").attributes("disabled")).toBeDefined();
|
||||
expect(inputs(whole).every((input) => input.attributes("disabled") !== undefined)).toBe(true);
|
||||
expect(whole.findAll("label").every((label) => label.classes().includes("g-disabled"))).toBe(true);
|
||||
});
|
||||
|
||||
it("passes switch mode and size to its checkboxes", () => {
|
||||
const { wrapper } = mountWithModel([], { options: OPTIONS, size: "small", switches: true });
|
||||
|
||||
expect(inputs(wrapper).every((input) => input.attributes("role") === "switch")).toBe(true);
|
||||
expect(wrapper.findAll("label").every((label) => label.classes().includes("g-small"))).toBe(true);
|
||||
});
|
||||
|
||||
it("lays options out inline unless stacked", () => {
|
||||
const inline = mountWithModel([], { options: OPTIONS }).wrapper;
|
||||
const stacked = mountWithModel([], { options: OPTIONS, stacked: true }).wrapper;
|
||||
|
||||
expect(inline.find(".g-checkbox-group-options").classes()).not.toContain("g-stacked");
|
||||
expect(stacked.find(".g-checkbox-group-options").classes()).toContain("g-stacked");
|
||||
});
|
||||
|
||||
describe("accessible name", () => {
|
||||
it("uses a legend for the label prop", () => {
|
||||
const { wrapper } = mountWithModel([], { label: "Options", options: OPTIONS });
|
||||
|
||||
expect(wrapper.element.tagName).toBe("FIELDSET");
|
||||
expect(wrapper.find("fieldset > legend").text()).toBe("Options");
|
||||
});
|
||||
|
||||
it("uses a legend for the label slot", () => {
|
||||
const wrapper = mount(GCheckboxGroup, { props: { options: OPTIONS }, slots: { label: "Slotted label" } });
|
||||
|
||||
expect(wrapper.find("fieldset > legend").text()).toBe("Slotted label");
|
||||
});
|
||||
|
||||
it("renders no legend without a label and passes aria attributes to the fieldset", () => {
|
||||
const { wrapper } = mountWithModel([], { "aria-labelledby": "outside-title", options: OPTIONS });
|
||||
|
||||
expect(wrapper.find("legend").exists()).toBe(false);
|
||||
expect(wrapper.find("fieldset").attributes("aria-labelledby")).toBe("outside-title");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
/**
|
||||
* A group of checkboxes bound to one array with v-model.
|
||||
*
|
||||
* Renders `options`, then any GCheckbox children from the default slot. Checking appends the
|
||||
* value in click order, unchecking removes it. The root is a `<fieldset>`: give it a `label`
|
||||
* (or a `label` slot) for a visible legend, or pass `aria-label` / `aria-labelledby` when the
|
||||
* label lives outside the group.
|
||||
*/
|
||||
|
||||
import { computed, provide } from "vue";
|
||||
|
||||
import { useUid } from "../composables/uid";
|
||||
import { looseEqual } from "../utils/looseEqual";
|
||||
import { checkboxGroupKey, type CheckboxGroupOption } from "./checkboxGroupContext";
|
||||
import type { ComponentSize } from "./componentVariants";
|
||||
|
||||
import GCheckbox from "./GCheckbox.vue";
|
||||
|
||||
defineOptions({
|
||||
// Under @vue/compat, keep v-model on modelValue instead of Vue 2's value/input
|
||||
compatConfig: { COMPONENT_V_MODEL: false },
|
||||
});
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Disables every checkbox in the group
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Visible legend for the group
|
||||
* @default undefined
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* The v-model value: values of the checked boxes, in the order they were checked
|
||||
* @default []
|
||||
*/
|
||||
modelValue?: T[];
|
||||
/**
|
||||
* Name shared by every checkbox input
|
||||
* @default an auto-generated unique name
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* Checkboxes to render before the default slot
|
||||
* @default []
|
||||
*/
|
||||
options?: CheckboxGroupOption<T>[];
|
||||
/**
|
||||
* Size of every checkbox in the group
|
||||
* @default undefined (surrounding font size)
|
||||
*/
|
||||
size?: ComponentSize;
|
||||
/**
|
||||
* Stack the checkboxes vertically instead of in a wrapping row
|
||||
* @default false
|
||||
*/
|
||||
stacked?: boolean;
|
||||
/**
|
||||
* Render every checkbox as a toggle switch
|
||||
* @default false
|
||||
*/
|
||||
switches?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
label: undefined,
|
||||
modelValue: () => [] as T[],
|
||||
name: undefined,
|
||||
options: () => [],
|
||||
size: undefined,
|
||||
stacked: false,
|
||||
switches: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: T[]): void;
|
||||
}>();
|
||||
|
||||
const uid = useUid("g-checkbox-group-");
|
||||
|
||||
const groupName = computed(() => props.name ?? uid.value);
|
||||
|
||||
function isChecked(value: unknown) {
|
||||
return props.modelValue.some((selected) => looseEqual(selected, value));
|
||||
}
|
||||
|
||||
// GCheckbox children are untyped, so their value is trusted to be a T
|
||||
function toggle(value: unknown, checked: boolean) {
|
||||
const others = props.modelValue.filter((selected) => !looseEqual(selected, value));
|
||||
emit("update:modelValue", checked ? [...others, value as T] : others);
|
||||
}
|
||||
|
||||
provide(checkboxGroupKey, {
|
||||
disabled: computed(() => props.disabled),
|
||||
name: groupName,
|
||||
size: computed(() => props.size),
|
||||
switches: computed(() => props.switches),
|
||||
isChecked,
|
||||
toggle,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset class="g-checkbox-group" :disabled="disabled">
|
||||
<legend v-if="label || $slots.label" class="g-checkbox-group-legend">
|
||||
<slot name="label">{{ label }}</slot>
|
||||
</legend>
|
||||
|
||||
<div class="g-checkbox-group-options" :class="{ 'g-stacked': stacked }">
|
||||
<GCheckbox
|
||||
v-for="(option, index) in options"
|
||||
:key="index"
|
||||
:disabled="option.disabled"
|
||||
:value="option.value">
|
||||
{{ option.text }}
|
||||
</GCheckbox>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.g-checkbox-group {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.g-checkbox-group-legend {
|
||||
width: auto;
|
||||
margin-bottom: var(--spacing-1, 0.25rem);
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.g-checkbox-group-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-1, 0.25rem) var(--spacing-4, 1rem);
|
||||
|
||||
&.g-stacked {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ComputedRef, InjectionKey } from "vue";
|
||||
|
||||
import type { ComponentSize } from "./componentVariants";
|
||||
|
||||
/** One entry of GCheckboxGroup's `options` prop. */
|
||||
export interface CheckboxGroupOption<T = unknown> {
|
||||
/** Label shown next to the checkbox */
|
||||
text: string;
|
||||
/** Value added to the group's v-model when checked */
|
||||
value: T;
|
||||
/** Disables this option only */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** What a GCheckboxGroup shares with the GCheckbox children it renders or wraps. */
|
||||
export interface CheckboxGroupContext {
|
||||
disabled: ComputedRef<boolean>;
|
||||
name: ComputedRef<string>;
|
||||
size: ComputedRef<ComponentSize | undefined>;
|
||||
switches: ComputedRef<boolean>;
|
||||
isChecked: (value: unknown) => boolean;
|
||||
toggle: (value: unknown, checked: boolean) => void;
|
||||
}
|
||||
|
||||
export const checkboxGroupKey: InjectionKey<CheckboxGroupContext> = Symbol("g-checkbox-group");
|
||||
@@ -2,6 +2,7 @@
|
||||
// Galaxy client consumes it through a Vite source alias, and the Tool Shed frontend through a
|
||||
// file: dependency. See README.md.
|
||||
|
||||
export { type CheckboxGroupOption } from "./components/checkboxGroupContext";
|
||||
export {
|
||||
type ColorVariant,
|
||||
type ComponentColor,
|
||||
@@ -19,6 +20,7 @@ export { default as GAlert } from "./components/GAlert.vue";
|
||||
export { default as GButton } from "./components/GButton.vue";
|
||||
export { default as GButtonGroup } from "./components/GButtonGroup.vue";
|
||||
export { default as GCheckbox } from "./components/GCheckbox.vue";
|
||||
export { default as GCheckboxGroup } from "./components/GCheckboxGroup.vue";
|
||||
export { default as GCollapse } from "./components/GCollapse.vue";
|
||||
export { default as GDropdown } from "./components/GDropdown.vue";
|
||||
export { default as GDropdownDivider } from "./components/GDropdownDivider.vue";
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { looseEqual } from "./looseEqual";
|
||||
|
||||
describe("looseEqual", () => {
|
||||
it("matches a number and its string form", () => {
|
||||
expect(looseEqual(2, "2")).toBe(true);
|
||||
expect(looseEqual("2", 2)).toBe(true);
|
||||
expect(looseEqual(2, "02")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches objects with equal JSON forms", () => {
|
||||
expect(looseEqual({ id: 1, tags: ["a"] }, { id: 1, tags: ["a"] })).toBe(true);
|
||||
expect(looseEqual({ id: 1 }, { id: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it("depends on object key order, unlike BootstrapVue", () => {
|
||||
expect(looseEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("matches arrays by JSON form, not item by item like BootstrapVue", () => {
|
||||
expect(looseEqual([1, "a"], [1, "a"])).toBe(true);
|
||||
expect(looseEqual([1], ["1"])).toBe(false);
|
||||
});
|
||||
|
||||
it("never matches an object with a primitive", () => {
|
||||
expect(looseEqual({}, "[object Object]")).toBe(false);
|
||||
expect(looseEqual("[object Object]", {})).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps null and undefined apart from each other and from strings", () => {
|
||||
expect(looseEqual(null, null)).toBe(true);
|
||||
expect(looseEqual(undefined, undefined)).toBe(true);
|
||||
expect(looseEqual(null, undefined)).toBe(false);
|
||||
expect(looseEqual(null, "null")).toBe(false);
|
||||
expect(looseEqual(null, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("matches undefined and its string form, like other primitives", () => {
|
||||
expect(looseEqual(undefined, "undefined")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Group membership test, loose like BootstrapVue's: objects match by JSON form, other values by
|
||||
* string form, so a numeric option still matches a value that came back from the server as a string.
|
||||
* Stricter than BootstrapVue's: the JSON form depends on key order (`{ a, b }` does not match `{ b, a }`),
|
||||
* arrays match by JSON form, not item by item (`[1]` does not match `["1"]`), and null only matches null.
|
||||
*/
|
||||
export function looseEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
if (typeof a === "object" || typeof b === "object") {
|
||||
return false;
|
||||
}
|
||||
return String(a) === String(b);
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
"resolvePackageJsonExports": false
|
||||
},
|
||||
"vueCompilerOptions": {
|
||||
"target": 2.7
|
||||
"target": 3
|
||||
},
|
||||
"include": ["./src/**/*.ts", "./src/**/*.vue"]
|
||||
}
|
||||
|
||||
Generated
-61
@@ -151,9 +151,6 @@ importers:
|
||||
dompurify:
|
||||
specifier: ^3.0.6
|
||||
version: 3.4.16
|
||||
echarts:
|
||||
specifier: ^5.5.1
|
||||
version: 5.5.1
|
||||
elkjs:
|
||||
specifier: ^0.11.1
|
||||
version: 0.11.1
|
||||
@@ -316,9 +313,6 @@ importers:
|
||||
vue-demi:
|
||||
specifier: ^0.14.10
|
||||
version: 0.14.10(vue@3.5.43(typescript@5.8.3))
|
||||
vue-echarts:
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3(@vue/runtime-core@3.5.43)(echarts@5.5.1)(vue@3.5.43(typescript@5.8.3))
|
||||
vue-infinite-scroll:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
@@ -2972,9 +2966,6 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
echarts@5.5.1:
|
||||
resolution: {integrity: sha512-Fce8upazaAXUVUVsjgV6mBnGuqgO+JNDlcgF79Dksy4+wgGpQB2lmYoO4TSweFg/mZITdpGHomw/cNBJZj1icA==}
|
||||
|
||||
editorconfig@3.0.2:
|
||||
resolution: {integrity: sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -5030,9 +5021,6 @@ packages:
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.6.3:
|
||||
resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==}
|
||||
|
||||
@@ -5459,17 +5447,6 @@ packages:
|
||||
vue-component-type-helpers@3.3.11:
|
||||
resolution: {integrity: sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==}
|
||||
|
||||
vue-demi@0.13.11:
|
||||
resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.0.0-rc.1
|
||||
vue: 3.5.43
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -5481,16 +5458,6 @@ packages:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-echarts@7.0.3:
|
||||
resolution: {integrity: sha512-/jSxNwOsw5+dYAUcwSfkLwKPuzTQ0Cepz1LxCOpj2QcHrrmUa/Ql0eQqMmc1rTPQVrh2JQ29n2dhq75ZcHvRDw==}
|
||||
peerDependencies:
|
||||
'@vue/runtime-core': ^3.0.0
|
||||
echarts: ^5.5.1
|
||||
vue: 3.5.43
|
||||
peerDependenciesMeta:
|
||||
'@vue/runtime-core':
|
||||
optional: true
|
||||
|
||||
vue-eslint-parser@10.4.1:
|
||||
resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -5691,9 +5658,6 @@ packages:
|
||||
resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
zrender@5.6.0:
|
||||
resolution: {integrity: sha512-uzgraf4njmmHAbEUxMJ8Oxg+P3fT04O+9p7gY+wJRVxo8Ge+KmYv0WJev945EH4wFuc4OY2NLXz46FZrWS9xJg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@aashutoshrathi/word-wrap@1.2.6': {}
|
||||
@@ -7989,11 +7953,6 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
echarts@5.5.1:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 5.6.0
|
||||
|
||||
editorconfig@3.0.2:
|
||||
dependencies:
|
||||
'@one-ini/wasm': 0.2.1
|
||||
@@ -10342,8 +10301,6 @@ snapshots:
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.6.3: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
@@ -10978,24 +10935,10 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.11: {}
|
||||
|
||||
vue-demi@0.13.11(vue@3.5.43(typescript@5.8.3)):
|
||||
dependencies:
|
||||
vue: 3.5.43(typescript@5.8.3)
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.43(typescript@5.8.3)):
|
||||
dependencies:
|
||||
vue: 3.5.43(typescript@5.8.3)
|
||||
|
||||
vue-echarts@7.0.3(@vue/runtime-core@3.5.43)(echarts@5.5.1)(vue@3.5.43(typescript@5.8.3)):
|
||||
dependencies:
|
||||
echarts: 5.5.1
|
||||
vue: 3.5.43(typescript@5.8.3)
|
||||
vue-demi: 0.13.11(vue@3.5.43(typescript@5.8.3))
|
||||
optionalDependencies:
|
||||
'@vue/runtime-core': 3.5.43
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
vue-eslint-parser@10.4.1(eslint@10.12.0):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
@@ -11223,7 +11166,3 @@ snapshots:
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yoctocolors-cjs@2.1.3: {}
|
||||
|
||||
zrender@5.6.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
// Components must not import stylesheets that emit CSS. In a <style scoped> block every
|
||||
// selector gets the component's [data-v-*] attribute, so each import compiles a fresh,
|
||||
// un-dedupable copy of Bootstrap and friends into base.css (see #23703).
|
||||
import { readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const ROOTS = ["src", "packages/ui/src"];
|
||||
// A bare "bootstrap" also pulls in everything: it resolves through the package's "sass" field.
|
||||
const FORBIDDEN = [/["'](?:[^"']*\/)?base(?:\.scss)?["']/, /["'](?:[^"']*\/)?~?bootstrap(?:\.scss)?["']/];
|
||||
const IMPORT = /^\s*@(?:import|use)\b(.+)$/;
|
||||
|
||||
export function findForbiddenImports(source) {
|
||||
return source.split("\n").flatMap((line, index) => {
|
||||
const match = line.match(IMPORT);
|
||||
return match && FORBIDDEN.some((pattern) => pattern.test(match[1])) ? [`${index + 1}: ${line.trim()}`] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const client = join(import.meta.dirname, "..");
|
||||
const problems = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const entry of readdirSync(join(client, root), { recursive: true, withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".vue")) {
|
||||
continue;
|
||||
}
|
||||
const path = join(entry.parentPath, entry.name);
|
||||
for (const problem of findForbiddenImports(readFileSync(path, "utf8"))) {
|
||||
problems.push(`${relative(".", path)}:${problem}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error("Components must not import base.scss or all of Bootstrap:\n");
|
||||
console.error(problems.map((problem) => ` ${problem}`).join("\n"));
|
||||
console.error(
|
||||
'\nEach import adds a full scoped copy (~600 KB) to base.css. Import "@/style/scss/theme/blue.scss"' +
|
||||
"\nfor variables instead.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && realpathSync(process.argv[1]) === import.meta.filename) {
|
||||
main();
|
||||
}
|
||||
@@ -49,6 +49,47 @@ describe("errorResponseMiddleware", () => {
|
||||
expect(errorMessageAsString(error)).toBe("Galaxy took too long to respond (504)");
|
||||
});
|
||||
|
||||
// Browsers leave statusText empty over HTTP/2, and msw's HttpResponse would fill it in.
|
||||
// A DELETE, because the rate limiter retries a GET that gets a 429.
|
||||
it.each([
|
||||
[400, "The request was not valid (400)"],
|
||||
[401, "Authentication is required (401)"],
|
||||
[403, "Access was denied (403)"],
|
||||
[404, "The requested resource was not found (404)"],
|
||||
[408, "The request timed out (408)"],
|
||||
[413, "The request was too large (413)"],
|
||||
[429, "Too many requests, please wait and try again (429)"],
|
||||
[500, "An internal server error occurred (500)"],
|
||||
[502, "Galaxy is temporarily unavailable (502)"],
|
||||
[520, "Galaxy is temporarily unavailable (520)"],
|
||||
[521, "Galaxy is temporarily unavailable (521)"],
|
||||
[522, "Galaxy is temporarily unavailable (522)"],
|
||||
[523, "Galaxy is temporarily unavailable (523)"],
|
||||
[524, "Galaxy took too long to respond (524)"],
|
||||
[418, "The request failed (418)"],
|
||||
])("names a %d without relying on the status text", async (status, message) => {
|
||||
server.use(
|
||||
http.delete(
|
||||
"/api/histories/:history_id",
|
||||
() => new Response("<html><body>nginx</body></html>", { status }),
|
||||
),
|
||||
);
|
||||
|
||||
const { error } = await GalaxyApi().DELETE("/api/histories/{history_id}", {
|
||||
params: { path: { history_id: "f2db41e1fa331b3e" } },
|
||||
});
|
||||
|
||||
expect(errorMessageAsString(error)).toBe(message);
|
||||
});
|
||||
|
||||
it("falls back to the status text for a status it has no wording for", async () => {
|
||||
respondWith("<html><body>teapot</body></html>", 418);
|
||||
|
||||
const { error } = await fetchConfiguration();
|
||||
|
||||
expect(errorMessageAsString(error)).toBe("I'm a Teapot (418)");
|
||||
});
|
||||
|
||||
it("leaves a Galaxy API error untouched, whatever its content type says", async () => {
|
||||
respondWith(JSON.stringify({ err_msg: "Quota exceeded", err_code: 403002 }), 403, {
|
||||
...FROM_GALAXY,
|
||||
@@ -68,7 +109,7 @@ describe("errorResponseMiddleware", () => {
|
||||
|
||||
const { error } = await fetchConfiguration();
|
||||
|
||||
expect(errorMessageAsString(error)).toBe("Internal Server Error (500)");
|
||||
expect(errorMessageAsString(error)).toBe("An internal server error occurred (500)");
|
||||
});
|
||||
|
||||
it("normalizes a Galaxy response that is not an API error", async () => {
|
||||
@@ -79,7 +120,7 @@ describe("errorResponseMiddleware", () => {
|
||||
|
||||
const { error } = await fetchConfiguration();
|
||||
|
||||
expect(errorMessageAsString(error)).toBe("Not Found (404)");
|
||||
expect(errorMessageAsString(error)).toBe("The requested resource was not found (404)");
|
||||
});
|
||||
|
||||
it("keeps the original headers but not the length of the body it replaced", async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Middleware } from "openapi-fetch";
|
||||
|
||||
import { REQUEST_ID_HEADER } from "@/api/staleCacheRetry";
|
||||
import { statusMessage } from "@/utils/simple-error";
|
||||
|
||||
/** The error shape Galaxy's API returns. */
|
||||
interface NormalizedApiError {
|
||||
@@ -8,17 +9,6 @@ interface NormalizedApiError {
|
||||
err_code: number;
|
||||
}
|
||||
|
||||
const GATEWAY_MESSAGES: Record<number, string> = {
|
||||
502: "Galaxy is temporarily unavailable",
|
||||
503: "Galaxy is temporarily unavailable",
|
||||
504: "Galaxy took too long to respond",
|
||||
};
|
||||
|
||||
function statusMessage(status: number, statusText: string): string {
|
||||
const description = GATEWAY_MESSAGES[status] ?? statusText.trim();
|
||||
return description ? `${description} (${status})` : `The request failed (${status})`;
|
||||
}
|
||||
|
||||
function isGalaxyError(response: Response, body: string): boolean {
|
||||
// Galaxy stamps every response it produces with a request id; a proxy answering
|
||||
// in its place does not, even when what it writes happens to be JSON.
|
||||
|
||||
@@ -95,6 +95,10 @@ export const templateTypes: FileSourceTypesDetail = {
|
||||
message:
|
||||
"This is a read-only file repository plugin that connects to IIIF (International Image Interoperability Framework) sources. IIIF is a framework widely used by museums, libraries, and archives for delivering high-resolution image-based cultural heritage materials.",
|
||||
},
|
||||
xrootd: {
|
||||
icon: faNetworkWired,
|
||||
message: "This file source supports browsing and importing files from an XRootD server, with optional uploads.",
|
||||
},
|
||||
ipfs: {
|
||||
icon: faNetworkWired,
|
||||
message: "This is a read-only file source for browsing and importing IPFS files.",
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { createTestingPinia } from "@pinia/testing";
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import { nextTick, ref } from "vue";
|
||||
|
||||
import { useServerMock } from "@/api/client/__mocks__";
|
||||
import { defaultActivities } from "@/stores/activitySetup";
|
||||
import { useActivityStore } from "@/stores/activityStore";
|
||||
import { useUnprivilegedToolStore } from "@/stores/unprivilegedToolStore";
|
||||
|
||||
import mountTarget from "./ActivitySettings.vue";
|
||||
|
||||
const mockConfig = ref({});
|
||||
|
||||
vi.mock("@/composables/config", () => ({
|
||||
useConfig: vi.fn(() => ({
|
||||
config: mockConfig,
|
||||
isConfigLoaded: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const { server, http } = useServerMock();
|
||||
const activityItemSelector = ".activity-settings-item";
|
||||
|
||||
@@ -28,6 +38,10 @@ function testActivity(id, newOptions = {}) {
|
||||
return { ...defaultOptions, ...newOptions };
|
||||
}
|
||||
|
||||
function activityTitle(id) {
|
||||
return defaultActivities.find((activity) => activity.id === id).title;
|
||||
}
|
||||
|
||||
async function testSearch(wrapper, query, result) {
|
||||
await wrapper.setProps({ query });
|
||||
const filtered = wrapper.findAll(activityItemSelector);
|
||||
@@ -39,6 +53,7 @@ describe("ActivitySettings", () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockConfig.value = { interactivetools_enable: true, llm_api_configured: true };
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false });
|
||||
// Mock the response of the API call
|
||||
server.use(
|
||||
@@ -144,4 +159,85 @@ describe("ActivitySettings", () => {
|
||||
await testSearch(wrapper, "someTHING", 3);
|
||||
await testSearch(wrapper, "odd", 1);
|
||||
});
|
||||
|
||||
describe("built-in activity availability", () => {
|
||||
function renderedTitles(settings) {
|
||||
return settings.findAll(`${activityItemSelector} .font-weight-bold`).map((title) => title.text());
|
||||
}
|
||||
|
||||
async function mountSettings(config, { canUseUnprivilegedTools }) {
|
||||
mockConfig.value = config;
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false });
|
||||
useUnprivilegedToolStore().canUseUnprivilegedTools = canUseUnprivilegedTools;
|
||||
const store = useActivityStore("test-activity-bar-gates");
|
||||
const settings = mount(mountTarget, {
|
||||
global: { ...getLocalVue(), stubs: { FontAwesomeIcon: { template: "<div></div>" } } },
|
||||
pinia,
|
||||
props: { query: "", activityBarId: "test-activity-bar-gates" },
|
||||
});
|
||||
await store.sync();
|
||||
await flushPromises();
|
||||
return { settings, store };
|
||||
}
|
||||
|
||||
it("lists only optional activities when user-defined tools are permitted", async () => {
|
||||
const { settings } = await mountSettings(
|
||||
{ interactivetools_enable: true, llm_api_configured: true },
|
||||
{ canUseUnprivilegedTools: true },
|
||||
);
|
||||
const titles = renderedTitles(settings);
|
||||
expect(titles).toContain(activityTitle("user-defined-tools"));
|
||||
expect(titles).not.toContain(activityTitle("upload"));
|
||||
expect(titles).not.toContain(activityTitle("tools"));
|
||||
});
|
||||
|
||||
it("updates config-gated activities when configuration arrives without changing saved visibility", async () => {
|
||||
const { settings, store } = await mountSettings(null, { canUseUnprivilegedTools: false });
|
||||
const interactiveTools = store.getAll().find((activity) => activity.id === "interactivetools");
|
||||
interactiveTools.visible = false;
|
||||
await nextTick();
|
||||
expect(renderedTitles(settings)).not.toContain(activityTitle("interactivetools"));
|
||||
expect(renderedTitles(settings)).not.toContain(activityTitle("galaxyai"));
|
||||
|
||||
mockConfig.value = { interactivetools_enable: true, llm_api_configured: false };
|
||||
await nextTick();
|
||||
expect(renderedTitles(settings)).toContain(activityTitle("interactivetools"));
|
||||
expect(renderedTitles(settings)).not.toContain(activityTitle("galaxyai"));
|
||||
expect(interactiveTools.visible).toBe(false);
|
||||
|
||||
mockConfig.value = { interactivetools_enable: false, llm_api_configured: true };
|
||||
await nextTick();
|
||||
expect(renderedTitles(settings)).not.toContain(activityTitle("interactivetools"));
|
||||
expect(renderedTitles(settings)).toContain(activityTitle("galaxyai"));
|
||||
expect(interactiveTools.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("updates Custom Tools when permission changes while keeping always-on activities out", async () => {
|
||||
const { settings } = await mountSettings(
|
||||
{ interactivetools_enable: true, llm_api_configured: true },
|
||||
{ canUseUnprivilegedTools: false },
|
||||
);
|
||||
const permissions = useUnprivilegedToolStore();
|
||||
expect(renderedTitles(settings)).not.toContain(activityTitle("user-defined-tools"));
|
||||
|
||||
permissions.canUseUnprivilegedTools = true;
|
||||
await nextTick();
|
||||
expect(renderedTitles(settings)).toContain(activityTitle("user-defined-tools"));
|
||||
expect(renderedTitles(settings)).not.toContain(activityTitle("upload"));
|
||||
expect(renderedTitles(settings)).not.toContain(activityTitle("tools"));
|
||||
|
||||
permissions.canUseUnprivilegedTools = false;
|
||||
await nextTick();
|
||||
expect(renderedTitles(settings)).not.toContain(activityTitle("user-defined-tools"));
|
||||
expect(renderedTitles(settings)).toContain(activityTitle("histories"));
|
||||
});
|
||||
|
||||
it("hides interactive tools and GalaxyAI where the instance does not offer them", async () => {
|
||||
const { settings } = await mountSettings({}, { canUseUnprivilegedTools: true });
|
||||
const titles = renderedTitles(settings);
|
||||
expect(titles).not.toContain(activityTitle("interactivetools"));
|
||||
expect(titles).not.toContain(activityTitle("galaxyai"));
|
||||
expect(titles).toContain(activityTitle("user-defined-tools"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import { storeToRefs } from "pinia";
|
||||
import { computed, type ComputedRef } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { useConfig } from "@/composables/config";
|
||||
import { isActivityAvailable } from "@/stores/activitySetup";
|
||||
import { useActivityStore } from "@/stores/activityStore";
|
||||
import type { Activity } from "@/stores/activityStoreTypes";
|
||||
import { useUnprivilegedToolStore } from "@/stores/unprivilegedToolStore";
|
||||
@@ -27,10 +29,11 @@ const activityStore = useActivityStore(props.activityBarId);
|
||||
const unprivilegedToolStore = useUnprivilegedToolStore();
|
||||
const { canUseUnprivilegedTools } = storeToRefs(unprivilegedToolStore);
|
||||
|
||||
const { config } = useConfig();
|
||||
|
||||
const optionalActivities = computed(() => {
|
||||
return activityStore.activities.filter(
|
||||
(a) => (a.optional && a.id !== "user-defined-tools") || canUseUnprivilegedTools.value,
|
||||
);
|
||||
const availability = { canUseUnprivilegedTools: canUseUnprivilegedTools.value, config: config.value };
|
||||
return activityStore.activities.filter((a) => a.optional && isActivityAvailable(a.id, availability));
|
||||
});
|
||||
|
||||
const filteredActivities = computed(() => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { GCheckboxGroup } from "@galaxyproject/galaxy-ui";
|
||||
|
||||
export default GCheckboxGroup;
|
||||
</script>
|
||||
@@ -35,7 +35,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
simple: false,
|
||||
});
|
||||
|
||||
const { config } = useConfig(true);
|
||||
const { config } = useConfig();
|
||||
|
||||
const emit = defineEmits(["rendered", "show", "shown", "hide", "hidden"]);
|
||||
|
||||
|
||||
@@ -286,7 +286,10 @@ const {
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false" />
|
||||
</a>
|
||||
<HiddenWorkbookUploadInput ref="uploadRef" @onFileUpload="onFileUpload" />
|
||||
<HiddenWorkbookUploadInput
|
||||
ref="uploadRef"
|
||||
data-description="workbook shortcut file input"
|
||||
@onFileUpload="onFileUpload" />
|
||||
</h2>
|
||||
</template>
|
||||
<div v-if="wizard.isCurrent('select-what')">
|
||||
|
||||
@@ -485,7 +485,10 @@ async function download() {
|
||||
<FontAwesomeIcon size="xl" :icon="faUpload" />
|
||||
Already have a completed workbook? Upload it here.
|
||||
</BLink>
|
||||
<HiddenWorkbookUploadInput ref="uploadRef" @onFileUpload="onFileUpload" />
|
||||
<HiddenWorkbookUploadInput
|
||||
ref="uploadRef"
|
||||
data-description="sample sheet workbook file input"
|
||||
@onFileUpload="onFileUpload" />
|
||||
</div>
|
||||
</div>
|
||||
</GenericWizard>
|
||||
|
||||
@@ -33,7 +33,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
const collectionAttributesStore = useCollectionAttributesStore();
|
||||
|
||||
const historyStore = useHistoryStore();
|
||||
@@ -294,10 +294,13 @@ async function saveAttrs() {
|
||||
{{ localize("Datatypes") }}
|
||||
</template>
|
||||
|
||||
<DatatypesProvider v-slot="{ item, loading }">
|
||||
<DatatypesProvider v-slot="{ item, loading, error }">
|
||||
<div v-if="loading">
|
||||
<LoadingSpan message="Loading Datatypes" />
|
||||
</div>
|
||||
<GAlert v-else-if="error" show variant="danger">
|
||||
{{ localize("Unable to load datatypes:") }} {{ error }}
|
||||
</GAlert>
|
||||
<div v-else>
|
||||
<ChangeDatatypeTab
|
||||
v-if="item && datatypeFromElements"
|
||||
|
||||
@@ -21,7 +21,7 @@ const emit = defineEmits(["download"]);
|
||||
<BCardTitle>
|
||||
<b>Step 1: Download</b>
|
||||
</BCardTitle>
|
||||
<BLink :href="generateWorkbookLink" @click="emit('download')"
|
||||
<BLink data-description="workbook download link" :href="generateWorkbookLink" @click="emit('download')"
|
||||
><FontAwesomeIcon size="xl" :icon="faDownload" /> Download workbook.</BLink
|
||||
>
|
||||
</BCard>
|
||||
|
||||
@@ -44,7 +44,10 @@ const emit = defineEmits(["workbookContents"]);
|
||||
<FontAwesomeIcon size="xl" :icon="faUpload" />
|
||||
Drop completed workbook here or click to upload.
|
||||
</BLink>
|
||||
<HiddenWorkbookUploadInput ref="uploadRef" @onFileUpload="onFileUpload" />
|
||||
<HiddenWorkbookUploadInput
|
||||
ref="uploadRef"
|
||||
data-description="workbook card file input"
|
||||
@onFileUpload="onFileUpload" />
|
||||
</div>
|
||||
</BCard>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
defineProps<{
|
||||
// Lets tests tell the wizard header's input apart from the one in the upload card.
|
||||
dataDescription: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["onFileUpload"]);
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
|
||||
@@ -19,6 +24,11 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<label style="display: none">
|
||||
<input ref="fileInputRef" type="file" accept=".xlsx,.xls,.tsv,.csv,.tabular" @change="onFileUpload" />
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
:data-description="dataDescription"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.tsv,.csv,.tabular"
|
||||
@change="onFileUpload" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -73,7 +73,6 @@ async function ensureDatasetDetails() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Slot content keeps this scope id but moves under body, so a :deep() descendant selector never matches it. */
|
||||
.dataset-details-popover {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { createTestingPinia } from "@pinia/testing";
|
||||
import { compileStyle, parse } from "@vue/compiler-sfc";
|
||||
import { mount, RouterLinkStub, type VueWrapper } from "@vue/test-utils";
|
||||
import flushPromises from "flush-promises";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import DatasetPopoverLink from "./DatasetPopoverLink.vue";
|
||||
|
||||
// Vitest does not load SFC styles, so compile this component's scoped CSS the way the build does.
|
||||
function injectScopedStyles() {
|
||||
const filename = resolve(__dirname, "DatasetPopoverLink.vue");
|
||||
const { descriptor } = parse(readFileSync(filename, "utf-8"), { filename });
|
||||
const id = (DatasetPopoverLink as { __scopeId?: string }).__scopeId ?? "";
|
||||
const style = document.createElement("style");
|
||||
style.textContent = descriptor.styles
|
||||
.map((block) => compileStyle({ source: block.content, filename, id, scoped: block.scoped }).code)
|
||||
.join("\n");
|
||||
document.head.appendChild(style);
|
||||
return style;
|
||||
}
|
||||
|
||||
describe("DatasetPopoverLink", () => {
|
||||
let wrapper: VueWrapper | undefined;
|
||||
let style: HTMLStyleElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
style?.remove();
|
||||
});
|
||||
|
||||
it("caps the width of the popover content after it moves to the document body", async () => {
|
||||
style = injectScopedStyles();
|
||||
wrapper = mount(DatasetPopoverLink as object, {
|
||||
props: { datasetId: "dataset-id" },
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const content = document.querySelector<HTMLElement>(".dataset-details-popover");
|
||||
expect(content).not.toBeNull();
|
||||
expect(wrapper.element.contains(content)).toBe(false);
|
||||
expect(getComputedStyle(content!).maxWidth).toBe("420px");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,35 @@
|
||||
import { createTestingPinia } from "@pinia/testing";
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { sanitizeHtml } from "@/directives/sanitizeHtml";
|
||||
|
||||
import GCard from "./GCard.vue";
|
||||
|
||||
const { resizeCallbacks } = vi.hoisted(() => ({ resizeCallbacks: [] as Array<() => void> }));
|
||||
|
||||
vi.mock("@vueuse/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<object>();
|
||||
return {
|
||||
...actual,
|
||||
useResizeObserver: vi.fn((_target: unknown, callback: () => void) => {
|
||||
resizeCallbacks.push(callback);
|
||||
return { isSupported: { value: true }, stop: vi.fn() };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const localVue = getLocalVue();
|
||||
|
||||
function mountCard(propsData: object) {
|
||||
function setWidth(element: Element, property: "clientWidth" | "offsetWidth", value: number) {
|
||||
Object.defineProperty(element, property, { configurable: true, value });
|
||||
}
|
||||
|
||||
function mountCard(propsData: object, slots: Record<string, string> = {}) {
|
||||
return mount(GCard as object, {
|
||||
propsData: { id: "card", title: "Card", ...propsData },
|
||||
slots,
|
||||
localVue,
|
||||
pinia: createTestingPinia({ createSpy: vi.fn }),
|
||||
stubs: { FontAwesomeIcon: true },
|
||||
@@ -48,4 +66,247 @@ describe("GCard", () => {
|
||||
await wrapper.find("#g-card-action-copy-card").trigger("click");
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe("header", () => {
|
||||
function mountHeader() {
|
||||
return mountCard({
|
||||
badges: [{ id: "count", label: "3 items", title: "View items", to: "/items" }],
|
||||
canRenameTitle: true,
|
||||
extraActions: [{ id: "delete", label: "Delete", title: "Delete", handler: vi.fn() }],
|
||||
indicators: [{ id: "published", label: "Published", title: "Published", handler: vi.fn() }],
|
||||
selectable: true,
|
||||
showBookmark: true,
|
||||
titleNLines: 2,
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps the title, the badges and the bookmark and menu as separate groups of the header row", () => {
|
||||
const header = mountHeader().find("#g-card-card-header");
|
||||
const badges = header.find(".g-card-header-badges");
|
||||
const actions = header.find(".g-card-header-actions");
|
||||
|
||||
for (const group of [header.find(".g-card-title-section"), badges, actions]) {
|
||||
expect(group.element.parentElement).toBe(header.element);
|
||||
}
|
||||
for (const id of ["badges", "indicators"]) {
|
||||
expect(badges.find(`#g-card-${id}-card`).exists()).toBe(true);
|
||||
}
|
||||
expect(actions.classes()).toContain("flex-shrink-0");
|
||||
for (const id of ["bookmark-add", "extra-actions"]) {
|
||||
expect(actions.find(`#g-card-${id}-card`).exists()).toBe(true);
|
||||
}
|
||||
expect(actions.find("#g-card-badges-card").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders the header controls in reading order", () => {
|
||||
const header = mountHeader().find("#g-card-card-header");
|
||||
const expected = [
|
||||
"g-card-select-card",
|
||||
"g-card-title-card",
|
||||
"g-card-rename-card",
|
||||
"g-card-badges-card",
|
||||
"g-card-indicators-card",
|
||||
"g-card-bookmark-add-card",
|
||||
"g-card-extra-actions-card",
|
||||
];
|
||||
const ids = Array.from(header.element.querySelectorAll("[id]"), (el) => el.id).filter((id) =>
|
||||
expected.includes(id),
|
||||
);
|
||||
|
||||
expect(ids).toEqual(expected);
|
||||
});
|
||||
|
||||
it("sizes the first row for header controls only when the card has them", () => {
|
||||
const withControls = mountHeader().find("#g-card-card-header .g-card-title-section");
|
||||
const withoutControls = mountCard({ badges: [{ id: "state", label: "ok", title: "State" }] }).find(
|
||||
"#g-card-card-header .g-card-title-section",
|
||||
);
|
||||
|
||||
expect(withControls.classes()).toContain("g-card-header-line");
|
||||
expect(withoutControls.classes()).not.toContain("g-card-header-line");
|
||||
});
|
||||
|
||||
it("omits the badges and actions groups when the card has nothing to show in them", () => {
|
||||
const header = mountCard({
|
||||
badges: [{ id: "hidden", label: "Hidden", title: "Hidden", visible: false }],
|
||||
extraActions: [{ id: "hidden", label: "Hidden", title: "Hidden", visible: false }],
|
||||
}).find("#g-card-card-header");
|
||||
|
||||
expect(header.find(".g-card-header-badges").exists()).toBe(false);
|
||||
expect(header.find("#g-card-badges-card").exists()).toBe(false);
|
||||
expect(header.find("#g-card-indicators-card").exists()).toBe(false);
|
||||
expect(header.find(".g-card-header-actions").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders only the badge or indicator container that has content", () => {
|
||||
const header = mountCard({
|
||||
indicators: [{ id: "published", label: "Published", title: "Published", handler: vi.fn() }],
|
||||
}).find("#g-card-card-header");
|
||||
|
||||
expect(header.find(".g-card-header-badges").exists()).toBe(true);
|
||||
expect(header.find("#g-card-badges-card").exists()).toBe(false);
|
||||
expect(header.find("#g-card-indicators-card").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the badges group for slot content", () => {
|
||||
const header = mountCard({}, { badges: "<span id='slot-badge'>Slot badge</span>" }).find(
|
||||
"#g-card-card-header",
|
||||
);
|
||||
|
||||
expect(header.find(".g-card-header-badges #slot-badge").exists()).toBe(true);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
});
|
||||
|
||||
function mountStackedHeader() {
|
||||
resizeCallbacks.length = 0;
|
||||
const wrapper = mountHeader();
|
||||
const header = wrapper.find("#g-card-card-header");
|
||||
const measure = resizeCallbacks.at(-1)!;
|
||||
setWidth(header.element, "clientWidth", 300);
|
||||
setWidth(header.find(".g-card-header-select").element, "offsetWidth", 20);
|
||||
setWidth(header.find("#g-card-badges-card").element, "offsetWidth", 150);
|
||||
setWidth(header.find("#g-card-indicators-card").element, "offsetWidth", 30);
|
||||
setWidth(header.find(".g-card-header-actions").element, "offsetWidth", 40);
|
||||
measure();
|
||||
return { wrapper, header, measure };
|
||||
}
|
||||
|
||||
it("keeps the badges on the first row until the header has been measured", () => {
|
||||
const header = mountHeader().find("#g-card-card-header");
|
||||
|
||||
expect(header.classes()).toContain("g-card-header-inline");
|
||||
});
|
||||
|
||||
it("moves the badges to their own row when they do not fit beside a 10rem title", async () => {
|
||||
const { wrapper, header, measure } = mountStackedHeader();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(header.classes()).toContain("g-card-header-stacked");
|
||||
|
||||
setWidth(header.element, "clientWidth", 600);
|
||||
measure();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(header.classes()).toContain("g-card-header-inline");
|
||||
});
|
||||
|
||||
it("keeps the last layout while the header is hidden", async () => {
|
||||
const { wrapper, header, measure } = mountStackedHeader();
|
||||
setWidth(header.element, "clientWidth", 0);
|
||||
measure();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(header.classes()).toContain("g-card-header-stacked");
|
||||
});
|
||||
|
||||
it("returns to one row when the badges are removed", async () => {
|
||||
const { wrapper, header } = mountStackedHeader();
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.setProps({ badges: [], indicators: [] } as never);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(header.classes()).toContain("g-card-header-inline");
|
||||
});
|
||||
|
||||
it("measures again when the header controls change", async () => {
|
||||
const widths: Array<[string, number]> = [
|
||||
["#g-card-badges-card", 120],
|
||||
[".g-card-header-actions", 40],
|
||||
];
|
||||
const offsetWidth = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
) {
|
||||
return widths.find(([selector]) => this.matches(selector))?.[1] ?? 0;
|
||||
});
|
||||
try {
|
||||
resizeCallbacks.length = 0;
|
||||
const wrapper = mountCard({ badges: [{ id: "state", label: "ok", title: "State" }] });
|
||||
const header = wrapper.find("#g-card-card-header");
|
||||
setWidth(header.element, "clientWidth", 300);
|
||||
resizeCallbacks.at(-1)!();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(header.classes()).toContain("g-card-header-inline");
|
||||
|
||||
await wrapper.setProps({ showBookmark: true } as never);
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(header.classes()).toContain("g-card-header-column");
|
||||
} finally {
|
||||
offsetWidth.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps the title when titleNLines is set", () => {
|
||||
const title = mountHeader().find("#g-card-title-text-card");
|
||||
|
||||
expect(title.classes()).toContain("g-card-title-truncate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clickable title tooltip", () => {
|
||||
const NAME = "Differential expression of liver samples, batch 2, trimmed and aligned to GRCh38";
|
||||
|
||||
function setHeight(element: Element, scrollHeight: number, clientHeight: number) {
|
||||
Object.defineProperty(element, "scrollHeight", { configurable: true, value: scrollHeight });
|
||||
Object.defineProperty(element, "clientHeight", { configurable: true, value: clientHeight });
|
||||
}
|
||||
|
||||
async function mountTitle(scrollHeight: number, clientHeight: number, hint = "Click to preview workflow") {
|
||||
resizeCallbacks.length = 0;
|
||||
const wrapper = mountCard({ title: { label: NAME, title: hint, handler: vi.fn() }, titleNLines: 2 });
|
||||
setHeight(wrapper.find("#g-card-title-link-card").element, scrollHeight, clientHeight);
|
||||
resizeCallbacks.forEach((measure) => measure());
|
||||
await wrapper.vm.$nextTick();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
it("shows the click hint, then the full name, when the title is cut off", async () => {
|
||||
const tooltip = (await mountTitle(60, 40)).find("#g-card-title-tooltip-card");
|
||||
|
||||
const hint = tooltip.find(".g-card-title-tooltip-hint");
|
||||
const name = tooltip.find(".g-card-title-tooltip-name");
|
||||
expect(hint.text()).toBe("Click to preview workflow");
|
||||
expect(name.text()).toBe(NAME);
|
||||
expect(hint.element.compareDocumentPosition(name.element)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
|
||||
it("shows only the click hint when the whole title fits", async () => {
|
||||
const tooltip = (await mountTitle(40, 40)).find("#g-card-title-tooltip-card");
|
||||
|
||||
expect(tooltip.find(".g-card-title-tooltip-hint").text()).toBe("Click to preview workflow");
|
||||
expect(tooltip.find(".g-card-title-tooltip-name").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not repeat the name when the hint already is the name", async () => {
|
||||
const tooltip = (await mountTitle(60, 40, NAME)).find("#g-card-title-tooltip-card");
|
||||
|
||||
expect(tooltip.find(".g-card-title-tooltip-hint").text()).toBe(NAME);
|
||||
expect(tooltip.find(".g-card-title-tooltip-name").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the full name as the link's accessible name and the tooltip as its description", async () => {
|
||||
const wrapper = await mountTitle(60, 40);
|
||||
const link = wrapper.find("#g-card-title-link-card");
|
||||
const tooltip = wrapper.find("#g-card-title-tooltip-card");
|
||||
|
||||
expect(link.text()).toBe(NAME);
|
||||
expect(link.attributes("aria-label")).toBeUndefined();
|
||||
expect(link.attributes("title")).toBeUndefined();
|
||||
expect(link.attributes("aria-describedby")).toBe("g-card-title-tooltip-card");
|
||||
expect(tooltip.find(".g-card-title-tooltip-hint").attributes("aria-hidden")).toBeUndefined();
|
||||
expect(tooltip.find(".g-card-title-tooltip-name").attributes("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("shows the tooltip when the link gets keyboard focus", async () => {
|
||||
const wrapper = await mountTitle(60, 40);
|
||||
|
||||
await wrapper.find("#g-card-title-link-card").trigger("focus");
|
||||
|
||||
expect(wrapper.find("#g-card-title-tooltip-card").attributes("data-show")).toBe("true");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
type IconDefinition,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { useResizeObserver } from "@vueuse/core";
|
||||
import { BBadge, BFormCheckbox, BLink } from "bootstrap-vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
import { sizeToGSize, variantToColor } from "@/components/BaseComponents/variantToColor";
|
||||
import type { BootstrapSize } from "@/components/Common";
|
||||
@@ -21,16 +22,23 @@ import { useUid } from "@/composables/utils/uid";
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import type { CardAction, CardBadge, CardIndicator, Title, TitleIcon, TitleSize } from "./GCard.types";
|
||||
import { chooseHeaderLayout, type GCardHeaderLayout } from "./gCardHeaderLayout";
|
||||
|
||||
import GButton from "@/components/BaseComponents/GButton.vue";
|
||||
import GButtonGroup from "@/components/BaseComponents/GButtonGroup.vue";
|
||||
import GDropdown from "@/components/BaseComponents/GDropdown.vue";
|
||||
import GDropdownItem from "@/components/BaseComponents/GDropdownItem.vue";
|
||||
import GTooltip from "@/components/BaseComponents/GTooltip.vue";
|
||||
import Heading from "@/components/Common/Heading.vue";
|
||||
import TextSummary from "@/components/Common/TextSummary.vue";
|
||||
import StatelessTags from "@/components/TagsMultiselect/StatelessTags.vue";
|
||||
import UtcDate from "@/components/UtcDate.vue";
|
||||
|
||||
/**
|
||||
* Narrowest the title gets before the badges move to their own row; matches the title section's flex-basis
|
||||
*/
|
||||
const TITLE_MIN_WIDTH_REM = 10;
|
||||
|
||||
interface Props {
|
||||
/** Unique identifier for the card
|
||||
* @default useUid("g-card-").value
|
||||
@@ -357,6 +365,117 @@ function toIconSize(size: BootstrapSize | undefined): SizeProp | undefined {
|
||||
*/
|
||||
const allowedTitleLines = computed(() => props.titleNLines);
|
||||
|
||||
const titleLink = ref<{ $el: HTMLElement } | null>(null);
|
||||
const titleLinkElement = computed(() => titleLink.value?.$el ?? null);
|
||||
const titleClamped = ref(false);
|
||||
|
||||
/**
|
||||
* Full name shown under the click hint, only when the clamped title hides part of it
|
||||
*/
|
||||
const titleTooltipName = computed(() => {
|
||||
if (typeof props.title !== "object" || !titleClamped.value) {
|
||||
return "";
|
||||
}
|
||||
return props.title.label === localize(props.title.title) ? "" : props.title.label;
|
||||
});
|
||||
|
||||
function measureTitle() {
|
||||
const link = titleLinkElement.value;
|
||||
// 1px threshold, as in v-g-tooltip.onoverflow, for subpixel line-clamp heights
|
||||
titleClamped.value = !!props.titleNLines && !!link && link.scrollHeight > link.clientHeight + 1;
|
||||
}
|
||||
|
||||
const hasExtraActions = computed(() => props.extraActions?.some((ea) => ea.visible ?? true) ?? false);
|
||||
|
||||
const hasBadges = computed(() => props.badges?.some((b) => b.visible ?? true) ?? false);
|
||||
|
||||
const hasIndicators = computed(() => props.indicators?.some((i) => i.visible ?? true) ?? false);
|
||||
|
||||
/**
|
||||
* Whether the header's first row holds controls taller than a text-size title line
|
||||
*/
|
||||
const hasHeaderControls = computed(
|
||||
() =>
|
||||
props.selectable || props.canRenameTitle || props.canClearTitle || props.showBookmark || hasExtraActions.value,
|
||||
);
|
||||
|
||||
const headerElement = ref<HTMLElement | null>(null);
|
||||
const selectElement = ref<HTMLElement | null>(null);
|
||||
const badgesElement = ref<HTMLElement | null>(null);
|
||||
const badgeListElement = ref<HTMLElement | null>(null);
|
||||
const indicatorsElement = ref<HTMLElement | null>(null);
|
||||
const actionsElement = ref<HTMLElement | null>(null);
|
||||
const headerLayout = ref<GCardHeaderLayout>("inline");
|
||||
let headerFrame: number | undefined;
|
||||
|
||||
/**
|
||||
* Places the badges beside the title, under the actions, or on their own row, whichever keeps the title 10rem wide.
|
||||
* CSS alone cannot move the badges off the first row while the actions, which follow them in the DOM, stay on it.
|
||||
* Only widths that do not depend on the applied layout are read, so the result cannot oscillate.
|
||||
*/
|
||||
function measureHeader() {
|
||||
const header = headerElement.value;
|
||||
const badges = badgesElement.value;
|
||||
if (!header || !badges) {
|
||||
setHeaderLayout("inline");
|
||||
return;
|
||||
}
|
||||
if (header.clientWidth === 0) {
|
||||
return;
|
||||
}
|
||||
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
const badgesGap = parseFloat(getComputedStyle(badges).columnGap) || 0;
|
||||
const badgeParts = [badgeListElement.value, indicatorsElement.value].filter((el): el is HTMLElement => !!el);
|
||||
setHeaderLayout(
|
||||
chooseHeaderLayout({
|
||||
actions: actionsElement.value?.offsetWidth,
|
||||
available: header.clientWidth,
|
||||
badges:
|
||||
badgeParts.reduce((sum, el) => sum + el.offsetWidth, 0) +
|
||||
badgesGap * Math.max(badgeParts.length - 1, 0),
|
||||
gap: parseFloat(getComputedStyle(header).columnGap) || 0,
|
||||
select: selectElement.value?.offsetWidth,
|
||||
titleMin: TITLE_MIN_WIDTH_REM * rootFontSize,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the layout in the next frame, so the class change never resizes observed elements inside the observer callback
|
||||
*/
|
||||
function setHeaderLayout(layout: GCardHeaderLayout) {
|
||||
if (headerFrame !== undefined) {
|
||||
cancelAnimationFrame(headerFrame);
|
||||
}
|
||||
headerFrame = requestAnimationFrame(() => {
|
||||
headerFrame = undefined;
|
||||
headerLayout.value = layout;
|
||||
});
|
||||
}
|
||||
|
||||
useResizeObserver([headerElement, badgeListElement, indicatorsElement, titleLinkElement], () => {
|
||||
measureTitle();
|
||||
measureHeader();
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => (typeof props.title === "object" ? props.title.label : undefined), () => props.titleNLines],
|
||||
measureTitle,
|
||||
{
|
||||
flush: "post",
|
||||
},
|
||||
);
|
||||
|
||||
watch([() => props.selectable, () => props.showBookmark, hasExtraActions, badgesElement], measureHeader, {
|
||||
flush: "post",
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (headerFrame !== undefined) {
|
||||
cancelAnimationFrame(headerFrame);
|
||||
}
|
||||
});
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
@@ -400,81 +519,97 @@ function onKeyDown(event: KeyboardEvent) {
|
||||
<div class="d-flex flex-column flex-gapy-1">
|
||||
<div
|
||||
:id="`g-card-${props.id}-header`"
|
||||
class="d-flex flex-gapy-1 flex-gapx-1 justify-content-between">
|
||||
<div class="d-flex flex-column flex-grow-1 g-card-title-section">
|
||||
<div class="d-flex">
|
||||
<div v-if="selectable">
|
||||
<slot name="select">
|
||||
<BFormCheckbox
|
||||
:id="getElementId(props.id, 'select')"
|
||||
v-g-tooltip.hover
|
||||
:checked="selected"
|
||||
:title="props.selectTitle || localize('Select for bulk actions')"
|
||||
@change="emit('select')" />
|
||||
</slot>
|
||||
</div>
|
||||
ref="headerElement"
|
||||
class="g-card-header align-items-start flex-gapx-1"
|
||||
:class="[`g-card-header-${headerLayout}`, { 'g-card-header-selectable': selectable }]">
|
||||
<div
|
||||
v-if="selectable"
|
||||
ref="selectElement"
|
||||
class="g-card-header-select g-card-header-line align-items-center d-flex flex-shrink-0">
|
||||
<slot name="select">
|
||||
<BFormCheckbox
|
||||
:id="getElementId(props.id, 'select')"
|
||||
v-g-tooltip.hover
|
||||
:checked="selected"
|
||||
:title="props.selectTitle || localize('Select for bulk actions')"
|
||||
@change="emit('select')" />
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div :id="`g-card-${props.id}-header-title`">
|
||||
<slot name="title">
|
||||
<Heading
|
||||
:id="getElementId(props.id, 'title')"
|
||||
bold
|
||||
inline
|
||||
:size="props.titleSize">
|
||||
<FontAwesomeIcon
|
||||
v-if="props.titleIcon?.icon"
|
||||
class="mr-1"
|
||||
:class="props.titleIcon.class"
|
||||
:icon="props.titleIcon.icon"
|
||||
:title="props.titleIcon.title"
|
||||
:size="props.titleIcon.size"
|
||||
fixed-width />
|
||||
<BLink
|
||||
v-if="typeof title === 'object'"
|
||||
:id="getElementId(props.id, 'title-link')"
|
||||
<div
|
||||
class="g-card-title-section d-flex flex-column justify-content-center"
|
||||
:class="{ 'g-card-header-line': hasHeaderControls }">
|
||||
<div :id="`g-card-${props.id}-header-title`">
|
||||
<slot name="title">
|
||||
<Heading :id="getElementId(props.id, 'title')" bold inline :size="props.titleSize">
|
||||
<FontAwesomeIcon
|
||||
v-if="props.titleIcon?.icon"
|
||||
class="mr-1"
|
||||
:class="props.titleIcon.class"
|
||||
:icon="props.titleIcon.icon"
|
||||
:title="props.titleIcon.title"
|
||||
:size="props.titleIcon.size"
|
||||
fixed-width />
|
||||
<BLink
|
||||
v-if="typeof title === 'object'"
|
||||
:id="getElementId(props.id, 'title-link')"
|
||||
ref="titleLink"
|
||||
:class="{ 'g-card-title-truncate': props.titleNLines }"
|
||||
@click.stop.prevent="title.handler && title.handler()">
|
||||
{{ title.label }}
|
||||
</BLink>
|
||||
<template v-else>
|
||||
<span
|
||||
:id="getElementId(props.id, 'title-text')"
|
||||
v-g-tooltip.onoverflow
|
||||
:title="localize(title)"
|
||||
:class="{ 'g-card-title-truncate': props.titleNLines }">
|
||||
{{ title }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<slot name="titleActions">
|
||||
<GButton
|
||||
v-if="props.canRenameTitle"
|
||||
:id="getElementId(props.id, 'rename')"
|
||||
v-g-tooltip.hover
|
||||
:title="localize(title.title)"
|
||||
:class="{ 'g-card-title-truncate': props.titleNLines }"
|
||||
@click.stop.prevent="title.handler && title.handler()">
|
||||
{{ title.label }}
|
||||
</BLink>
|
||||
<template v-else>
|
||||
<span
|
||||
:id="getElementId(props.id, 'title-text')"
|
||||
v-g-tooltip.onoverflow
|
||||
:title="localize(title)"
|
||||
:class="{ 'g-card-title-truncate': props.titleNLines }">
|
||||
{{ title }}
|
||||
</span>
|
||||
</template>
|
||||
class="inline-icon-button g-card-rename"
|
||||
transparent
|
||||
icon-only
|
||||
color="blue"
|
||||
:title="localize(props.renameTitle)"
|
||||
@click="emit('rename')">
|
||||
<FontAwesomeIcon :icon="faPen" fixed-width />
|
||||
</GButton>
|
||||
<GButton
|
||||
v-if="props.canClearTitle"
|
||||
:id="getElementId(props.id, 'clear-title')"
|
||||
v-g-tooltip.hover
|
||||
class="inline-icon-button g-card-clear-title"
|
||||
transparent
|
||||
:title="localize(props.clearTitleTooltip)"
|
||||
@click="emit('clearTitle')">
|
||||
<FontAwesomeIcon :icon="faTimes" fixed-width />
|
||||
</GButton>
|
||||
</slot>
|
||||
</Heading>
|
||||
|
||||
<slot name="titleActions">
|
||||
<GButton
|
||||
v-if="props.canRenameTitle"
|
||||
:id="getElementId(props.id, 'rename')"
|
||||
v-g-tooltip.hover
|
||||
class="inline-icon-button g-card-rename"
|
||||
transparent
|
||||
icon-only
|
||||
color="blue"
|
||||
:title="localize(props.renameTitle)"
|
||||
@click="emit('rename')">
|
||||
<FontAwesomeIcon :icon="faPen" fixed-width />
|
||||
</GButton>
|
||||
<GButton
|
||||
v-if="props.canClearTitle"
|
||||
:id="getElementId(props.id, 'clear-title')"
|
||||
v-g-tooltip.hover
|
||||
class="inline-icon-button g-card-clear-title"
|
||||
transparent
|
||||
:title="localize(props.clearTitleTooltip)"
|
||||
@click="emit('clearTitle')">
|
||||
<FontAwesomeIcon :icon="faTimes" fixed-width />
|
||||
</GButton>
|
||||
</slot>
|
||||
</Heading>
|
||||
</slot>
|
||||
</div>
|
||||
<!-- Outside the heading, so the hidden tooltip text is not part of its name -->
|
||||
<GTooltip
|
||||
v-if="typeof title === 'object'"
|
||||
:id="getElementId(props.id, 'title-tooltip')"
|
||||
class="g-card-title-tooltip"
|
||||
:reference="titleLinkElement">
|
||||
<div class="g-card-title-tooltip-hint">{{ localize(title.title) }}</div>
|
||||
<!-- Hidden from the link's description, since the link text already reads the name -->
|
||||
<div
|
||||
v-if="titleTooltipName"
|
||||
class="g-card-title-tooltip-name"
|
||||
aria-hidden="true">
|
||||
{{ titleTooltipName }}
|
||||
</div>
|
||||
</GTooltip>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="align-items-center d-flex flex-gapx-1">
|
||||
@@ -503,148 +638,148 @@ function onKeyDown(event: KeyboardEvent) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="align-items-start d-flex flex-row-reverse flex-wrap gap-1 flex-shrink-0">
|
||||
<div>
|
||||
<slot v-if="props.showBookmark" name="bookmark">
|
||||
<GButton
|
||||
v-if="!bookmarkLoading"
|
||||
:id="
|
||||
getElementId(
|
||||
props.id,
|
||||
props.bookmarked ? 'bookmark-remove' : 'bookmark-add',
|
||||
)
|
||||
"
|
||||
v-g-tooltip.hover
|
||||
class="inline-icon-button"
|
||||
transparent
|
||||
icon-only
|
||||
color="blue"
|
||||
:title="props.bookmarked ? 'Remove bookmark' : 'Add to bookmarks'"
|
||||
@click="toggleBookmark">
|
||||
<FontAwesomeIcon :icon="props.bookmarked ? faStar : farStar" fixed-width />
|
||||
</GButton>
|
||||
<GButton
|
||||
v-else
|
||||
:id="getElementId(props.id, 'bookmark-loading')"
|
||||
v-g-tooltip.hover
|
||||
class="inline-icon-button"
|
||||
transparent
|
||||
icon-only
|
||||
color="blue"
|
||||
:title="localize('Bookmarking...')"
|
||||
disabled>
|
||||
<FontAwesomeIcon :icon="faSpinner" spin fixed-width />
|
||||
</GButton>
|
||||
</slot>
|
||||
|
||||
<slot name="extra-actions">
|
||||
<GDropdown
|
||||
v-if="
|
||||
props.extraActions?.length &&
|
||||
props.extraActions.some((ea) => ea.visible ?? true)
|
||||
"
|
||||
:id="getElementId(props.id, 'extra-actions')"
|
||||
v-g-tooltip.hover
|
||||
right
|
||||
no-caret
|
||||
title="More options"
|
||||
toggle-class="inline-icon-button"
|
||||
variant="link"
|
||||
@show="() => onExtraDropdown(true)"
|
||||
@hide="() => onExtraDropdown(false)">
|
||||
<template v-slot:button-content>
|
||||
<FontAwesomeIcon :icon="faCaretDown" fixed-width />
|
||||
</template>
|
||||
|
||||
<template v-for="ea in props.extraActions" :key="ea.id">
|
||||
<GDropdownItem
|
||||
v-if="ea.visible ?? true"
|
||||
:id="getActionId(props.id, ea.id)"
|
||||
:disabled="ea.disabled"
|
||||
:variant="ea.variant || 'link'"
|
||||
:to="ea.to"
|
||||
:href="ea.href"
|
||||
:title="ea.title"
|
||||
:size="ea.size || 'sm'"
|
||||
:target="ea.externalLink ? '_blank' : undefined"
|
||||
@click="ea.handler && ea.handler()">
|
||||
<FontAwesomeIcon v-if="ea.icon" :icon="ea.icon" fixed-width />
|
||||
{{ localize(ea.label) }}
|
||||
</GDropdownItem>
|
||||
</template>
|
||||
</GDropdown>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-gapx-1" style="margin-top: 1px">
|
||||
<div
|
||||
:id="getElementId(props.id, 'badges')"
|
||||
class="align-items-center align-self-baseline d-flex flex-gapx-1">
|
||||
<slot name="badges">
|
||||
<template v-for="badge in props.badges" :key="badge.id">
|
||||
<BBadge
|
||||
v-if="badge.visible ?? true"
|
||||
:id="getBadgeId(props.id, badge.id)"
|
||||
v-g-tooltip.hover.top
|
||||
:pill="badge.type !== 'badge'"
|
||||
:class="{
|
||||
'outline-badge': badge.variant?.includes('outline'),
|
||||
'cursor-pointer': badge.handler,
|
||||
[String(badge.class)]: badge.class,
|
||||
}"
|
||||
:title="localize(badge.title)"
|
||||
:variant="badge.variant || 'secondary'"
|
||||
:to="badge.to"
|
||||
:href="badge.href"
|
||||
@click.stop="badge.handler && badge.handler()">
|
||||
<FontAwesomeIcon
|
||||
v-if="badge.icon"
|
||||
:icon="badge.icon"
|
||||
fixed-width
|
||||
:spin="badge.spin" />
|
||||
{{ localize(badge.label) }}
|
||||
</BBadge>
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div :id="getElementId(props.id, 'indicators')" class="align-self-baseline">
|
||||
<slot name="indicators">
|
||||
<template v-for="indicator in props.indicators" :key="indicator.id">
|
||||
<GButton
|
||||
v-if="(indicator.visible ?? true) && !indicator.disabled"
|
||||
:id="getIndicatorId(props.id, indicator.id)"
|
||||
v-g-tooltip.hover
|
||||
class="inline-icon-button"
|
||||
:title="localize(indicator.title)"
|
||||
v-bind="variantToColor(indicator.variant || 'link')"
|
||||
:size="sizeToGSize(indicator.size || 'sm')"
|
||||
:to="indicator.to"
|
||||
:href="indicator.href"
|
||||
:disabled="indicator.disabled"
|
||||
:target="indicator.externalLink ? '_blank' : undefined"
|
||||
@click.stop="indicator.handler && indicator.handler()">
|
||||
<FontAwesomeIcon
|
||||
v-if="indicator.icon"
|
||||
:icon="indicator.icon"
|
||||
fixed-width />
|
||||
{{ localize(indicator.label) }}
|
||||
</GButton>
|
||||
<div
|
||||
v-if="hasBadges || hasIndicators || $slots.badges || $slots.indicators"
|
||||
ref="badgesElement"
|
||||
class="g-card-header-badges align-items-center d-flex flex-gapx-1 flex-shrink-0 mw-100"
|
||||
:class="{ 'g-card-header-line': hasHeaderControls }">
|
||||
<div
|
||||
v-if="hasBadges || $slots.badges"
|
||||
:id="getElementId(props.id, 'badges')"
|
||||
ref="badgeListElement"
|
||||
class="align-items-center d-flex flex-gapx-1 flex-wrap">
|
||||
<slot name="badges">
|
||||
<template v-for="badge in props.badges" :key="badge.id">
|
||||
<BBadge
|
||||
v-if="badge.visible ?? true"
|
||||
:id="getBadgeId(props.id, badge.id)"
|
||||
v-g-tooltip.hover.top
|
||||
:pill="badge.type !== 'badge'"
|
||||
:class="{
|
||||
'outline-badge': badge.variant?.includes('outline'),
|
||||
'cursor-pointer': badge.handler,
|
||||
[String(badge.class)]: badge.class,
|
||||
}"
|
||||
:title="localize(badge.title)"
|
||||
:variant="badge.variant || 'secondary'"
|
||||
:to="badge.to"
|
||||
:href="badge.href"
|
||||
@click.stop="badge.handler && badge.handler()">
|
||||
<FontAwesomeIcon
|
||||
v-else-if="
|
||||
(indicator.visible ?? true) && indicator.disabled && indicator.icon
|
||||
"
|
||||
:id="getIndicatorId(props.id, indicator.id)"
|
||||
:key="`${indicator.id}-icon`"
|
||||
v-g-tooltip.hover
|
||||
:title="localize(indicator.title)"
|
||||
:icon="indicator.icon"
|
||||
:size="toIconSize(indicator.size) || 'sm'"
|
||||
fixed-width />
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
v-if="badge.icon"
|
||||
:icon="badge.icon"
|
||||
fixed-width
|
||||
:spin="badge.spin" />
|
||||
{{ localize(badge.label) }}
|
||||
</BBadge>
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasIndicators || $slots.indicators"
|
||||
:id="getElementId(props.id, 'indicators')"
|
||||
ref="indicatorsElement">
|
||||
<slot name="indicators">
|
||||
<template v-for="indicator in props.indicators" :key="indicator.id">
|
||||
<GButton
|
||||
v-if="(indicator.visible ?? true) && !indicator.disabled"
|
||||
:id="getIndicatorId(props.id, indicator.id)"
|
||||
v-g-tooltip.hover
|
||||
class="inline-icon-button"
|
||||
:title="localize(indicator.title)"
|
||||
v-bind="variantToColor(indicator.variant || 'link')"
|
||||
:size="sizeToGSize(indicator.size || 'sm')"
|
||||
:to="indicator.to"
|
||||
:href="indicator.href"
|
||||
:disabled="indicator.disabled"
|
||||
:target="indicator.externalLink ? '_blank' : undefined"
|
||||
@click.stop="indicator.handler && indicator.handler()">
|
||||
<FontAwesomeIcon v-if="indicator.icon" :icon="indicator.icon" fixed-width />
|
||||
{{ localize(indicator.label) }}
|
||||
</GButton>
|
||||
<FontAwesomeIcon
|
||||
v-else-if="
|
||||
(indicator.visible ?? true) && indicator.disabled && indicator.icon
|
||||
"
|
||||
:id="getIndicatorId(props.id, indicator.id)"
|
||||
:key="`${indicator.id}-icon`"
|
||||
v-g-tooltip.hover
|
||||
:title="localize(indicator.title)"
|
||||
:icon="indicator.icon"
|
||||
:size="toIconSize(indicator.size) || 'sm'"
|
||||
fixed-width />
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.showBookmark || hasExtraActions || $slots['extra-actions']"
|
||||
ref="actionsElement"
|
||||
class="g-card-header-actions align-items-center d-flex flex-shrink-0"
|
||||
:class="{ 'g-card-header-line': hasHeaderControls }">
|
||||
<slot v-if="props.showBookmark" name="bookmark">
|
||||
<GButton
|
||||
v-if="!bookmarkLoading"
|
||||
:id="getElementId(props.id, props.bookmarked ? 'bookmark-remove' : 'bookmark-add')"
|
||||
v-g-tooltip.hover
|
||||
class="inline-icon-button"
|
||||
transparent
|
||||
icon-only
|
||||
color="blue"
|
||||
:title="props.bookmarked ? 'Remove bookmark' : 'Add to bookmarks'"
|
||||
@click="toggleBookmark">
|
||||
<FontAwesomeIcon :icon="props.bookmarked ? faStar : farStar" fixed-width />
|
||||
</GButton>
|
||||
<GButton
|
||||
v-else
|
||||
:id="getElementId(props.id, 'bookmark-loading')"
|
||||
v-g-tooltip.hover
|
||||
class="inline-icon-button"
|
||||
transparent
|
||||
icon-only
|
||||
color="blue"
|
||||
:title="localize('Bookmarking...')"
|
||||
disabled>
|
||||
<FontAwesomeIcon :icon="faSpinner" spin fixed-width />
|
||||
</GButton>
|
||||
</slot>
|
||||
|
||||
<slot name="extra-actions">
|
||||
<GDropdown
|
||||
v-if="hasExtraActions"
|
||||
:id="getElementId(props.id, 'extra-actions')"
|
||||
v-g-tooltip.hover
|
||||
right
|
||||
no-caret
|
||||
title="More options"
|
||||
toggle-class="inline-icon-button"
|
||||
variant="link"
|
||||
@show="() => onExtraDropdown(true)"
|
||||
@hide="() => onExtraDropdown(false)">
|
||||
<template v-slot:button-content>
|
||||
<FontAwesomeIcon :icon="faCaretDown" fixed-width />
|
||||
</template>
|
||||
|
||||
<template v-for="ea in props.extraActions" :key="ea.id">
|
||||
<GDropdownItem
|
||||
v-if="ea.visible ?? true"
|
||||
:id="getActionId(props.id, ea.id)"
|
||||
:disabled="ea.disabled"
|
||||
:variant="ea.variant || 'link'"
|
||||
:to="ea.to"
|
||||
:href="ea.href"
|
||||
:title="ea.title"
|
||||
:size="ea.size || 'sm'"
|
||||
:target="ea.externalLink ? '_blank' : undefined"
|
||||
@click="ea.handler && ea.handler()">
|
||||
<FontAwesomeIcon v-if="ea.icon" :icon="ea.icon" fixed-width />
|
||||
{{ localize(ea.label) }}
|
||||
</GDropdownItem>
|
||||
</template>
|
||||
</GDropdown>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -870,8 +1005,82 @@ function onKeyDown(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
.g-card-header-line {
|
||||
// One shared first-row height so the checkbox, title, badges and actions center on the same line.
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.g-card-title-section {
|
||||
min-width: 50%;
|
||||
flex: 1 1 10rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.g-card-header-badges > * {
|
||||
// Keeps the measured badge widths independent of the stacked state.
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.g-card-header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.g-card-header-column {
|
||||
// Title on the left; the badges sit directly under the actions in a right-hand column.
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"title actions"
|
||||
"title badges";
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto 1fr;
|
||||
row-gap: 0.25rem;
|
||||
|
||||
&.g-card-header-selectable {
|
||||
grid-template-areas:
|
||||
"select title actions"
|
||||
"select title badges";
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.g-card-header-select {
|
||||
grid-area: select;
|
||||
}
|
||||
|
||||
.g-card-title-section {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.g-card-header-badges {
|
||||
grid-area: badges;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.g-card-header-actions {
|
||||
grid-area: actions;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
.g-card-header-stacked {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.25rem;
|
||||
|
||||
.g-card-title-section {
|
||||
// Never pushes the actions off the first row, however narrow the card.
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.g-card-header-badges {
|
||||
// Own row below the title and actions, right edge in line with the caret.
|
||||
order: 1;
|
||||
flex-basis: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
|
||||
> * {
|
||||
// A row wider than the card wraps instead of overflowing; a capped width still measures as stacked.
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.g-card-rename {
|
||||
@@ -889,6 +1098,16 @@ function onKeyDown(event: KeyboardEvent) {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.g-card-title-tooltip {
|
||||
// Matches v-g-tooltip, since GTooltip grows to the full name's width
|
||||
max-width: 300px;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
.g-card-title-tooltip-name {
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
}
|
||||
|
||||
.g-card-title-truncate {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
|
||||
import TagsSelectionDialog from "./TagsSelectionDialog.vue";
|
||||
|
||||
vi.mock("@/stores/userTagsStore", () => ({
|
||||
useUserTagsStore: vi.fn(() => ({
|
||||
userTags: [],
|
||||
onNewTagSeen: vi.fn(),
|
||||
onTagUsed: vi.fn(),
|
||||
onMultipleNewTagsSeen: vi.fn(),
|
||||
})),
|
||||
normalizeTag: vi.fn((tag: string) => tag),
|
||||
}));
|
||||
|
||||
describe("TagsSelectionDialog", () => {
|
||||
it("shows the tag suggestions in the dialog body, under the tag input", async () => {
|
||||
const wrapper = mount(TagsSelectionDialog, {
|
||||
props: { show: true, initialTags: ["abc"] },
|
||||
global: getLocalVue(),
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
await wrapper.find(".stateless-tags .toggle-button").trigger("click");
|
||||
await nextTick();
|
||||
|
||||
const dialogBody = wrapper.find("dialog .g-modal-content").element;
|
||||
const input = document.querySelector("input[role='combobox']") as Element;
|
||||
const listbox = document.querySelector("[role='listbox']") as Element;
|
||||
expect(listbox).not.toBeNull();
|
||||
expect(dialogBody.contains(listbox)).toBe(true);
|
||||
expect(dialogBody.contains(input)).toBe(true);
|
||||
expect(input.compareDocumentPosition(listbox) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,6 @@ function resetTags() {
|
||||
ok-disabled-title="Please select at least one tag"
|
||||
@ok="onOk"
|
||||
@cancel="onCancel">
|
||||
<StatelessTags :value="tags" @input="onTagsChange($event)" />
|
||||
<StatelessTags :value="tags" list-in-flow @input="onTagsChange($event)" />
|
||||
</GModal>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chooseHeaderLayout } from "./gCardHeaderLayout";
|
||||
|
||||
const base = { available: 300, gap: 4, titleMin: 160 };
|
||||
|
||||
describe("chooseHeaderLayout", () => {
|
||||
it("keeps one row when the header has no badges", () => {
|
||||
expect(chooseHeaderLayout({ ...base, actions: 400 })).toBe("inline");
|
||||
});
|
||||
|
||||
it("keeps the badges beside the title while a 10rem title still fits", () => {
|
||||
// 20 + 4 + 160 + 4 + 64 + 4 + 44 = 300
|
||||
expect(chooseHeaderLayout({ ...base, select: 20, badges: 64, actions: 44 })).toBe("inline");
|
||||
});
|
||||
|
||||
it("puts the badges under the actions when they only fit in the actions column", () => {
|
||||
// one row needs 301; the column is max(65, 44) = 65 wide and leaves the title 20 + 4 + 160 + 4 + 65 = 253
|
||||
expect(chooseHeaderLayout({ ...base, select: 20, badges: 65, actions: 44 })).toBe("column");
|
||||
// 20 + 4 + 160 + 4 + 112 = 300
|
||||
expect(chooseHeaderLayout({ ...base, select: 20, badges: 112, actions: 44 })).toBe("column");
|
||||
});
|
||||
|
||||
it("gives the badges their own row when the actions column would squeeze the title", () => {
|
||||
expect(chooseHeaderLayout({ ...base, select: 20, badges: 113, actions: 44 })).toBe("stacked");
|
||||
});
|
||||
|
||||
it("sizes the actions column by the wider of the badges and the actions", () => {
|
||||
// 160 + 4 + max(40, 136) = 300
|
||||
expect(chooseHeaderLayout({ ...base, badges: 40, actions: 136 })).toBe("column");
|
||||
expect(chooseHeaderLayout({ ...base, badges: 40, actions: 137 })).toBe("stacked");
|
||||
});
|
||||
|
||||
it("never uses the actions column for a card without actions", () => {
|
||||
// 160 + 4 + 136 = 300
|
||||
expect(chooseHeaderLayout({ ...base, badges: 136 })).toBe("inline");
|
||||
expect(chooseHeaderLayout({ ...base, badges: 137 })).toBe("stacked");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Where a GCard header places its badges:
|
||||
* - "inline": one row of checkbox, title, badges and actions.
|
||||
* - "column": the title on the left, the badges directly under the actions in a right-hand column.
|
||||
* - "stacked": the badges on their own full-width row below the title and actions.
|
||||
*/
|
||||
export type GCardHeaderLayout = "inline" | "column" | "stacked";
|
||||
|
||||
export interface GCardHeaderWidths {
|
||||
/** Width of the header's content box */
|
||||
available: number;
|
||||
/** Gap between the header's items */
|
||||
gap: number;
|
||||
/** Narrowest the title may get */
|
||||
titleMin: number;
|
||||
/** Width of the actions group, undefined when the card has none */
|
||||
actions?: number;
|
||||
/** Width of the badges and indicators, undefined when the card has none */
|
||||
badges?: number;
|
||||
/** Width of the select checkbox, undefined when the card has none */
|
||||
select?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the first layout that keeps the title at least `titleMin` wide. The widths must not depend on the
|
||||
* layout that is currently applied, so applying the result cannot change it.
|
||||
*/
|
||||
export function chooseHeaderLayout(widths: GCardHeaderWidths): GCardHeaderLayout {
|
||||
const { actions, available, badges, gap, select, titleMin } = widths;
|
||||
if (badges === undefined) {
|
||||
return "inline";
|
||||
}
|
||||
const selectWidth = select === undefined ? 0 : select + gap;
|
||||
const actionsWidth = actions === undefined ? 0 : actions + gap;
|
||||
if (selectWidth + titleMin + gap + badges + actionsWidth <= available) {
|
||||
return "inline";
|
||||
}
|
||||
if (actions !== undefined && selectWidth + titleMin + gap + Math.max(badges, actions) <= available) {
|
||||
return "column";
|
||||
}
|
||||
return "stacked";
|
||||
}
|
||||
@@ -28,7 +28,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { currentUser } = storeToRefs(userStore);
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import FormNumber from "./FormNumber.vue";
|
||||
import FormNumberList from "./FormNumberList.vue";
|
||||
|
||||
const localVue = getLocalVue();
|
||||
|
||||
function mountList(value: unknown) {
|
||||
return mount(FormNumberList as object, {
|
||||
propsData: { value, type: "integer" },
|
||||
localVue,
|
||||
});
|
||||
}
|
||||
|
||||
describe("FormNumberList", () => {
|
||||
it("renders a number field per value", () => {
|
||||
const wrapper = mountList([1, 2]);
|
||||
const inputs = wrapper.findAll("input.ui-input");
|
||||
expect(inputs.length).toBe(2);
|
||||
expect((inputs.at(0)!.element as HTMLInputElement).value).toBe("1");
|
||||
expect((inputs.at(1)!.element as HTMLInputElement).value).toBe("2");
|
||||
});
|
||||
|
||||
it("starts with one empty field", () => {
|
||||
const wrapper = mountList(null);
|
||||
expect(wrapper.findAllComponents(FormNumber).length).toBe(1);
|
||||
expect(wrapper.find("[data-description='remove value']").classes()).toContain("g-disabled");
|
||||
});
|
||||
|
||||
it("emits the entered values as numbers", async () => {
|
||||
const wrapper = mountList([1]);
|
||||
await wrapper.find("[data-description='add value']").trigger("click");
|
||||
await wrapper.findAll("input.ui-input").at(1)!.setValue("3");
|
||||
expect(wrapper.emitted("input")!.at(-1)).toEqual([[1, 3]]);
|
||||
});
|
||||
|
||||
it("removes a value", async () => {
|
||||
const wrapper = mountList([1, 2]);
|
||||
await wrapper.findAll("[data-description='remove value']").at(0)!.trigger("click");
|
||||
expect(wrapper.emitted("input")!.at(-1)).toEqual([[2]]);
|
||||
expect(wrapper.findAllComponents(FormNumber).length).toBe(1);
|
||||
});
|
||||
|
||||
it("emits null once every field is empty", async () => {
|
||||
const wrapper = mountList([1]);
|
||||
await wrapper.find("input.ui-input").setValue("");
|
||||
expect(wrapper.emitted("input")!.at(-1)).toEqual([null]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import FormNumber from "./FormNumber.vue";
|
||||
import FormText from "./FormText.vue";
|
||||
import FormValueList from "./FormValueList.vue";
|
||||
|
||||
const localVue = getLocalVue();
|
||||
|
||||
function mountList(value: unknown, type = "integer", extraProps = {}) {
|
||||
return mount(FormValueList as object, {
|
||||
propsData: { value, type, ...extraProps },
|
||||
localVue,
|
||||
});
|
||||
}
|
||||
|
||||
describe("FormValueList", () => {
|
||||
it("renders a number field per value", () => {
|
||||
const wrapper = mountList([1, 2]);
|
||||
const inputs = wrapper.findAll("input.ui-input");
|
||||
expect(inputs.length).toBe(2);
|
||||
expect((inputs.at(0)!.element as HTMLInputElement).value).toBe("1");
|
||||
expect((inputs.at(1)!.element as HTMLInputElement).value).toBe("2");
|
||||
});
|
||||
|
||||
it("starts with one empty field", () => {
|
||||
const wrapper = mountList(null);
|
||||
expect(wrapper.findAllComponents(FormNumber).length).toBe(1);
|
||||
expect(wrapper.find("[data-description='remove value']").classes()).toContain("g-disabled");
|
||||
});
|
||||
|
||||
it("emits the entered values as numbers", async () => {
|
||||
const wrapper = mountList([1]);
|
||||
await wrapper.find("[data-description='add value']").trigger("click");
|
||||
await wrapper.findAll("input.ui-input").at(1)!.setValue("3");
|
||||
expect(wrapper.emitted("input")!.at(-1)).toEqual([[1, 3]]);
|
||||
});
|
||||
|
||||
it("removes a value", async () => {
|
||||
const wrapper = mountList([1, 2]);
|
||||
await wrapper.findAll("[data-description='remove value']").at(0)!.trigger("click");
|
||||
expect(wrapper.emitted("input")!.at(-1)).toEqual([[2]]);
|
||||
expect(wrapper.findAllComponents(FormNumber).length).toBe(1);
|
||||
});
|
||||
|
||||
it("emits null once every field is empty", async () => {
|
||||
const wrapper = mountList([1]);
|
||||
await wrapper.find("input.ui-input").setValue("");
|
||||
expect(wrapper.emitted("input")!.at(-1)).toEqual([null]);
|
||||
});
|
||||
|
||||
it("renders a text field per text value", () => {
|
||||
const wrapper = mountList(["a", "b"], "text");
|
||||
expect(wrapper.findAllComponents(FormText).length).toBe(2);
|
||||
expect(wrapper.findAllComponents(FormNumber).length).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps commas in a text value", () => {
|
||||
const wrapper = mountList("a,b", "text");
|
||||
const inputs = wrapper.findAll("input.ui-input");
|
||||
expect(inputs.length).toBe(1);
|
||||
expect((inputs.at(0)!.element as HTMLInputElement).value).toBe("a,b");
|
||||
});
|
||||
|
||||
it.each([" a,b\nc ", [" a,b\nc "]])(
|
||||
"preserves newline-separated text default %j as editable rows",
|
||||
async (value) => {
|
||||
const wrapper = mountList(value, "text");
|
||||
const inputs = wrapper.findAll("input.ui-input");
|
||||
expect(inputs.length).toBe(2);
|
||||
expect((inputs.at(0)!.element as HTMLInputElement).value).toBe(" a,b");
|
||||
expect((inputs.at(1)!.element as HTMLInputElement).value).toBe("c ");
|
||||
await inputs.at(1)!.setValue("d");
|
||||
expect(wrapper.emitted("input")!.at(-1)).toEqual([[" a,b", "d"]]);
|
||||
},
|
||||
);
|
||||
|
||||
it("emits the entered text values as strings", async () => {
|
||||
const wrapper = mountList("a", "text");
|
||||
await wrapper.find("[data-description='add value']").trigger("click");
|
||||
await wrapper.findAll("input.ui-input").at(1)!.setValue("2");
|
||||
expect(wrapper.emitted("input")!.at(-1)).toEqual([["a", "2"]]);
|
||||
});
|
||||
|
||||
it("offers text suggestions on every row", () => {
|
||||
const datalist = [
|
||||
{ label: "First", value: "a" },
|
||||
{ label: "Second", value: "b" },
|
||||
];
|
||||
const wrapper = mountList(["a", "b"], "text", { id: "field", datalist });
|
||||
const inputs = wrapper.findAll("input.ui-input");
|
||||
const listIds = [inputs.at(0)!.attributes("list"), inputs.at(1)!.attributes("list")];
|
||||
expect(new Set(listIds).size).toBe(2);
|
||||
for (const listId of listIds) {
|
||||
const options = wrapper.findAll(`datalist[id='${listId}'] option`);
|
||||
expect(options.length).toBe(2);
|
||||
expect(options.at(0)!.attributes("value")).toBe("a");
|
||||
expect(options.at(1)!.attributes("value")).toBe("b");
|
||||
}
|
||||
});
|
||||
});
|
||||
+44
-18
@@ -1,28 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { faPlus, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { ref, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import FormNumber from "./FormNumber.vue";
|
||||
import FormText from "./FormText.vue";
|
||||
import GButton from "@/components/BaseComponents/GButton.vue";
|
||||
|
||||
type NumberValue = number | string;
|
||||
type FieldValue = number | string;
|
||||
|
||||
interface DatalistItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
key: number;
|
||||
value: NumberValue;
|
||||
value: FieldValue;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string;
|
||||
value?: NumberValue | NumberValue[] | null;
|
||||
type: "integer" | "float";
|
||||
value?: FieldValue | FieldValue[] | null;
|
||||
type: "integer" | "float" | "text";
|
||||
min?: number | null;
|
||||
max?: number | null;
|
||||
placeholder?: string;
|
||||
optional?: boolean;
|
||||
showState?: boolean;
|
||||
datalist?: DatalistItem[];
|
||||
}>(),
|
||||
{
|
||||
id: "",
|
||||
@@ -32,19 +39,26 @@ const props = withDefaults(
|
||||
placeholder: "",
|
||||
optional: false,
|
||||
showState: false,
|
||||
datalist: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "input", value: number[] | null): void;
|
||||
(e: "input", value: FieldValue[] | null): void;
|
||||
}>();
|
||||
|
||||
const isText = computed(() => props.type === "text");
|
||||
|
||||
let nextKey = 0;
|
||||
|
||||
function toValues(value: NumberValue | NumberValue[] | null | undefined): NumberValue[] {
|
||||
function toValues(value: FieldValue | FieldValue[] | null | undefined): FieldValue[] {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return [];
|
||||
}
|
||||
if (isText.value) {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
return values.flatMap((entry) => String(entry).split("\n"));
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
@@ -57,23 +71,24 @@ function toValues(value: NumberValue | NumberValue[] | null | undefined): Number
|
||||
return [value];
|
||||
}
|
||||
|
||||
function toRows(values: NumberValue[]): Row[] {
|
||||
function toRows(values: FieldValue[]): Row[] {
|
||||
const valueRows = values.length ? values : [""];
|
||||
return valueRows.map((value) => ({ key: nextKey++, value }));
|
||||
}
|
||||
|
||||
function toNumbers(values: NumberValue[]): number[] {
|
||||
return values.filter((v) => v !== "" && v !== null && v !== undefined).map((v) => Number(v));
|
||||
function toEmitted(values: FieldValue[]): FieldValue[] {
|
||||
const entered = values.filter((v) => v !== "" && v !== null && v !== undefined);
|
||||
return isText.value ? entered.map((v) => String(v)) : entered.map((v) => Number(v));
|
||||
}
|
||||
|
||||
const rows = ref<Row[]>(toRows(toValues(props.value)));
|
||||
|
||||
function emitValue() {
|
||||
const numbers = toNumbers(rows.value.map((row) => row.value));
|
||||
emit("input", numbers.length ? numbers : null);
|
||||
const values = toEmitted(rows.value.map((row) => row.value));
|
||||
emit("input", values.length ? values : null);
|
||||
}
|
||||
|
||||
function onRowInput(index: number, value: NumberValue) {
|
||||
function onRowInput(index: number, value: FieldValue) {
|
||||
rows.value[index]!.value = value;
|
||||
emitValue();
|
||||
}
|
||||
@@ -90,8 +105,8 @@ function removeRow(index: number) {
|
||||
watch(
|
||||
() => props.value,
|
||||
(newValue) => {
|
||||
const incoming = toNumbers(toValues(newValue));
|
||||
const current = toNumbers(rows.value.map((row) => row.value));
|
||||
const incoming = toEmitted(toValues(newValue));
|
||||
const current = toEmitted(rows.value.map((row) => row.value));
|
||||
if (JSON.stringify(incoming) !== JSON.stringify(current)) {
|
||||
rows.value = toRows(incoming);
|
||||
}
|
||||
@@ -104,9 +119,20 @@ watch(
|
||||
<div
|
||||
v-for="(row, index) in rows"
|
||||
:key="row.key"
|
||||
class="d-flex align-items-start mb-1"
|
||||
data-description="number list row">
|
||||
class="d-flex align-items-center mb-2"
|
||||
data-description="value list row">
|
||||
<FormText
|
||||
v-if="isText"
|
||||
:id="`${props.id}-${row.key}`"
|
||||
class="flex-grow-1"
|
||||
:value="String(row.value)"
|
||||
:placeholder="props.placeholder"
|
||||
:optional="props.optional"
|
||||
:show-state="props.showState"
|
||||
:datalist="props.datalist"
|
||||
@input="onRowInput(index, $event)" />
|
||||
<FormNumber
|
||||
v-else
|
||||
class="flex-grow-1"
|
||||
:value="row.value"
|
||||
:type="props.type"
|
||||
@@ -117,7 +143,7 @@ watch(
|
||||
:show-state="props.showState"
|
||||
@input="onRowInput(index, $event)" />
|
||||
<GButton
|
||||
class="ml-1"
|
||||
class="ml-2"
|
||||
tooltip
|
||||
title="Remove value"
|
||||
color="blue"
|
||||
@@ -6,8 +6,8 @@ import { sanitizeHtml } from "@/directives/sanitizeHtml";
|
||||
|
||||
import FormHidden from "./Elements/FormHidden.vue";
|
||||
import FormNumber from "./Elements/FormNumber.vue";
|
||||
import FormNumberList from "./Elements/FormNumberList.vue";
|
||||
import FormText from "./Elements/FormText.vue";
|
||||
import FormValueList from "./Elements/FormValueList.vue";
|
||||
import FormElement from "./FormElement.vue";
|
||||
|
||||
const localVue = getLocalVue();
|
||||
@@ -88,16 +88,30 @@ describe("FormElement", () => {
|
||||
expect(wrapper.findComponent(FormText).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("displays a multiple integer as a list of number fields", async () => {
|
||||
await wrapper.setProps({ type: "integer", value: [1, 2], workflowRun: true, attributes: { multiple: true } });
|
||||
expect(wrapper.findComponent(FormNumberList).exists()).toBe(true);
|
||||
it.each(["integer", "float"])("displays a multiple %s as a list of number fields", async (type) => {
|
||||
await wrapper.setProps({ type, value: [1, 2], workflowRun: true, attributes: { multiple: true } });
|
||||
expect(wrapper.findComponent(FormValueList).exists()).toBe(true);
|
||||
expect(wrapper.findAllComponents(FormNumber).length).toBe(2);
|
||||
|
||||
await wrapper.setProps({ value: 1, attributes: { multiple: false } });
|
||||
expect(wrapper.findComponent(FormNumberList).exists()).toBe(false);
|
||||
expect(wrapper.findComponent(FormValueList).exists()).toBe(false);
|
||||
expect(wrapper.findAllComponents(FormNumber).length).toBe(1);
|
||||
});
|
||||
|
||||
it("displays a multiple text as a list of text fields", async () => {
|
||||
await wrapper.setProps({ type: "text", value: ["a", "b"], workflowRun: true, attributes: { multiple: true } });
|
||||
expect(wrapper.findComponent(FormValueList).exists()).toBe(true);
|
||||
expect(wrapper.findAllComponents(FormText).length).toBe(2);
|
||||
|
||||
const datalist = [{ label: "First", value: "a" }];
|
||||
await wrapper.setProps({ attributes: { multiple: true, datalist } });
|
||||
expect(wrapper.findComponent(FormValueList).props("datalist")).toEqual(datalist);
|
||||
|
||||
await wrapper.setProps({ value: "a", attributes: { multiple: false } });
|
||||
expect(wrapper.findComponent(FormValueList).exists()).toBe(false);
|
||||
expect(wrapper.findAllComponents(FormText).length).toBe(1);
|
||||
});
|
||||
|
||||
it("marks required values", async () => {
|
||||
await wrapper.setProps({ type: "text", attributes: { optional: false } });
|
||||
expect(wrapper.find(".ui-form-title-star").exists()).toBe(true);
|
||||
|
||||
@@ -22,13 +22,13 @@ import FormError from "./Elements/FormError.vue";
|
||||
import FormHidden from "./Elements/FormHidden.vue";
|
||||
import FormInput from "./Elements/FormInput.vue";
|
||||
import FormNumber from "./Elements/FormNumber.vue";
|
||||
import FormNumberList from "./Elements/FormNumberList.vue";
|
||||
import FormOptionalText from "./Elements/FormOptionalText.vue";
|
||||
import FormRulesEdit from "./Elements/FormRulesEdit.vue";
|
||||
import FormSelection from "./Elements/FormSelection.vue";
|
||||
import FormTags from "./Elements/FormTags.vue";
|
||||
import FormText from "./Elements/FormText.vue";
|
||||
import FormUpload from "./Elements/FormUpload.vue";
|
||||
import FormValueList from "./Elements/FormValueList.vue";
|
||||
import FormElementHeader from "./FormElementHeader.vue";
|
||||
import FormElementHelpMarkdown from "./FormElementHelpMarkdown.vue";
|
||||
import GButton from "@/components/BaseComponents/GButton.vue";
|
||||
@@ -99,7 +99,11 @@ const collapsibleValue: ComputedRef<FormParameterValue> = computed(() => attrs.v
|
||||
const defaultValue: ComputedRef<FormParameterValue> = computed(() => attrs.value["default_value"]);
|
||||
const connectedValue: FormParameterValue = { __class__: "ConnectedValue" };
|
||||
|
||||
const isMultipleInteger = computed(() => props.type === "integer" && Boolean(attrs.value.multiple));
|
||||
const valueListType = computed(() =>
|
||||
(props.type === "integer" || props.type === "float" || props.type === "text") && attrs.value.multiple
|
||||
? props.type
|
||||
: null,
|
||||
);
|
||||
|
||||
const computedPlaceholder = computed(() => {
|
||||
if (!props.workflowRun) {
|
||||
@@ -374,8 +378,8 @@ const extendedCollectionType = computed<ExtendedCollectionType>(() => {
|
||||
</div>
|
||||
<FormBoolean v-else-if="props.type === 'boolean'" :id="props.id" v-model="currentValue" />
|
||||
<FormHidden v-else-if="isHiddenType" :id="props.id" :value="currentValue" :info="attrs['info']" />
|
||||
<FormNumberList
|
||||
v-else-if="isMultipleInteger"
|
||||
<FormValueList
|
||||
v-else-if="valueListType"
|
||||
:id="props.id"
|
||||
:value="currentValue"
|
||||
:max="attrs.max"
|
||||
@@ -383,7 +387,8 @@ const extendedCollectionType = computed<ExtendedCollectionType>(() => {
|
||||
:placeholder="computedPlaceholder"
|
||||
:optional="isOptional"
|
||||
:show-state="props.workflowRun"
|
||||
type="integer"
|
||||
:type="valueListType"
|
||||
:datalist="attrs.datalist"
|
||||
@input="(v: FormParameterValue) => (currentValue = v)" />
|
||||
<FormNumber
|
||||
v-else-if="props.type === 'integer' || props.type === 'float'"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount, type VueWrapper } from "@vue/test-utils";
|
||||
import flushPromises from "flush-promises";
|
||||
import { createPinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { defineComponent, h } from "vue";
|
||||
|
||||
import { HttpResponse, useServerMock } from "@/api/client/__mocks__";
|
||||
|
||||
import HelpText from "./HelpText.vue";
|
||||
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
const ROWS = 25;
|
||||
const STATES = ["scheduled", "ready", "failed", "cancelled", "new"];
|
||||
|
||||
// Grid rows render one HelpText per invocation state, and each HelpText mounts its HelpTerm eagerly.
|
||||
function mountHelpTexts(uris: string[]) {
|
||||
const Rows = defineComponent({
|
||||
render: () =>
|
||||
h(
|
||||
"div",
|
||||
uris.map((uri) => h(HelpText, { uri, text: uri.split(".").pop() })),
|
||||
),
|
||||
});
|
||||
return mount(Rows as object, { localVue: getLocalVue(), pinia: createPinia(), attachTo: document.body });
|
||||
}
|
||||
|
||||
describe("HelpText", () => {
|
||||
let datatypesRequests: number;
|
||||
let wrapper: VueWrapper | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
datatypesRequests = 0;
|
||||
server.use(
|
||||
http.get("/api/datatypes", ({ response }) => {
|
||||
datatypesRequests++;
|
||||
return response.untyped(HttpResponse.json([]));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = undefined;
|
||||
});
|
||||
|
||||
it("renders invocation state help for many rows without requesting datatypes", async () => {
|
||||
const uris = Array.from({ length: ROWS }, (_, i) => `galaxy.invocations.states.${STATES[i % STATES.length]}`);
|
||||
wrapper = mountHelpTexts(uris);
|
||||
await flushPromises();
|
||||
expect(wrapper.findAll(".help-text")).toHaveLength(ROWS);
|
||||
expect(wrapper.text()).not.toContain("Loading Galaxy help terms");
|
||||
expect(wrapper.text()).toContain("has had all of its jobs scheduled");
|
||||
expect(datatypesRequests).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ import HistoryArchiveSimple from "@/components/History/Archiving/HistoryArchiveS
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
|
||||
const historyStore = useHistoryStore();
|
||||
const { config } = useConfig(true);
|
||||
const { config } = useConfig();
|
||||
const toast = useToast();
|
||||
|
||||
const { hasWritable: hasWritableFileSources } = useFileSources();
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { JobStateSummary } from "./JobStateSummary";
|
||||
|
||||
import CollectionProgress from "./CollectionProgress.vue";
|
||||
|
||||
const localVue = getLocalVue();
|
||||
|
||||
describe("CollectionProgress", () => {
|
||||
let wrapper;
|
||||
|
||||
it("should display the correct number of items", async () => {
|
||||
const dsc = { job_state_summary: { all_jobs: 3, running: 3 }, populated_state: {} };
|
||||
const jobStateSummary = new JobStateSummary(dsc);
|
||||
wrapper = mount(CollectionProgress, {
|
||||
props: {
|
||||
summary: jobStateSummary,
|
||||
},
|
||||
global: localVue,
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find(".progress").find(".bg-warning").attributes("aria-valuenow")).toBe("3");
|
||||
});
|
||||
|
||||
it("should correctly display states", async () => {
|
||||
const dsc = { job_state_summary: { all_jobs: 5, running: 3, failed: 1, ok: 1 }, populated_state: {} };
|
||||
const jobStateSummary = new JobStateSummary(dsc);
|
||||
wrapper = mount(CollectionProgress, {
|
||||
props: {
|
||||
summary: jobStateSummary,
|
||||
},
|
||||
global: localVue,
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find(".progress").find(".bg-warning").attributes("aria-valuenow")).toBe("3");
|
||||
expect(wrapper.find(".progress").find(".bg-success").attributes("aria-valuenow")).toBe("1");
|
||||
expect(wrapper.find(".progress").find(".bg-danger").attributes("aria-valuenow")).toBe("1");
|
||||
});
|
||||
|
||||
it("should update as dataset states change", async () => {
|
||||
const dsc = { job_state_summary: { all_jobs: 3, running: 3 }, populated_state: {} };
|
||||
let jobStateSummary = new JobStateSummary(dsc);
|
||||
wrapper = mount(CollectionProgress, {
|
||||
props: {
|
||||
summary: jobStateSummary,
|
||||
},
|
||||
global: localVue,
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find(".progress").find(".bg-warning").attributes("aria-valuenow")).toBe("3");
|
||||
dsc["job_state_summary"]["ok"] = 2;
|
||||
dsc["job_state_summary"]["running"] = 1;
|
||||
jobStateSummary = new JobStateSummary(dsc);
|
||||
await wrapper.setProps({ summary: jobStateSummary });
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find(".progress").find(".bg-warning").attributes("aria-valuenow")).toBe("1");
|
||||
expect(wrapper.find(".progress").find(".bg-success").attributes("aria-valuenow")).toBe("2");
|
||||
});
|
||||
|
||||
it("should be visible when all jobs are queued", async () => {
|
||||
const dsc = { job_state_summary: { all_jobs: 3, queued: 3 }, populated_state: {} };
|
||||
const jobStateSummary = new JobStateSummary(dsc);
|
||||
wrapper = mount(CollectionProgress, {
|
||||
props: {
|
||||
summary: jobStateSummary,
|
||||
},
|
||||
global: localVue,
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find(".progress").find(".bg-secondary").attributes("aria-valuenow")).toBe("3");
|
||||
});
|
||||
});
|
||||
+28
-2
@@ -44,7 +44,7 @@ const getHiddenSelection = () => new Map([["FAKE_ID", { visible: false }]]);
|
||||
const getDeletedSelection = () => new Map([["FAKE_ID", { deleted: true }]]);
|
||||
const getActiveSelection = () => new Map([["FAKE_ID", { deleted: false }]]);
|
||||
|
||||
async function mountSelectionOperationsWrapper(config) {
|
||||
async function mountSelectionOperationsWrapper(config, { stubs = {}, attachTo } = {}) {
|
||||
setupMockConfig(config);
|
||||
|
||||
const pinia = createPinia();
|
||||
@@ -58,8 +58,9 @@ async function mountSelectionOperationsWrapper(config) {
|
||||
totalItemsInQuery: 5,
|
||||
isMultiViewItem: false,
|
||||
},
|
||||
global: localVue,
|
||||
global: { ...localVue, stubs: { ...localVue.stubs, ...stubs } },
|
||||
pinia,
|
||||
attachTo,
|
||||
});
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
@@ -366,4 +367,29 @@ describe("History Selection Operations", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tag dialogs", () => {
|
||||
it.each(["add tags", "remove tags"])(
|
||||
"%s: shows the tag suggestions in the dialog body, above Cancel and Ok",
|
||||
async (menuItem) => {
|
||||
wrapper = await mountSelectionOperationsWrapper(NO_TASKS_CONFIG, {
|
||||
stubs: { GModal: false, HeadlessMultiselect: false, StatelessTags: false, teleport: false },
|
||||
attachTo: document.body,
|
||||
});
|
||||
wrapper.findComponent(`[data-description="${menuItem}"]`).vm.$emit("click");
|
||||
await flushPromises();
|
||||
|
||||
const dialog = wrapper.find("dialog[open]");
|
||||
await dialog.find(".stateless-tags .toggle-button").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
const listbox = dialog.element.querySelector("[role='listbox']");
|
||||
expect(listbox).not.toBeNull();
|
||||
expect(dialog.find(".g-modal-content").element.contains(listbox)).toBe(true);
|
||||
const confirmButtons = dialog.find("footer .g-modal-confirm-buttons").element;
|
||||
expect(listbox.compareDocumentPosition(confirmButtons) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
wrapper.unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-3
@@ -95,8 +95,12 @@
|
||||
@ok="changeDatatypeOfSelected"
|
||||
@cancel="resetDatatype">
|
||||
<p v-localize>Select a new data type for {{ numSelected }} items:</p>
|
||||
<DatatypesProvider v-slot="{ item: datatypes, loading: loadingDatatypes }">
|
||||
<DatatypesProvider v-slot="{ item: datatypes, loading: loadingDatatypes, error: datatypesError }">
|
||||
<GAlert v-if="datatypesError" show variant="danger">
|
||||
Unable to load datatypes: {{ datatypesError }}
|
||||
</GAlert>
|
||||
<SingleItemSelector
|
||||
v-else
|
||||
collection-name="Data Types"
|
||||
:loading="loadingDatatypes"
|
||||
:items="datatypes"
|
||||
@@ -117,6 +121,7 @@
|
||||
:key="showAddTagsModal"
|
||||
:value="selectedTags"
|
||||
class="tags"
|
||||
list-in-flow
|
||||
@input="(tags) => (selectedTags = tags)" />
|
||||
<GTip class="mt-2" :tips="['Press Enter after typing each tag.']" />
|
||||
</GModal>
|
||||
@@ -133,8 +138,9 @@
|
||||
:key="showRemoveTagsModal"
|
||||
:value="selectedTags"
|
||||
class="tags"
|
||||
list-in-flow
|
||||
@input="(tags) => (selectedTags = tags)" />
|
||||
<GTip :tips="['Press Enter after typing each tag.']" />
|
||||
<GTip class="mt-2" :tips="['Press Enter after typing each tag.']" />
|
||||
</GModal>
|
||||
<StorageOperationWizardModal
|
||||
v-model:show="showStorageOperationModal"
|
||||
@@ -181,6 +187,7 @@ import { useObjectStoreStore } from "@/stores/objectStoreStore";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
|
||||
import StorageOperationWizardModal from "./StorageOperationWizardModal.vue";
|
||||
import GAlert from "@/components/BaseComponents/GAlert.vue";
|
||||
import GDropdown from "@/components/BaseComponents/GDropdown.vue";
|
||||
import GDropdownDivider from "@/components/BaseComponents/GDropdownDivider.vue";
|
||||
import GDropdownItem from "@/components/BaseComponents/GDropdownItem.vue";
|
||||
@@ -193,6 +200,7 @@ import StatelessTags from "@/components/TagsMultiselect/StatelessTags.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GAlert,
|
||||
CollectionCreatorIndex,
|
||||
DbKeyProvider,
|
||||
DatatypesProvider,
|
||||
@@ -216,7 +224,7 @@ export default {
|
||||
totalItemsInQuery: { type: Number, default: 0 },
|
||||
},
|
||||
setup() {
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
const { confirm } = useConfirmDialog();
|
||||
const objectStoreStore = useObjectStoreStore();
|
||||
const { isAnonymous } = useUserStore();
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ const objectStoreStore = useObjectStoreStore();
|
||||
const quotaUsageStore = useQuotaUsageStore();
|
||||
const storageOperationsStore = useStorageOperationsStore();
|
||||
const { loading: objectStoresLoading, loadErrorMessage } = storeToRefs(objectStoreStore);
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
|
||||
const selectedTargetObjectStoreId = ref<string | null>(null);
|
||||
const storagePreview = ref<StorageOperationPreviewResponse | null>(null);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { RegisteredUser } from "@/api";
|
||||
import type { AnyHistoryEntry } from "@/api/histories";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
|
||||
import HistoryCard from "./HistoryCard.vue";
|
||||
|
||||
vi.mock("@/composables/confirmDialog", () => ({
|
||||
useConfirmDialog: () => ({ confirm: vi.fn() }),
|
||||
}));
|
||||
|
||||
const OWNER = "owner";
|
||||
const HISTORY_NAME = "Differential expression of liver samples, batch 2, trimmed and aligned to GRCh38";
|
||||
|
||||
function mountOwnHistoryCard() {
|
||||
const localVue = getLocalVue();
|
||||
useUserStore().setCurrentUser({ id: "user_id", email: "owner@example.org", username: OWNER } as RegisteredUser);
|
||||
const history = {
|
||||
id: "history_id",
|
||||
name: HISTORY_NAME,
|
||||
username: OWNER,
|
||||
deleted: false,
|
||||
purged: false,
|
||||
tags: [],
|
||||
count: 3,
|
||||
update_time: "2026-01-01T00:00:00",
|
||||
} as unknown as AnyHistoryEntry;
|
||||
return mount(HistoryCard as object, {
|
||||
props: { history },
|
||||
global: {
|
||||
...localVue,
|
||||
stubs: { ...localVue.stubs, FontAwesomeIcon: true, HistoryDatasetsBadge: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("HistoryCard", () => {
|
||||
it("shows the name of an owned history as plain text, not as a link", () => {
|
||||
const wrapper = mountOwnHistoryCard();
|
||||
|
||||
expect(wrapper.find("#g-card-title-link-history-history_id").exists()).toBe(false);
|
||||
const title = wrapper.find("#g-card-title-text-history-history_id");
|
||||
expect(title.text()).toBe(HISTORY_NAME);
|
||||
expect(title.element.tagName).toBe("SPAN");
|
||||
expect(title.attributes("title")).toBe(HISTORY_NAME);
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,6 @@ import { useHistoryCardBadges } from "@/components/History/useHistoryCardBadges"
|
||||
import { useHistoryCardIndicators } from "@/components/History/useHistoryCardIndicators";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import ExportRecordDOILink from "@/components/Common/ExportRecordDOILink.vue";
|
||||
import GCard from "@/components/Common/GCard.vue";
|
||||
@@ -134,12 +133,6 @@ const emit = defineEmits<{
|
||||
*/
|
||||
(e: "select", history: AnyHistoryEntry): void;
|
||||
|
||||
/**
|
||||
* Emitted when the history title is clicked
|
||||
* @event titleClick
|
||||
*/
|
||||
(e: "titleClick", history: AnyHistoryEntry["id"]): void;
|
||||
|
||||
/**
|
||||
* Emitted when the rename action is triggered
|
||||
* @event rename
|
||||
@@ -177,32 +170,6 @@ const emit = defineEmits<{
|
||||
(e: "on-history-card-click", history: AnyHistoryEntry, event: Event): void;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Handles clicking on the history title to navigate to the history view
|
||||
* @function onTitleClick
|
||||
*/
|
||||
async function onTitleClick() {
|
||||
if (isMyHistory(props.history)) {
|
||||
await historyStore.setCurrentHistory(props.history.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed property that creates the title configuration for the history card
|
||||
* @returns {Object | string} A string title for an unowned history or a title
|
||||
* configuration with label, tooltip, and click handler
|
||||
*/
|
||||
const historyCardTitle = computed(() => {
|
||||
if (isMyHistory(props.history)) {
|
||||
return {
|
||||
label: props.history.name,
|
||||
title: localize("Click to set as current"),
|
||||
handler: onTitleClick,
|
||||
};
|
||||
}
|
||||
return props.history.name;
|
||||
});
|
||||
|
||||
/**
|
||||
* Get action configurations for the history card
|
||||
*/
|
||||
@@ -259,7 +226,7 @@ function onKeyDown(event: KeyboardEvent) {
|
||||
:id="`history-${history.id}`"
|
||||
:key="history.id"
|
||||
class="history-card"
|
||||
:title="historyCardTitle"
|
||||
:title="history.name"
|
||||
:title-badges="historyCardTitleBadges"
|
||||
:title-n-lines="2"
|
||||
:can-rename-title="!history.deleted && !history.purged && isMyHistory(history)"
|
||||
@@ -278,7 +245,6 @@ function onKeyDown(event: KeyboardEvent) {
|
||||
:update-time="history.update_time"
|
||||
:clickable="props.clickable"
|
||||
:highlighted="props.highlighted"
|
||||
@titleClick="onTitleClick"
|
||||
@rename="emit('rename', history.id, history.name)"
|
||||
@select="isMyHistory(history) && emit('select', history)"
|
||||
@tagsUpdate="(tags: string[]) => onTagsUpdate(history.id, tags)"
|
||||
|
||||
@@ -779,8 +779,8 @@ onMounted(async () => {
|
||||
|
||||
<TagsSelectionDialog
|
||||
:show="showBulkAddTagsModal"
|
||||
:title="`Add tags to ${selectedHistories.length} selected history${
|
||||
selectedHistories.length > 1 ? 's' : ''
|
||||
:title="`Add tags to ${selectedHistories.length} selected ${
|
||||
selectedHistories.length > 1 ? 'histories' : 'history'
|
||||
}`"
|
||||
@cancel="onToggleBulkTags"
|
||||
@ok="onBulkTagsAdd" />
|
||||
|
||||
@@ -150,7 +150,9 @@ describe("History center panel View", () => {
|
||||
expect(wrapper.findAllComponents(ContentItem).length).toBe(10);
|
||||
expect(item.deleted).toBe(false);
|
||||
const dialog = wrapper.findComponent(OperationErrorDialog);
|
||||
expect(dialog.props("operationError").errorMessage.message).toBe("Too Many Requests (429)");
|
||||
expect(dialog.props("operationError").errorMessage.message).toBe(
|
||||
"Too many requests, please wait and try again (429)",
|
||||
);
|
||||
dialog.vm.$emit("hide");
|
||||
await flushPromises();
|
||||
expect(wrapper.findComponent(OperationErrorDialog).exists()).toBe(false);
|
||||
|
||||
@@ -87,13 +87,18 @@ describe("JobInformation/JobInformation.vue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("job messages", async () => {
|
||||
const rendered_link = jobInfoTable.findAll(`#job-messages .job-message`);
|
||||
expect(rendered_link.length).toBe(jobResponse.job_messages.length);
|
||||
for (let i = 0; i < rendered_link.length; i++) {
|
||||
const msg = rendered_link.at(i).text();
|
||||
expect(jobResponse.job_messages.includes(msg));
|
||||
}
|
||||
it("numbers each job message and lists its fields", async () => {
|
||||
const messages = jobInfoTable.findAll("#job-messages .job-message");
|
||||
expect(messages.length).toBe(jobResponse.job_messages.length);
|
||||
messages.forEach((message, index) => {
|
||||
expect(message.text()).toMatch(new RegExp(`^Job Message ${index + 1}:`));
|
||||
const expected = jobResponse.job_messages[index];
|
||||
expect(message.findAll("li").map((field) => field.text())).toEqual([
|
||||
`type: ${expected.type}`,
|
||||
`desc: ${expected.desc}`,
|
||||
`error_level: ${expected.error_level}`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("explains stream read failures and displays structured diagnostics", async () => {
|
||||
|
||||
@@ -5,7 +5,36 @@
|
||||
"tool_stdout": "stdout",
|
||||
"tool_stderr": "stderr",
|
||||
"command_line": "commandline",
|
||||
"job_messages": ["test-msg1", "test-msg2", "test-msg3", "test-msg4"],
|
||||
"job_messages": [
|
||||
{
|
||||
"type": "regex",
|
||||
"desc": "test-msg1",
|
||||
"error_level": 1,
|
||||
"match": null,
|
||||
"stream": null
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"desc": "test-msg2",
|
||||
"error_level": 1,
|
||||
"match": null,
|
||||
"stream": null
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"desc": "test-msg3",
|
||||
"error_level": 1,
|
||||
"match": null,
|
||||
"stream": null
|
||||
},
|
||||
{
|
||||
"type": "regex",
|
||||
"desc": "test-msg4",
|
||||
"error_level": 1,
|
||||
"match": null,
|
||||
"stream": null
|
||||
}
|
||||
],
|
||||
"id": "test_id",
|
||||
"copied_from_job_id": "test_copied_from_job_id",
|
||||
"state": "running"
|
||||
|
||||
+30
-2
@@ -34,6 +34,11 @@ const mockDatatypesProvider = {
|
||||
return () => slots.default?.({ loading: false, item: ["xml"] });
|
||||
},
|
||||
};
|
||||
const mockFailedDatatypesProvider = {
|
||||
setup(_props, { slots }) {
|
||||
return () => slots.default?.({ loading: false, item: [], error: "unavailable" });
|
||||
},
|
||||
};
|
||||
const mockDbKeyProvider = {
|
||||
setup(_props, { slots }) {
|
||||
return () => slots.default?.({ loading: false, item: ["?"] });
|
||||
@@ -65,7 +70,13 @@ const UNRESTRICTED_MESSAGE = '[data-test-id="unrestricted-msg"]';
|
||||
const DATASET_TABLE = '[data-test-id="dataset-table"]';
|
||||
const PEEK_VIEW = '[data-test-id="peek-view"]';
|
||||
|
||||
async function mountLibraryDatasetWrapper(localVue, router, expectDatasetId, isAdmin = false) {
|
||||
async function mountLibraryDatasetWrapper(
|
||||
localVue,
|
||||
router,
|
||||
expectDatasetId,
|
||||
isAdmin = false,
|
||||
datatypesProvider = mockDatatypesProvider,
|
||||
) {
|
||||
const pinia = createPinia();
|
||||
const propsData = {
|
||||
dataset_id: expectDatasetId,
|
||||
@@ -76,7 +87,7 @@ async function mountLibraryDatasetWrapper(localVue, router, expectDatasetId, isA
|
||||
router,
|
||||
propsData,
|
||||
stubs: {
|
||||
DatatypesProvider: mockDatatypesProvider,
|
||||
DatatypesProvider: datatypesProvider,
|
||||
DbKeyProvider: mockDbKeyProvider,
|
||||
},
|
||||
pinia,
|
||||
@@ -168,4 +179,21 @@ describe("Libraries/LibraryFolder/LibraryFolderDataset/LibraryDataset.vue", () =
|
||||
|
||||
expect(wrapper.find(DATASET_TABLE).html()).toContain("<input");
|
||||
});
|
||||
|
||||
it("shows the current datatype and a load error when datatypes fail to load", async () => {
|
||||
const wrapper = await mountLibraryDatasetWrapper(
|
||||
localVue,
|
||||
router,
|
||||
UNRESTRICTED_DATASET_ID,
|
||||
false,
|
||||
mockFailedDatatypesProvider,
|
||||
);
|
||||
await wrapper.find(MODIFY_BUTTON).trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
const error = wrapper.find('[data-test-id="datatypes-error"]');
|
||||
expect(error.exists()).toBe(true);
|
||||
expect(error.text()).toContain(EXPECTED_DATASET_DATA.file_ext);
|
||||
expect(error.text()).toContain("Unable to load datatypes");
|
||||
});
|
||||
});
|
||||
|
||||
+6
-1
@@ -83,8 +83,13 @@
|
||||
:value="row.item.value" />
|
||||
<DatatypesProvider
|
||||
v-else-if="row.item.name === fieldTitles.file_ext"
|
||||
v-slot="{ item: datatypes, loading: loadingDatatypes }">
|
||||
v-slot="{ item: datatypes, loading: loadingDatatypes, error: datatypesError }">
|
||||
<span v-if="datatypesError" data-test-id="datatypes-error">
|
||||
{{ dataset.file_ext }}
|
||||
<span class="text-danger">(Unable to load datatypes: {{ datatypesError }})</span>
|
||||
</span>
|
||||
<SingleItemSelector
|
||||
v-else
|
||||
collection-name="Data Types"
|
||||
:loading="loadingDatatypes"
|
||||
:items="datatypes"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createTestingPinia } from "@pinia/testing";
|
||||
import { getLocalVue, withPlugins } from "@tests/vitest/helpers";
|
||||
import { mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
|
||||
import { useServerMock } from "@/api/client/__mocks__";
|
||||
import type { ShowFullJobResponse } from "@/api/jobs";
|
||||
@@ -9,7 +10,6 @@ import { useJobStore } from "@/stores/jobStore";
|
||||
|
||||
import JobMetrics from "./JobMetrics.vue";
|
||||
import JobParameters from "./JobParameters.vue";
|
||||
import ToolLinkPopover from "@/components/Tool/ToolLinkPopover.vue";
|
||||
|
||||
// Translates only what a test asks for, so the other assertions see the source strings.
|
||||
const translations = vi.hoisted(() => ({}) as Record<string, string>);
|
||||
@@ -28,6 +28,7 @@ let wrapper: VueWrapper | undefined;
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = undefined;
|
||||
document.body.innerHTML = "";
|
||||
Object.keys(translations).forEach((text) => delete translations[text]);
|
||||
});
|
||||
|
||||
@@ -35,12 +36,13 @@ describe.each([
|
||||
{ name: "JobMetrics", component: JobMetrics },
|
||||
{ name: "JobParameters", component: JobParameters },
|
||||
])("$name", ({ component }) => {
|
||||
function mountElement(toolId?: string) {
|
||||
async function mountElement(toolId?: string) {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn });
|
||||
// The testing store stubs fetchJob, so stand in for the job it would have loaded.
|
||||
// @ts-expect-error: getters are only writable on a testing store
|
||||
useJobStore(pinia).getJob = () => (toolId ? ({ tool_id: toolId } as ShowFullJobResponse) : null);
|
||||
wrapper = mount(component as object, {
|
||||
attachTo: document.body,
|
||||
props: { jobId: "job_id", title: "Job" },
|
||||
global: {
|
||||
...withPlugins(localVue, pinia),
|
||||
@@ -51,34 +53,59 @@ describe.each([
|
||||
JobMetrics: true,
|
||||
JobParameters: true,
|
||||
JobSelection: true,
|
||||
ToolLinkPopover: true,
|
||||
// Fetches the tool's name, which these tests don't need.
|
||||
ToolLink: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
// The popover binds to its button on the tick after mounting.
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
it("anchors the tool popover to a named button so keyboard users can open it", () => {
|
||||
const element = mountElement("cat1");
|
||||
it("renders the tool details button inline after the title, controlling the tool dialog", async () => {
|
||||
const element = await mountElement("cat1");
|
||||
|
||||
const button = element.find("button[aria-label='Tool details']");
|
||||
const popover = element.findComponent(ToolLinkPopover);
|
||||
const target = (popover.props("target") as () => { $el?: Element })();
|
||||
const title = element.find(".card-title").element;
|
||||
const button = element.find("button[aria-label='Tool details']").element as HTMLButtonElement;
|
||||
const dialog = document.body.querySelector(".popover[role='dialog']");
|
||||
|
||||
expect(button.exists()).toBe(true);
|
||||
expect(target.$el ?? target).toBe(button.element);
|
||||
expect(popover.props("interactive")).toBe(true);
|
||||
expect(button.previousElementSibling?.outerHTML).toBe("<b>Job</b>");
|
||||
expect(button.parentElement).toBe(title);
|
||||
expect(button.getAttribute("type")).toBe("button");
|
||||
expect(button.classList.value).toBe("g-link g-dark g-thin");
|
||||
expect(button.querySelector("svg")?.classList.value).toBe("svg-inline--fa fa-info-circle fa-sm");
|
||||
expect(button.getAttribute("aria-haspopup")).toBe("dialog");
|
||||
expect(button.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(button.getAttribute("aria-controls")).toBe(dialog?.id);
|
||||
expect(dialog?.getAttribute("aria-label")).toBe("Tool");
|
||||
expect(button.nextElementSibling?.className).toBe("g-popover-host");
|
||||
expect(button.nextElementSibling?.nextElementSibling).toBeNull();
|
||||
});
|
||||
|
||||
it("localizes the tool details button's accessible name", () => {
|
||||
it("opens the tool dialog when the button gets keyboard focus", async () => {
|
||||
const element = await mountElement("cat1");
|
||||
const button = element.find("button[aria-label='Tool details']").element as HTMLButtonElement;
|
||||
|
||||
button.focus();
|
||||
await nextTick();
|
||||
|
||||
expect(button.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("localizes the tool details button's accessible name", async () => {
|
||||
translations["Tool details"] = "Werkzeugdetails";
|
||||
|
||||
expect(mountElement("cat1").find("button[aria-label='Werkzeugdetails']").exists()).toBe(true);
|
||||
const element = await mountElement("cat1");
|
||||
|
||||
expect(element.find("button[aria-label='Werkzeugdetails']").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows no tool details button until the job's tool is known", () => {
|
||||
const element = mountElement();
|
||||
it("shows no tool details button or dialog until the job's tool is known", async () => {
|
||||
const element = await mountElement();
|
||||
|
||||
expect(element.find("button[aria-label='Tool details']").exists()).toBe(false);
|
||||
expect(document.body.querySelector(".popover")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BCard, BCardFooter, BCardTitle } from "bootstrap-vue";
|
||||
import { computed, toRef, watch } from "vue";
|
||||
|
||||
import { useJobStore } from "@/stores/jobStore";
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import { useMappingJobs } from "./handlesMappingJobs";
|
||||
|
||||
import JobSelection from "./JobSelection.vue";
|
||||
import GLink from "@/components/BaseComponents/GLink.vue";
|
||||
import JobMetrics from "@/components/JobMetrics/JobMetrics.vue";
|
||||
import ToolLinkPopover from "@/components/Tool/ToolLinkPopover.vue";
|
||||
import ToolDetailsButton from "@/components/Tool/ToolDetailsButton.vue";
|
||||
|
||||
interface JobMetricsProps {
|
||||
jobId?: string;
|
||||
@@ -67,10 +63,7 @@ watch(
|
||||
<BCard nobody>
|
||||
<BCardTitle v-if="title">
|
||||
<b>{{ title }}</b>
|
||||
<GLink v-if="toolId" ref="info" dark thin type="button" :aria-label="localize('Tool details')">
|
||||
<FontAwesomeIcon :icon="faInfoCircle" size="sm" />
|
||||
</GLink>
|
||||
<ToolLinkPopover interactive :target="() => $refs.info" :tool-id="toolId" :tool-version="toolVersion" />
|
||||
<ToolDetailsButton v-if="toolId" :tool-id="toolId" :tool-version="toolVersion" />
|
||||
</BCardTitle>
|
||||
<JobSelection
|
||||
v-model="selectedJob"
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BCard, BCardFooter, BCardTitle } from "bootstrap-vue";
|
||||
import { computed, toRef, watch } from "vue";
|
||||
|
||||
import { useJobStore } from "@/stores/jobStore";
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import { useMappingJobs } from "./handlesMappingJobs";
|
||||
|
||||
import JobSelection from "./JobSelection.vue";
|
||||
import GLink from "@/components/BaseComponents/GLink.vue";
|
||||
import JobParameters from "@/components/JobParameters/JobParameters.vue";
|
||||
import ToolLinkPopover from "@/components/Tool/ToolLinkPopover.vue";
|
||||
import ToolDetailsButton from "@/components/Tool/ToolDetailsButton.vue";
|
||||
|
||||
interface JobParametersProps {
|
||||
jobId?: string;
|
||||
@@ -69,10 +65,7 @@ watch(
|
||||
<BCard nobody>
|
||||
<BCardTitle v-if="title">
|
||||
<b>{{ title }}</b>
|
||||
<GLink v-if="toolId" ref="info" dark thin type="button" :aria-label="localize('Tool details')">
|
||||
<FontAwesomeIcon :icon="faInfoCircle" size="sm" />
|
||||
</GLink>
|
||||
<ToolLinkPopover interactive :target="() => $refs.info" :tool-id="toolId" :tool-version="toolVersion" />
|
||||
<ToolDetailsButton v-if="toolId" :tool-id="toolId" :tool-version="toolVersion" />
|
||||
</BCardTitle>
|
||||
<JobSelection
|
||||
v-model="selectedJob"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount, shallowMount } from "@vue/test-utils";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import flushPromises from "flush-promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
|
||||
import { HttpResponse, useServerMock } from "@/api/client/__mocks__";
|
||||
|
||||
import MountTarget from "./WorkflowDisplay.vue";
|
||||
import ToolLinkPopover from "@/components/Tool/ToolLinkPopover.vue";
|
||||
import WorkflowStepIcon from "@/components/WorkflowInvocationState/WorkflowStepIcon.vue";
|
||||
|
||||
// Translates only what a test asks for, so the other assertions see the source strings.
|
||||
@@ -21,12 +21,16 @@ const localVue = getLocalVue(true);
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
let getRequests = [];
|
||||
let stepWrappers = [];
|
||||
|
||||
beforeEach(() => {
|
||||
getRequests = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stepWrappers.forEach((wrapper) => wrapper.unmount());
|
||||
stepWrappers = [];
|
||||
document.body.innerHTML = "";
|
||||
Object.keys(translations).forEach((text) => delete translations[text]);
|
||||
});
|
||||
|
||||
@@ -73,19 +77,22 @@ function mountWithSteps(steps) {
|
||||
return HttpResponse.json({ name: "workflow_name", steps });
|
||||
}),
|
||||
);
|
||||
return shallowMount(MountTarget, {
|
||||
const wrapper = mount(MountTarget, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
workflowId: "workflow_id",
|
||||
embedded: false,
|
||||
expanded: false,
|
||||
},
|
||||
// The real GLink, so the assertions see the button users get.
|
||||
global: { ...localVue, stubs: { ...localVue.stubs, GLink: false } },
|
||||
// The real button and popover, so the assertions see the markup users get.
|
||||
global: { ...localVue, stubs: { ...localVue.stubs, ToolLink: true, WorkflowTree: true } },
|
||||
});
|
||||
stepWrappers.push(wrapper);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe("WorkflowDisplay", () => {
|
||||
it("opens a tool step's popover from a named button", async () => {
|
||||
it("renders a tool step's details button before its title, controlling the tool dialog", async () => {
|
||||
const wrapper = mountWithSteps([
|
||||
{ order_index: 0, type: "data_input" },
|
||||
{ order_index: 1, type: "tool", tool_id: "cat1", tool_version: "1.0.0" },
|
||||
@@ -94,13 +101,32 @@ describe("WorkflowDisplay", () => {
|
||||
|
||||
const buttons = wrapper.findAll("button[aria-label='Tool details']");
|
||||
expect(buttons).toHaveLength(1);
|
||||
const button = buttons.at(0);
|
||||
expect(button.attributes("type")).toBe("button");
|
||||
const button = buttons.at(0).element;
|
||||
const dialog = document.body.querySelector(".popover[role='dialog']");
|
||||
expect(button.previousElementSibling).toBeNull();
|
||||
expect(button.getAttribute("type")).toBe("button");
|
||||
expect(button.classList.value).toBe("g-link g-dark g-thin");
|
||||
// The same tool icon as the other step types, so the button is never empty.
|
||||
expect(button.findComponent(WorkflowStepIcon).props("stepType")).toBe("tool");
|
||||
const popover = wrapper.findComponent(ToolLinkPopover);
|
||||
expect(popover.props("target")).toBe(button.attributes("id"));
|
||||
expect(popover.props("interactive")).toBe(true);
|
||||
expect(buttons.at(0).findComponent(WorkflowStepIcon).props("stepType")).toBe("tool");
|
||||
expect(button.querySelector("svg").classList.value).toBe("svg-inline--fa fa-wrench mr-1");
|
||||
expect(button.getAttribute("aria-haspopup")).toBe("dialog");
|
||||
expect(button.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(button.getAttribute("aria-controls")).toBe(dialog.id);
|
||||
expect(dialog.getAttribute("aria-label")).toBe("Tool");
|
||||
expect(dialog.querySelector("tool-link-stub").getAttribute("toolid")).toBe("cat1");
|
||||
expect(button.nextElementSibling.className).toBe("g-popover-host");
|
||||
expect(button.nextElementSibling.nextElementSibling.textContent).toBe("Step 2: Unknown tool");
|
||||
});
|
||||
|
||||
it("opens a tool step's dialog when its button gets keyboard focus", async () => {
|
||||
const wrapper = mountWithSteps([{ order_index: 1, type: "tool", tool_id: "cat1", tool_version: "1.0.0" }]);
|
||||
await flushPromises();
|
||||
const button = wrapper.find("button[aria-label='Tool details']").element;
|
||||
|
||||
button.focus();
|
||||
await nextTick();
|
||||
|
||||
expect(button.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("shows no tool details button for a tool step without a tool", async () => {
|
||||
@@ -109,6 +135,7 @@ describe("WorkflowDisplay", () => {
|
||||
|
||||
expect(wrapper.find("[aria-label='Tool details']").exists()).toBe(false);
|
||||
expect(wrapper.findComponent(WorkflowStepIcon).props("stepType")).toBe("tool");
|
||||
expect(document.body.querySelector(".popover")).toBeNull();
|
||||
});
|
||||
|
||||
it("localizes the tool step button's accessible name", async () => {
|
||||
|
||||
@@ -5,16 +5,14 @@ import { computed, ref, watch } from "vue";
|
||||
|
||||
import { getStepTitle } from "@/components/WorkflowInvocationState/util";
|
||||
import { useUid } from "@/composables/utils/uid";
|
||||
import localize from "@/utils/localization";
|
||||
import { withPrefix } from "@/utils/redirect";
|
||||
import { isEmpty } from "@/utils/utils";
|
||||
|
||||
import WorkflowTree from "./WorkflowTree.vue";
|
||||
import GAlert from "@/components/BaseComponents/GAlert.vue";
|
||||
import GButton from "@/components/BaseComponents/GButton.vue";
|
||||
import GLink from "@/components/BaseComponents/GLink.vue";
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
import ToolLinkPopover from "@/components/Tool/ToolLinkPopover.vue";
|
||||
import ToolDetailsButton from "@/components/Tool/ToolDetailsButton.vue";
|
||||
import WorkflowStepIcon from "@/components/WorkflowInvocationState/WorkflowStepIcon.vue";
|
||||
|
||||
interface WorkflowDisplayProps {
|
||||
@@ -133,22 +131,14 @@ function toolButtonId(orderIndex: number) {
|
||||
<div v-else :class="!expanded && 'content-height'">
|
||||
<div v-if="itemContent !== null">
|
||||
<div v-for="step in itemContent?.steps" :key="step.order_index" class="mb-2">
|
||||
<GLink
|
||||
<ToolDetailsButton
|
||||
v-if="step.type == 'tool' && step.tool_id"
|
||||
:id="toolButtonId(step.order_index)"
|
||||
dark
|
||||
thin
|
||||
type="button"
|
||||
:aria-label="localize('Tool details')">
|
||||
<WorkflowStepIcon step-type="tool" />
|
||||
</GLink>
|
||||
<WorkflowStepIcon v-else-if="step.type" :step-type="step.type" />
|
||||
<ToolLinkPopover
|
||||
v-if="step.type == 'tool' && step.tool_id"
|
||||
interactive
|
||||
:target="toolButtonId(step.order_index)"
|
||||
:tool-id="step.tool_id"
|
||||
:tool-version="step.tool_version" />
|
||||
:tool-version="step.tool_version">
|
||||
<WorkflowStepIcon step-type="tool" />
|
||||
</ToolDetailsButton>
|
||||
<WorkflowStepIcon v-else-if="step.type" :step-type="step.type" />
|
||||
<span>{{ stepTitle(step) }}</span>
|
||||
<WorkflowTree :input="step" :skip-head="true" />
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
const userStore = useUserStore();
|
||||
const { currentUser } = storeToRefs(userStore);
|
||||
|
||||
|
||||
@@ -34,7 +34,10 @@ describe("PageCard", () => {
|
||||
|
||||
const title = wrapper.find(getSelector("title", FAKE_PAGE_SUMMARY.id));
|
||||
expect(title.text()).toBe("My Analysis");
|
||||
expect(title.attributes("title")).toBe("Edit Notebook");
|
||||
// The tooltip links to the title once the title's element is mounted
|
||||
await wrapper.vm.$nextTick();
|
||||
const tooltipId = title.attributes("aria-describedby");
|
||||
expect(wrapper.find(`#${tooltipId}`).text()).toBe("Edit Notebook");
|
||||
|
||||
// Click to emit edit event
|
||||
await title.trigger("click");
|
||||
|
||||
@@ -28,7 +28,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { currentUser } = storeToRefs(useUserStore());
|
||||
const { config, isConfigLoaded } = useConfig(false);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
|
||||
const permissionsChangeRequired = computed(() => {
|
||||
if (isShareableHistoryWithStatus(props.item)) {
|
||||
|
||||
@@ -21,6 +21,7 @@ const TASKS_CONFIG = {
|
||||
const FALLBACK_URL = "http://cow.com/direct_download";
|
||||
const DOWNLOAD_ENDPOINT = "http://cow.com/prepare_download";
|
||||
const STORAGE_REQUEST_ID = "moocow1235";
|
||||
const POLL_DELAY = 200;
|
||||
|
||||
async function mountStsDownloadButtonWrapper(config) {
|
||||
setupMockConfig(config);
|
||||
@@ -85,4 +86,73 @@ describe("StsDownloadButton", () => {
|
||||
await flushPromises();
|
||||
expect(wrapper.find(".fa-spinner").exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ready", true],
|
||||
["not ready", false],
|
||||
])("should not poll or navigate when a %s response arrives after unmount", async (_label, ready) => {
|
||||
const storageRequestId = `in-flight-poll-${ready}`;
|
||||
let readyRequests = 0;
|
||||
let releasePendingPoll;
|
||||
const pendingPoll = new Promise((resolve) => (releasePendingPoll = resolve));
|
||||
server.use(
|
||||
http.untyped.post(DOWNLOAD_ENDPOINT, () => {
|
||||
return HttpResponse.json({ storage_request_id: storageRequestId });
|
||||
}),
|
||||
http.untyped.get(`api/short_term_storage/${storageRequestId}/ready`, async () => {
|
||||
readyRequests += 1;
|
||||
if (readyRequests > 1) {
|
||||
await pendingPoll;
|
||||
return HttpResponse.json(ready);
|
||||
}
|
||||
return HttpResponse.json(false);
|
||||
}),
|
||||
);
|
||||
const wrapper = await mountStsDownloadButtonWrapper(TASKS_CONFIG);
|
||||
const assignSpy = vi.spyOn(window.location, "assign");
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
await wrapper.find("button").trigger("click");
|
||||
await flushPromises();
|
||||
expect(readyRequests).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(POLL_DELAY);
|
||||
await vi.waitFor(() => expect(readyRequests).toBe(2));
|
||||
|
||||
wrapper.unmount();
|
||||
releasePendingPoll();
|
||||
await flushPromises();
|
||||
vi.advanceTimersByTime(POLL_DELAY * 10);
|
||||
await flushPromises();
|
||||
expect(readyRequests).toBe(2);
|
||||
expect(assignSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("should not start polling when the prepare request resolves after unmount", async () => {
|
||||
const storageRequestId = "in-flight-prepare";
|
||||
let readyRequests = 0;
|
||||
let releasePendingPost;
|
||||
const pendingPost = new Promise((resolve) => (releasePendingPost = resolve));
|
||||
server.use(
|
||||
http.untyped.post(DOWNLOAD_ENDPOINT, async () => {
|
||||
await pendingPost;
|
||||
return HttpResponse.json({ storage_request_id: storageRequestId });
|
||||
}),
|
||||
http.untyped.get(`api/short_term_storage/${storageRequestId}/ready`, () => {
|
||||
readyRequests += 1;
|
||||
return HttpResponse.json(false);
|
||||
}),
|
||||
);
|
||||
const wrapper = await mountStsDownloadButtonWrapper(TASKS_CONFIG);
|
||||
|
||||
await wrapper.find("button").trigger("click");
|
||||
wrapper.unmount();
|
||||
releasePendingPost();
|
||||
await flushPromises();
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_DELAY * 2));
|
||||
expect(readyRequests).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
*/
|
||||
import { faDownload, faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import axios, { type AxiosResponse } from "axios";
|
||||
import { computed, onBeforeUnmount, ref } from "vue";
|
||||
import axios from "axios";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { ComponentColor, ComponentSize } from "@/components/BaseComponents/componentVariants";
|
||||
import { useConfig } from "@/composables/config";
|
||||
import { useShortTermStorage } from "@/composables/shortTermStorage";
|
||||
import { useShortTermStorageMonitor } from "@/composables/shortTermStorageMonitor";
|
||||
import { Toast } from "@/composables/toast";
|
||||
import { getAppRoot } from "@/onload/loadConfig";
|
||||
import { withPrefix } from "@/utils/redirect";
|
||||
|
||||
import GButton from "@/components/BaseComponents/GButton.vue";
|
||||
@@ -58,11 +59,13 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
size: "medium",
|
||||
});
|
||||
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
|
||||
const waiting = ref(false);
|
||||
const storageRequestId = ref<string>();
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const { waitForTask, isCompleted, hasFailed, taskStatus } = useShortTermStorageMonitor();
|
||||
const { downloadObjectByRequestId } = useShortTermStorage();
|
||||
|
||||
const canDownload = computed(() => {
|
||||
if (!config.value.enable_celery_tasks) {
|
||||
@@ -71,38 +74,19 @@ const canDownload = computed(() => {
|
||||
return true;
|
||||
});
|
||||
|
||||
function onDownload() {
|
||||
async function onDownload() {
|
||||
if (!config.value.enable_celery_tasks) {
|
||||
window.open(withPrefix(props.fallbackUrl ?? ""));
|
||||
} else {
|
||||
waiting.value = true;
|
||||
axios.post(props.downloadEndpoint, props.postParameters).then(handleInitialize).catch(handleError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInitialize(response: AxiosResponse) {
|
||||
const storageRequestId = response.data.storage_request_id;
|
||||
pollStorageRequestId(storageRequestId);
|
||||
}
|
||||
|
||||
function pollStorageRequestId(storageRequestId: string) {
|
||||
const url = `${getAppRoot()}api/short_term_storage/${storageRequestId}/ready`;
|
||||
axios
|
||||
.get(url)
|
||||
.then((r) => {
|
||||
handlePollResponse(r, storageRequestId);
|
||||
})
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
function handlePollResponse(response: AxiosResponse, storageRequestId: string) {
|
||||
const ready = response.data;
|
||||
if (ready) {
|
||||
const url = `${getAppRoot()}api/short_term_storage/${storageRequestId}`;
|
||||
window.location.assign(url);
|
||||
waiting.value = false;
|
||||
} else {
|
||||
pollAfterDelay(storageRequestId);
|
||||
waiting.value = true;
|
||||
try {
|
||||
const response = await axios.post(props.downloadEndpoint, props.postParameters);
|
||||
const requestId: string = response.data.storage_request_id;
|
||||
storageRequestId.value = requestId;
|
||||
waitForTask(requestId, POLL_DELAY);
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,21 +95,17 @@ function handleError(err: unknown) {
|
||||
waiting.value = false;
|
||||
}
|
||||
|
||||
function clearPollTimeout() {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
watch(isCompleted, (completed) => {
|
||||
if (completed && storageRequestId.value) {
|
||||
downloadObjectByRequestId(storageRequestId.value);
|
||||
waiting.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function pollAfterDelay(storageRequestId: string) {
|
||||
clearPollTimeout();
|
||||
timeout = setTimeout(() => {
|
||||
pollStorageRequestId(storageRequestId);
|
||||
}, POLL_DELAY);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPollTimeout();
|
||||
watch(hasFailed, (failed) => {
|
||||
if (failed) {
|
||||
handleError(taskStatus.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -66,6 +66,11 @@ describe("HeadlessMultiselect", () => {
|
||||
await keyPress(wrapper.find(selectors.input), "Escape");
|
||||
}
|
||||
|
||||
async function nextAnimationFrame() {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
// The options popup is teleported to `#app`, so it's no longer a
|
||||
// descendant of `wrapper.element` -- query the DOM directly for it.
|
||||
function findAllOptions() {
|
||||
@@ -107,6 +112,82 @@ describe("HeadlessMultiselect", () => {
|
||||
const button = wrapper.find(selectors.openButton);
|
||||
expect(button.element).toBe(document.activeElement);
|
||||
});
|
||||
|
||||
it("stays open when opened from the keyboard", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
});
|
||||
|
||||
const input = await open(wrapper);
|
||||
expect(input.element).toBe(document.activeElement);
|
||||
|
||||
// the open button is removed while focused, so focusout fires without a relatedTarget
|
||||
wrapper.element.dispatchEvent(new FocusEvent("focusout", { bubbles: true, relatedTarget: null }));
|
||||
await nextAnimationFrame();
|
||||
|
||||
expect(findAllOptions().length).toBe(sampleOptions.length);
|
||||
await close(wrapper);
|
||||
});
|
||||
|
||||
it("closes when focus moves outside", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
});
|
||||
|
||||
const input = await open(wrapper);
|
||||
(input.element as HTMLInputElement).blur();
|
||||
wrapper.element.dispatchEvent(new FocusEvent("focusout", { bubbles: true, relatedTarget: null }));
|
||||
await nextAnimationFrame();
|
||||
|
||||
expect(findAllOptions().length).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps Escape in the input from reaching a parent dialog", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
});
|
||||
|
||||
const input = await open(wrapper);
|
||||
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
|
||||
input.element.dispatchEvent(event);
|
||||
await nextTick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(findAllOptions().length).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps Escape on an option from reaching a parent dialog", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
});
|
||||
|
||||
await open(wrapper);
|
||||
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
|
||||
nth(findAllOptions(), 0).element.dispatchEvent(event);
|
||||
await nextTick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(findAllOptions().length).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps Escape on the close button from reaching a parent dialog", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
});
|
||||
|
||||
await open(wrapper);
|
||||
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
|
||||
wrapper.find("fieldset button").element.dispatchEvent(event);
|
||||
await nextTick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(wrapper.find(selectors.input).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("while inputting text", () => {
|
||||
@@ -217,6 +298,235 @@ describe("HeadlessMultiselect", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("as a combobox", () => {
|
||||
it("reports the listbox it controls", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
id: "tags",
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
});
|
||||
|
||||
const input = await open(wrapper);
|
||||
expect(input.attributes("role")).toBe("combobox");
|
||||
expect(input.attributes("aria-expanded")).toBe("true");
|
||||
expect(input.attributes("aria-controls")).toBe("tags-options");
|
||||
expect(document.getElementById("tags-options")?.getAttribute("role")).toBe("listbox");
|
||||
await close(wrapper);
|
||||
});
|
||||
|
||||
it("marks only an invalid search value as invalid", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
validator: (value: string) => value !== "invalid",
|
||||
});
|
||||
|
||||
const input = await open(wrapper);
|
||||
await input.setValue("valid");
|
||||
expect(input.attributes("aria-invalid")).not.toBe("true");
|
||||
|
||||
await input.setValue("invalid");
|
||||
expect(input.attributes("aria-invalid")).toBe("true");
|
||||
await close(wrapper);
|
||||
});
|
||||
|
||||
it("points to the highlighted option", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
id: "tags",
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
});
|
||||
|
||||
const input = await open(wrapper);
|
||||
expect(input.attributes("aria-activedescendant")).toBe("tags-option-0");
|
||||
|
||||
await keyPress(input, "ArrowDown");
|
||||
expect(input.attributes("aria-activedescendant")).toBe("tags-option-1");
|
||||
await close(wrapper);
|
||||
});
|
||||
|
||||
it("points to no option when there are none", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
options: [] as string[],
|
||||
selected: [] as string[],
|
||||
});
|
||||
|
||||
const input = await open(wrapper);
|
||||
expect(input.attributes("aria-activedescendant")).toBeUndefined();
|
||||
await close(wrapper);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when pressing Tab on the close button", () => {
|
||||
function tabOnCloseButton(wrapper: ReturnType<typeof mountWithProps>) {
|
||||
const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
|
||||
wrapper.find("fieldset button").element.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
it("moves focus to the first suggestion", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
id: "tags",
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
});
|
||||
await open(wrapper);
|
||||
|
||||
const event = tabOnCloseButton(wrapper);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement?.id).toBe("tags-option-0");
|
||||
await close(wrapper);
|
||||
});
|
||||
|
||||
it("keeps the native Tab when there are no suggestions", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
options: [] as string[],
|
||||
selected: [] as string[],
|
||||
});
|
||||
await open(wrapper);
|
||||
|
||||
const event = tabOnCloseButton(wrapper);
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
await close(wrapper);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with the suggestions in the page flow", () => {
|
||||
it("teleports the suggestions out of the editor by default", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
id: "tags",
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
});
|
||||
await open(wrapper);
|
||||
|
||||
expect(document.getElementById("tags-options")).not.toBeNull();
|
||||
expect(wrapper.find("#tags-options").exists()).toBe(false);
|
||||
await close(wrapper);
|
||||
});
|
||||
|
||||
it("renders the suggestions inside the editor", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
id: "tags",
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
listInFlow: true,
|
||||
});
|
||||
await open(wrapper);
|
||||
|
||||
const listbox = wrapper.find("#tags-options");
|
||||
expect(listbox.exists()).toBe(true);
|
||||
expect(listbox.classes()).toContain("headless-multiselect__options--in-flow");
|
||||
expect(wrapper.findAll(selectors.option)).toHaveLength(sampleOptions.length);
|
||||
await close(wrapper);
|
||||
});
|
||||
|
||||
it("keeps the native Tab on the last suggestion", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
id: "tags",
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
listInFlow: true,
|
||||
});
|
||||
await open(wrapper);
|
||||
|
||||
const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
|
||||
wrapper.find(`#tags-option-${sampleOptions.length - 1}`).element.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
await close(wrapper);
|
||||
});
|
||||
|
||||
it("keeps the native Tab on the close button", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
id: "tags",
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
listInFlow: true,
|
||||
});
|
||||
await open(wrapper);
|
||||
const closeButton = wrapper.find("fieldset button").element as HTMLButtonElement;
|
||||
closeButton.focus();
|
||||
|
||||
const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
|
||||
closeButton.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(document.activeElement).toBe(closeButton);
|
||||
await close(wrapper);
|
||||
});
|
||||
|
||||
function addOutsideButton() {
|
||||
const button = document.createElement("button");
|
||||
appRoot.appendChild(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
function focusOutTo(wrapper: ReturnType<typeof mountWithProps>, target: HTMLElement) {
|
||||
wrapper.element.dispatchEvent(new FocusEvent("focusout", { bubbles: true, relatedTarget: target }));
|
||||
target.focus();
|
||||
}
|
||||
|
||||
it("keeps the suggestions open when focus leaves (mouse down or Tab)", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
id: "tags",
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
listInFlow: true,
|
||||
});
|
||||
await open(wrapper);
|
||||
|
||||
focusOutTo(wrapper, addOutsideButton());
|
||||
await nextAnimationFrame();
|
||||
|
||||
expect(wrapper.find("#tags-options").exists()).toBe(true);
|
||||
await close(wrapper);
|
||||
});
|
||||
|
||||
it("closes on a click outside only after the click is delivered", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
id: "tags",
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
listInFlow: true,
|
||||
});
|
||||
await open(wrapper);
|
||||
// onClickOutside skips clicks within the same task as the opening click
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
const button = addOutsideButton();
|
||||
let clicked = false;
|
||||
button.addEventListener("click", () => {
|
||||
clicked = true;
|
||||
});
|
||||
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(clicked).toBe(true);
|
||||
expect(wrapper.find("#tags-options").exists()).toBe(true);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
await nextTick();
|
||||
expect(wrapper.find("#tags-options").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("still closes the overlay when focus moves to an outside button", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
id: "tags",
|
||||
options: sampleOptions,
|
||||
selected: [] as string[],
|
||||
});
|
||||
await open(wrapper);
|
||||
|
||||
focusOutTo(wrapper, addOutsideButton());
|
||||
await nextAnimationFrame();
|
||||
|
||||
expect(document.getElementById("tags-options")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when selecting options", () => {
|
||||
it("selects options via keyboard", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
|
||||
@@ -23,12 +23,15 @@ const props = withDefaults(
|
||||
id?: string;
|
||||
/** adjusts the visual appearance of the search value */
|
||||
validator?: (option: string) => boolean;
|
||||
/** renders the suggestions below the input in the page flow instead of as an overlay */
|
||||
listInFlow?: boolean;
|
||||
}>(),
|
||||
{
|
||||
maxShownOptions: 50,
|
||||
placeholder: "type to search",
|
||||
id: () => useUid("headless-multiselect-").value,
|
||||
validator: () => () => true,
|
||||
listInFlow: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -103,6 +106,12 @@ const trimmedOptions = computed(() => {
|
||||
/** the option which will be added when the `Enter` key is pressed */
|
||||
const highlightedOption = ref(0);
|
||||
|
||||
const activeDescendant = computed(() =>
|
||||
trimmedOptions.value[highlightedOption.value] !== undefined
|
||||
? `${props.id}-option-${highlightedOption.value}`
|
||||
: undefined,
|
||||
);
|
||||
|
||||
watch(
|
||||
() => trimmedSearchValue.value,
|
||||
() => {
|
||||
@@ -183,6 +192,8 @@ function onOptionKey(event: KeyboardEvent, index: number) {
|
||||
} else if (event.key === "ArrowDown") {
|
||||
getOptionWithId(index + 1)?.focus();
|
||||
} else if (event.key === "Escape") {
|
||||
// keep Escape from also cancelling a parent <dialog>
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
}
|
||||
@@ -196,25 +207,41 @@ function onMouseDownInside() {
|
||||
* Closes the popup when focus leaves this component.
|
||||
* Since this component uses a Teleport, it relies on a custom `data-parent-id` attribute
|
||||
* to determine if the element is a child of this component.
|
||||
* In the page flow the list stays open and closes on a click outside instead.
|
||||
*/
|
||||
function onFocusOut(e: FocusEvent) {
|
||||
// closing on mousedown would shift the page under the pointer before the click lands
|
||||
if (props.listInFlow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTarget = e.relatedTarget as HTMLElement | null;
|
||||
|
||||
// Delay until after click completes
|
||||
requestAnimationFrame(() => {
|
||||
if (!mouseDownInside.value) {
|
||||
if (!newTarget || newTarget.getAttribute("data-parent-id") !== props.id) {
|
||||
close(false);
|
||||
}
|
||||
// a keyboard open removes the focused toggle button (no relatedTarget) before focusing the input
|
||||
if (!mouseDownInside.value && !isOwnElement(newTarget) && !isOwnElement(document.activeElement)) {
|
||||
close(false);
|
||||
}
|
||||
mouseDownInside.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function isOwnElement(element: Element | null) {
|
||||
return element?.getAttribute("data-parent-id") === props.id;
|
||||
}
|
||||
|
||||
/** emulates tab behavior, because options list is teleported to the app layer */
|
||||
function onCloseButtonTab(event: KeyboardEvent) {
|
||||
if (!event.shiftKey) {
|
||||
getOptionWithId(0)?.focus();
|
||||
// in the page flow the options follow the close button, so the native Tab order is right
|
||||
if (props.listInFlow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const first = event.shiftKey ? null : getOptionWithId(0);
|
||||
// without suggestions there is no option to move to, so keep the native Tab
|
||||
if (first) {
|
||||
first.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
@@ -240,6 +267,11 @@ function getNextFocusableElement() {
|
||||
* so this component is not continuous in the DOM
|
||||
*/
|
||||
function onOptionTab(event: KeyboardEvent, index: number) {
|
||||
// in the page flow the options follow the close button, so the native Tab order is right
|
||||
if (props.listInFlow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (index === 0 && event.shiftKey) {
|
||||
closeButton.value?.focus();
|
||||
event.preventDefault();
|
||||
@@ -284,7 +316,13 @@ whenever(isOpen, async () => {
|
||||
onClickOutside(
|
||||
root,
|
||||
() => {
|
||||
if (isOpen.value) {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
}
|
||||
if (props.listInFlow) {
|
||||
// let the click reach its target, e.g. a dialog reading its own bounds, before the layout shrinks
|
||||
setTimeout(() => close(false));
|
||||
} else {
|
||||
close(false);
|
||||
}
|
||||
},
|
||||
@@ -302,23 +340,25 @@ onClickOutside(
|
||||
v-model="searchValue"
|
||||
aria-autocomplete="list"
|
||||
:aria-label="props.placeholder"
|
||||
role="searchbox"
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
type="text"
|
||||
:aria-invalid="props.validator(trimmedSearchValue)"
|
||||
:aria-owns="`${props.id}-options`"
|
||||
:aria-activedescendant="`${props.id}-options`"
|
||||
:aria-invalid="searchValueValid ? undefined : 'true'"
|
||||
aria-expanded="true"
|
||||
:aria-controls="`${props.id}-options`"
|
||||
:aria-activedescendant="activeDescendant"
|
||||
:data-parent-id="props.id"
|
||||
:placeholder="props.placeholder"
|
||||
@keydown.up="onInputUp"
|
||||
@keydown.down="onInputDown"
|
||||
@keydown.enter="onInputEnter"
|
||||
@keydown.escape="close(true)" />
|
||||
@keydown.escape.prevent="close(true)" />
|
||||
<button
|
||||
ref="closeButton"
|
||||
:data-parent-id="props.id"
|
||||
title="close"
|
||||
@click="close(true)"
|
||||
@keydown.escape.prevent="close(true)"
|
||||
@keydown.tab="onCloseButtonTab">
|
||||
<FontAwesomeIcon :icon="faChevronUp" />
|
||||
</button>
|
||||
@@ -328,13 +368,13 @@ onClickOutside(
|
||||
<FontAwesomeIcon :icon="faTags" />
|
||||
</button>
|
||||
|
||||
<Teleport v-if="isOpen" :to="`#${getPopupLayerId()}`">
|
||||
<Teleport v-if="isOpen" :to="`#${getPopupLayerId()}`" :disabled="props.listInFlow">
|
||||
<div
|
||||
:id="`${props.id}-options`"
|
||||
tabindex="-1"
|
||||
aria-expanded="true"
|
||||
role="listbox"
|
||||
class="headless-multiselect__options"
|
||||
:class="{ 'headless-multiselect__options--in-flow': props.listInFlow }"
|
||||
:style="{
|
||||
'--top': `${bounds.top.value}px`,
|
||||
'--left': `${bounds.left.value}px`,
|
||||
@@ -459,6 +499,14 @@ onClickOutside(
|
||||
|
||||
background-color: $white;
|
||||
|
||||
&.headless-multiselect__options--in-flow {
|
||||
position: static;
|
||||
transform: none;
|
||||
width: auto;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.headless-multiselect__option {
|
||||
padding: 0.4rem 0.5rem;
|
||||
display: inline-block;
|
||||
|
||||
@@ -151,6 +151,19 @@ describe("StatelessTags", () => {
|
||||
expect(toastWarning).toHaveBeenCalledWith(expect.any(String), "Invalid Tag");
|
||||
});
|
||||
|
||||
it("shows the suggestions inside the editor when asked to", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
disabled: false,
|
||||
listInFlow: true,
|
||||
});
|
||||
|
||||
wrapper.find(toggleButton).trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find(selectors.multiselect).findAll(selectors.options)).toHaveLength(autocompleteTags.length);
|
||||
});
|
||||
|
||||
it("hides too many tags", async () => {
|
||||
const wrapper = mountWithProps({
|
||||
value: ["tag_1", "tag_2", "tag_3", "tag_4", "tag_5", "tag_6"],
|
||||
|
||||
@@ -22,6 +22,7 @@ interface StatelessTagsProps {
|
||||
maxVisibleTags?: number;
|
||||
placeholder?: string;
|
||||
inline?: boolean;
|
||||
listInFlow?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<StatelessTagsProps>(), {
|
||||
@@ -31,6 +32,7 @@ const props = withDefaults(defineProps<StatelessTagsProps>(), {
|
||||
useToggleLink: true,
|
||||
maxVisibleTags: 5,
|
||||
placeholder: "Add Tags",
|
||||
listInFlow: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -140,6 +142,7 @@ function onTagClicked(tag: string) {
|
||||
:selected="props.value"
|
||||
:placeholder="props.placeholder"
|
||||
:validator="(x) => !!isValid(x)"
|
||||
:list-in-flow="props.listInFlow"
|
||||
@addOption="onAddTag"
|
||||
@input="onInput"
|
||||
@selected="(tag: string) => userTagsStore.onTagUsed(tag)" />
|
||||
|
||||
@@ -84,6 +84,12 @@ describe("Tag", () => {
|
||||
expect(tag.emitted("click")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("names its delete button after the tag", () => {
|
||||
const tag = mountWithProps({ option: "my_tag", editable: true });
|
||||
|
||||
expect(tag.find(".tag-delete-button").attributes("aria-label")).toBe("Remove tag my_tag");
|
||||
});
|
||||
|
||||
it("displays named tags bold", () => {
|
||||
{
|
||||
const wrapper = mountWithProps({ option: "my_tag" });
|
||||
|
||||
@@ -53,6 +53,7 @@ const searched = computed(() => props.option?.toLowerCase() === props.search?.to
|
||||
inline
|
||||
icon-only
|
||||
class="px-1 py-0 tag-delete-button"
|
||||
:aria-label="`Remove tag ${props.option}`"
|
||||
tabindex="-1"
|
||||
@click.prevent.stop="onDelete">
|
||||
<FontAwesomeIcon :icon="faTimes"></FontAwesomeIcon>
|
||||
|
||||
@@ -21,7 +21,7 @@ import GModal from "@/components/BaseComponents/GModal.vue";
|
||||
import ToolSource from "@/components/Tool/ToolSource.vue";
|
||||
|
||||
const { currentUser, isAdmin } = storeToRefs(useUserStore());
|
||||
const { config } = useConfig(true);
|
||||
const { config } = useConfig();
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
|
||||
import ToolDetailsButton from "./ToolDetailsButton.vue";
|
||||
|
||||
// Translates only what a test asks for, so the other assertions see the source strings.
|
||||
const translations = vi.hoisted(() => ({}) as Record<string, string>);
|
||||
vi.mock("@/utils/localization", async (importOriginal) => {
|
||||
const actual = await importOriginal<{ localize: (text: string) => string }>();
|
||||
const localize = (text: string) => translations[text] ?? actual.localize(text);
|
||||
return { ...actual, default: localize, localize };
|
||||
});
|
||||
|
||||
const localVue = getLocalVue();
|
||||
|
||||
let wrapper: VueWrapper | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = undefined;
|
||||
document.body.innerHTML = "";
|
||||
Object.keys(translations).forEach((text) => delete translations[text]);
|
||||
});
|
||||
|
||||
async function mountButton(props: Record<string, unknown> = {}, slots: Record<string, string> = {}) {
|
||||
wrapper = mount(ToolDetailsButton as object, {
|
||||
attachTo: document.body,
|
||||
props: { toolId: "cat1", toolVersion: "1.0.0", ...props },
|
||||
slots,
|
||||
// ToolLink fetches the tool's name, which these tests don't need.
|
||||
global: { ...localVue, stubs: { ...localVue.stubs, ToolLink: true } },
|
||||
});
|
||||
// GPopover binds to its target on the tick after mounting.
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
return wrapper.find("button").element as HTMLButtonElement;
|
||||
}
|
||||
|
||||
// Queried off the document, since the popover relocates out of the wrapper.
|
||||
function popover() {
|
||||
const el = document.body.querySelector<HTMLElement>(".popover");
|
||||
if (!el) {
|
||||
throw new Error("popover element not found");
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("ToolDetailsButton", () => {
|
||||
it("renders a focusable button named for keyboard and screen reader users", async () => {
|
||||
const button = await mountButton();
|
||||
|
||||
expect(button.getAttribute("type")).toBe("button");
|
||||
expect(button.getAttribute("aria-label")).toBe("Tool details");
|
||||
expect(button.classList.value).toBe("g-link g-dark g-thin");
|
||||
expect(button.tabIndex).toBe(0);
|
||||
expect(button.querySelector("svg.fa-info-circle.fa-sm")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("localizes the button's accessible name", async () => {
|
||||
translations["Tool details"] = "Werkzeugdetails";
|
||||
|
||||
const button = await mountButton();
|
||||
|
||||
expect(button.getAttribute("aria-label")).toBe("Werkzeugdetails");
|
||||
});
|
||||
|
||||
it("takes the caller's id and icon", async () => {
|
||||
const button = await mountButton({ id: "step-1" }, { default: "<i class='custom-icon' />" });
|
||||
|
||||
expect(button.id).toBe("step-1");
|
||||
expect(button.querySelector(".custom-icon")).not.toBeNull();
|
||||
expect(button.querySelector("svg.fa-info-circle")).toBeNull();
|
||||
});
|
||||
|
||||
it("controls a tool dialog that sits next to it in the page", async () => {
|
||||
const button = await mountButton();
|
||||
const dialog = popover();
|
||||
|
||||
expect(button.getAttribute("aria-haspopup")).toBe("dialog");
|
||||
expect(button.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(button.getAttribute("aria-controls")).toBe(dialog.id);
|
||||
expect(dialog.getAttribute("role")).toBe("dialog");
|
||||
expect(dialog.getAttribute("aria-label")).toBe("Tool");
|
||||
expect(button.nextElementSibling?.classList.contains("g-popover-host")).toBe(true);
|
||||
});
|
||||
|
||||
it("opens the popover with the tool link on keyboard focus", async () => {
|
||||
const button = await mountButton();
|
||||
|
||||
button.focus();
|
||||
await nextTick();
|
||||
|
||||
expect(button.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(popover().style.display).not.toBe("none");
|
||||
expect(popover().querySelector("tool-link-stub")?.getAttribute("toolid")).toBe("cat1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { ref } from "vue";
|
||||
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import ToolLinkPopover from "./ToolLinkPopover.vue";
|
||||
import GLink from "@/components/BaseComponents/GLink.vue";
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Id of the tool the popover links to
|
||||
*/
|
||||
toolId: string;
|
||||
/**
|
||||
* Id for the button
|
||||
* @default undefined
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Version of the tool the popover links to
|
||||
* @default undefined
|
||||
*/
|
||||
toolVersion?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
id: undefined,
|
||||
toolVersion: undefined,
|
||||
});
|
||||
|
||||
const button = ref<InstanceType<typeof GLink> | null>(null);
|
||||
|
||||
// A stable function, so the popover doesn't rebind to the same button on every render.
|
||||
const popoverTarget = () => button.value;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Two roots, so the button and the popover host sit inline where the caller puts them. -->
|
||||
<GLink :id="id" ref="button" dark thin type="button" :aria-label="localize('Tool details')">
|
||||
<slot>
|
||||
<FontAwesomeIcon :icon="faInfoCircle" size="sm" />
|
||||
</slot>
|
||||
</GLink>
|
||||
|
||||
<ToolLinkPopover interactive :target="popoverTarget" :tool-id="toolId" :tool-version="toolVersion" />
|
||||
</template>
|
||||
@@ -18,7 +18,7 @@ const dataUrl = computed(() => {
|
||||
|
||||
const dataRef = ref<unknown>(null);
|
||||
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
|
||||
urlData({ url: dataUrl.value }).then((data) => {
|
||||
dataRef.value = data;
|
||||
|
||||
@@ -13,7 +13,7 @@ import GAlert from "@/components/BaseComponents/GAlert.vue";
|
||||
import Webhook from "@/components/Common/Webhook.vue";
|
||||
import ToolEntryPoints from "@/components/ToolEntryPoints/ToolEntryPoints.vue";
|
||||
|
||||
const { config } = useConfig(true);
|
||||
const { config } = useConfig();
|
||||
const jobStore = useJobStore();
|
||||
const { latestResponse } = storeToRefs(jobStore);
|
||||
const router = useRouter();
|
||||
|
||||
@@ -49,6 +49,7 @@ describe("Monitor", () => {
|
||||
expect(cells.at(1).text()).toContain("status_0_0");
|
||||
expect(cells.at(3).text()).toContain("name_1 (owner_1)");
|
||||
expect(cells.at(4).text()).toContain("status_1");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("stops polling once unmounted", async () => {
|
||||
@@ -71,4 +72,52 @@ describe("Monitor", () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not resume polling when a request resolves after unmount", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
getInstalledRepositories.mockClear();
|
||||
const wrapper = mount(Monitor);
|
||||
await flushPromises();
|
||||
|
||||
let resolvePending;
|
||||
getInstalledRepositories.mockImplementationOnce(() => new Promise((resolve) => (resolvePending = resolve)));
|
||||
vi.advanceTimersByTime(5000);
|
||||
await flushPromises();
|
||||
expect(getInstalledRepositories).toHaveBeenCalledTimes(2);
|
||||
|
||||
wrapper.unmount();
|
||||
resolvePending([]);
|
||||
await flushPromises();
|
||||
vi.advanceTimersByTime(15000);
|
||||
await flushPromises();
|
||||
expect(getInstalledRepositories).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("stops polling after a request fails, even when the tab is shown again", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const visibility = vi.spyOn(document, "visibilityState", "get");
|
||||
try {
|
||||
getInstalledRepositories.mockClear();
|
||||
getInstalledRepositories.mockRejectedValueOnce(new Error("boom"));
|
||||
const wrapper = mount(Monitor);
|
||||
await flushPromises();
|
||||
expect(wrapper.find(".alert-danger").text()).toContain("boom");
|
||||
|
||||
for (const state of ["hidden", "visible"]) {
|
||||
visibility.mockReturnValue(state);
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}
|
||||
vi.advanceTimersByTime(15000);
|
||||
await flushPromises();
|
||||
expect(getInstalledRepositories).toHaveBeenCalledTimes(1);
|
||||
wrapper.unmount();
|
||||
} finally {
|
||||
visibility.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from "vue";
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
|
||||
import type { TableField } from "@/components/Common/GTable.types";
|
||||
import { Services } from "@/components/Toolshed/services";
|
||||
import { useResourceWatcher } from "@/composables/resourceWatcher";
|
||||
|
||||
import GAlert from "@/components/BaseComponents/GAlert.vue";
|
||||
import GLink from "@/components/BaseComponents/GLink.vue";
|
||||
@@ -45,38 +46,25 @@ const fields: TableField[] = [
|
||||
},
|
||||
];
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const showItems = computed(() => items.value.length > 0);
|
||||
|
||||
const showEmpty = computed(() => !loading.value && items.value.length === 0);
|
||||
|
||||
function schedulePoll() {
|
||||
clearPollTimeout();
|
||||
timeout = setTimeout(() => {
|
||||
load();
|
||||
}, POLL_DELAY);
|
||||
}
|
||||
const { startWatchingResource, dispose } = useResourceWatcher(load, {
|
||||
shortPollingInterval: POLL_DELAY,
|
||||
enableBackgroundPolling: false,
|
||||
});
|
||||
|
||||
function clearPollTimeout() {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
services
|
||||
.getInstalledRepositories({
|
||||
async function load() {
|
||||
try {
|
||||
items.value = await services.getInstalledRepositories({
|
||||
filter: (x: InstallingRepository) => x.status !== "Installed",
|
||||
})
|
||||
.then((repositories: InstallingRepository[]) => {
|
||||
items.value = repositories;
|
||||
loading.value = false;
|
||||
schedulePoll();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
error.value = e;
|
||||
});
|
||||
loading.value = false;
|
||||
} catch (e) {
|
||||
error.value = e;
|
||||
dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function onQuery(q: string) {
|
||||
@@ -89,11 +77,9 @@ function uninstallRepository(repository: InstallingRepository) {
|
||||
});
|
||||
}
|
||||
|
||||
load();
|
||||
startWatchingResource();
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPollTimeout();
|
||||
});
|
||||
onUnmounted(dispose);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -92,7 +92,7 @@ export default {
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
return { config, isConfigLoaded };
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { GalaxyApi } from "@/api";
|
||||
import { getDbKeys } from "@/api/dbKeys";
|
||||
import { memoizeUntilRejected } from "@/utils/sharedPromise";
|
||||
import { errorMessageAsString, rethrowSimple } from "@/utils/simple-error";
|
||||
|
||||
export const AUTO_EXTENSION = {
|
||||
@@ -23,12 +24,6 @@ export const RULES_TYPES = [
|
||||
{ id: "datasets", text: "Datasets" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Local cache.
|
||||
*/
|
||||
let _cachedDatatypes;
|
||||
let _cachedDbKeys;
|
||||
|
||||
/*
|
||||
* Local helper utilities.
|
||||
*/
|
||||
@@ -44,10 +39,7 @@ function dbKeySort(defaultDbKey) {
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDbKeys() {
|
||||
if (_cachedDbKeys) {
|
||||
return _cachedDbKeys;
|
||||
}
|
||||
async function fetchDbKeys() {
|
||||
const dbKeys = await getDbKeys();
|
||||
const dbKeyList = [];
|
||||
for (var key in dbKeys) {
|
||||
@@ -56,17 +48,16 @@ async function loadDbKeys() {
|
||||
text: dbKeys[key][0],
|
||||
});
|
||||
}
|
||||
_cachedDbKeys = dbKeyList;
|
||||
return dbKeyList;
|
||||
}
|
||||
|
||||
async function loadUploadDatatypes() {
|
||||
if (_cachedDatatypes) {
|
||||
return _cachedDatatypes;
|
||||
}
|
||||
const { data: datatypes } = await GalaxyApi().GET("/api/datatypes", {
|
||||
async function fetchUploadDatatypes() {
|
||||
const { data: datatypes, error } = await GalaxyApi().GET("/api/datatypes", {
|
||||
params: { query: { extension_only: false } },
|
||||
});
|
||||
if (error) {
|
||||
rethrowSimple(error);
|
||||
}
|
||||
const listExtensions = [];
|
||||
for (var key in datatypes) {
|
||||
listExtensions.push({
|
||||
@@ -83,10 +74,14 @@ async function loadUploadDatatypes() {
|
||||
var b_text = b.text && b.text.toLowerCase();
|
||||
return a_text > b_text ? 1 : a_text < b_text ? -1 : 0;
|
||||
});
|
||||
_cachedDatatypes = listExtensions;
|
||||
return listExtensions;
|
||||
}
|
||||
|
||||
/** @type {() => Promise<any[]>} */
|
||||
const loadDbKeys = memoizeUntilRejected(fetchDbKeys);
|
||||
/** @type {() => Promise<any[]>} */
|
||||
const loadUploadDatatypes = memoizeUntilRejected(fetchUploadDatatypes);
|
||||
|
||||
/*
|
||||
* Exported utilities.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HttpResponse, useServerMock } from "@/api/client/__mocks__";
|
||||
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
const DATATYPES = [
|
||||
{ extension: "txt", description: "Text", description_url: null, composite_files: null, upload_warning: null },
|
||||
{ extension: "bed", description: "BED", description_url: null, composite_files: null, upload_warning: null },
|
||||
];
|
||||
const GENOMES = [
|
||||
["Human hg38", "hg38"],
|
||||
["Unspecified", "?"],
|
||||
];
|
||||
|
||||
// The module caches results at module scope, so reload it for each test.
|
||||
async function loadUtils() {
|
||||
return await import("./utils");
|
||||
}
|
||||
|
||||
describe("Upload utils caching", () => {
|
||||
let datatypesRequests: number;
|
||||
let dbKeysRequests: number;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
datatypesRequests = 0;
|
||||
dbKeysRequests = 0;
|
||||
server.use(
|
||||
http.get("/api/datatypes", ({ response }) => {
|
||||
datatypesRequests++;
|
||||
return response.untyped(HttpResponse.json(DATATYPES));
|
||||
}),
|
||||
http.get("/api/genomes", ({ response }) => {
|
||||
dbKeysRequests++;
|
||||
return response.untyped(HttpResponse.json(GENOMES));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shares one datatypes request between concurrent callers", async () => {
|
||||
const { getUploadDatatypes, AUTO_EXTENSION } = await loadUtils();
|
||||
const results = await Promise.all(Array.from({ length: 10 }, () => getUploadDatatypes(false, AUTO_EXTENSION)));
|
||||
expect(datatypesRequests).toBe(1);
|
||||
for (const result of results) {
|
||||
expect(result.map((e: { id: string }) => e.id)).toEqual(["auto", "bed", "txt"]);
|
||||
}
|
||||
await getUploadDatatypes(true, AUTO_EXTENSION);
|
||||
expect(datatypesRequests).toBe(1);
|
||||
});
|
||||
|
||||
it("refetches datatypes after a failed request", async () => {
|
||||
server.use(
|
||||
http.get("/api/datatypes", ({ response }) => {
|
||||
datatypesRequests++;
|
||||
return response.untyped(HttpResponse.json({ err_code: 503, err_msg: "Unavailable" }, { status: 503 }));
|
||||
}),
|
||||
);
|
||||
const { getUploadDatatypes, AUTO_EXTENSION } = await loadUtils();
|
||||
const failures = await Promise.allSettled([
|
||||
getUploadDatatypes(true, AUTO_EXTENSION),
|
||||
getUploadDatatypes(true, AUTO_EXTENSION),
|
||||
]);
|
||||
expect(failures.map((f) => f.status)).toEqual(["rejected", "rejected"]);
|
||||
expect(datatypesRequests).toBe(1);
|
||||
|
||||
server.resetHandlers();
|
||||
server.use(
|
||||
http.get("/api/datatypes", ({ response }) => {
|
||||
datatypesRequests++;
|
||||
return response.untyped(HttpResponse.json(DATATYPES));
|
||||
}),
|
||||
);
|
||||
const result = await getUploadDatatypes(true, AUTO_EXTENSION);
|
||||
expect(result.map((e: { id: string }) => e.id)).toEqual(["bed", "txt"]);
|
||||
expect(datatypesRequests).toBe(2);
|
||||
});
|
||||
|
||||
it("shares one genomes request between concurrent callers", async () => {
|
||||
const { getUploadDbKeys } = await loadUtils();
|
||||
const results = await Promise.all(Array.from({ length: 10 }, () => getUploadDbKeys("?")));
|
||||
expect(dbKeysRequests).toBe(1);
|
||||
for (const result of results) {
|
||||
expect(result.map((e: { id: string }) => e.id)).toEqual(["?", "hg38"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("refetches genomes after a failed request", async () => {
|
||||
server.use(
|
||||
http.get("/api/genomes", ({ response }) => {
|
||||
dbKeysRequests++;
|
||||
return response.untyped(HttpResponse.json({ err_code: 500, err_msg: "Error" }, { status: 500 }));
|
||||
}),
|
||||
);
|
||||
const { getUploadDbKeys } = await loadUtils();
|
||||
await expect(getUploadDbKeys("?")).rejects.toBeTruthy();
|
||||
expect(dbKeysRequests).toBe(1);
|
||||
|
||||
server.resetHandlers();
|
||||
server.use(
|
||||
http.get("/api/genomes", ({ response }) => {
|
||||
dbKeysRequests++;
|
||||
return response.untyped(HttpResponse.json(GENOMES));
|
||||
}),
|
||||
);
|
||||
const result = await getUploadDbKeys("?");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(dbKeysRequests).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import GAlert from "@/components/BaseComponents/GAlert.vue";
|
||||
import GButton from "@/components/BaseComponents/GButton.vue";
|
||||
import QuotaUsageSummary from "@/components/User/DiskUsage/Quota/QuotaUsageSummary.vue";
|
||||
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
const userStore = useUserStore();
|
||||
const { currentUser } = storeToRefs(userStore);
|
||||
const quotaUsageStore = useQuotaUsageStore();
|
||||
|
||||
@@ -33,7 +33,7 @@ const props = withDefaults(defineProps<NotificationsPreferencesProps>(), {
|
||||
|
||||
const breadcrumbItems = [{ title: "User Preferences", to: "/user" }, { title: "Notifications Preferences" }];
|
||||
|
||||
const { config } = useConfig(true);
|
||||
const { config } = useConfig();
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
|
||||
@@ -13,7 +13,7 @@ import localize from "@/utils/localization";
|
||||
import GButton from "@/components/BaseComponents/GButton.vue";
|
||||
import BreadcrumbHeading from "@/components/Common/BreadcrumbHeading.vue";
|
||||
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { currentUser } = storeToRefs(userStore);
|
||||
|
||||
@@ -48,7 +48,7 @@ const breadcrumbItems = [{ title: "User Preferences" }];
|
||||
|
||||
const { confirm } = useConfirmDialog();
|
||||
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
|
||||
const objectStoreTemplatesStore = useObjectStoreTemplatesStore();
|
||||
const fileSourceTemplatesStore = useFileSourceTemplatesStore();
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Step } from "@/stores/workflowStepStore";
|
||||
|
||||
import FormPickValue from "./FormPickValue.vue";
|
||||
import FormElement from "@/components/Form/FormElement.vue";
|
||||
import FormSection from "@/components/Workflow/Editor/Forms/FormSection.vue";
|
||||
|
||||
const localVue = getLocalVue();
|
||||
|
||||
@@ -30,10 +31,11 @@ function makeStep(overrides: Partial<Step> = {}): Step {
|
||||
} as Step;
|
||||
}
|
||||
|
||||
function mountPickValue(step?: Step): VueWrapper {
|
||||
function mountPickValue(step?: Step, datatypes?: unknown[]): VueWrapper {
|
||||
return shallowMount(FormPickValue as any, {
|
||||
propsData: {
|
||||
step: step ?? makeStep(),
|
||||
datatypes,
|
||||
},
|
||||
localVue,
|
||||
pinia: createTestingPinia({ createSpy: vi.fn }),
|
||||
@@ -81,6 +83,12 @@ describe("FormPickValue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hides actions that require a job", () => {
|
||||
const wrapper = mountPickValue(undefined, []);
|
||||
|
||||
expect(wrapper.findComponent(FormSection).props("supportsJobBasedActions")).toBe(false);
|
||||
});
|
||||
|
||||
describe("mode changes", () => {
|
||||
it("emits onChange with updated mode", () => {
|
||||
const wrapper = mountPickValue();
|
||||
|
||||
@@ -135,6 +135,7 @@ if (connections) {
|
||||
:step="step"
|
||||
:datatypes="datatypes"
|
||||
:post-job-actions="postJobActions ?? {}"
|
||||
:supports-job-based-actions="false"
|
||||
@onChange="onChangePostJobActions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { shallowMount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import FormSection from "./FormSection.vue";
|
||||
import FormElement from "@/components/Form/FormElement.vue";
|
||||
|
||||
const localVue = getLocalVue();
|
||||
|
||||
function mountFormSection(supportsJobBasedActions?: boolean) {
|
||||
return shallowMount(FormSection as any, {
|
||||
localVue,
|
||||
propsData: {
|
||||
id: 0,
|
||||
nodeInputs: [],
|
||||
nodeOutputs: [{ name: "output" }],
|
||||
step: { id: 0, type: "pick_value" },
|
||||
datatypes: [],
|
||||
postJobActions: {},
|
||||
supportsJobBasedActions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("FormSection", () => {
|
||||
it("shows job-based actions by default", () => {
|
||||
const wrapper = mountFormSection();
|
||||
|
||||
expect(wrapper.findAllComponents(FormElement)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hides job-based actions when the step cannot execute them", () => {
|
||||
const wrapper = mountFormSection(false);
|
||||
|
||||
expect(wrapper.findAllComponents(FormElement)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div v-if="firstOutput">
|
||||
<FormElement
|
||||
v-if="supportsJobBasedActions"
|
||||
:id="emailActionKey"
|
||||
:value="emailActionValue"
|
||||
title="Email notification"
|
||||
@@ -8,6 +9,7 @@
|
||||
help="An email notification will be sent when the job has completed."
|
||||
@input="onInput" />
|
||||
<FormElement
|
||||
v-if="supportsJobBasedActions"
|
||||
:id="deleteActionKey"
|
||||
:value="deleteActionValue"
|
||||
title="Output cleanup"
|
||||
@@ -62,6 +64,10 @@ export default {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
supportsJobBasedActions: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -23,7 +23,7 @@ const props = defineProps<{
|
||||
historyId?: string;
|
||||
}>();
|
||||
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
|
||||
const { confirm } = useConfirmDialog();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useWorkflowCardBadges } from "@/components/Workflow/List/useWorkflowCar
|
||||
import { useWorkflowCardIndicators } from "@/components/Workflow/List/useWorkflowCardIndicators";
|
||||
import { updateWorkflow } from "@/components/Workflow/workflows.services";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import GCard from "@/components/Common/GCard.vue";
|
||||
|
||||
@@ -107,7 +108,7 @@ const { workflowCardBadges, workflowCardTitleBadges } = useWorkflowCardBadges(
|
||||
const workflowCardTitle = computed(() => {
|
||||
return {
|
||||
label: workflow.value.name,
|
||||
title: "Preview Workflow",
|
||||
title: localize("Click to preview workflow"),
|
||||
handler: () => emit("preview", props.workflow.id),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ const emit = defineEmits<{
|
||||
(e: "input", value: OnCompleteAction[]): void;
|
||||
}>();
|
||||
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
const { hasWritable: hasWritableFileSources } = useFileSources({ exclude: ["rdm"] });
|
||||
|
||||
const notificationSystemEnabled = computed(
|
||||
|
||||
@@ -79,7 +79,7 @@ const { currentHistoryId, changingCurrentHistory } = storeToRefs(useHistoryStore
|
||||
const { stateStore } = provideScopedWorkflowStores(props.model.workflowId);
|
||||
const { activeNodeId } = storeToRefs(stateStore);
|
||||
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
const { showPanels } = usePanels();
|
||||
|
||||
const formData = ref<Record<string, any>>({});
|
||||
@@ -617,7 +617,7 @@ onBeforeMount(() => {
|
||||
<div v-if="showRuntimeSettingsPanel" class="workflow-runtime-settings-panel p-3 rounded-bottom">
|
||||
<!-- Send to new history -->
|
||||
<div class="settings-row">
|
||||
<GCheckbox id="send-to-new-history" v-model="sendToNewHistory" toggle>
|
||||
<GCheckbox id="send-to-new-history" v-model="sendToNewHistory" switch>
|
||||
Send results to a new history
|
||||
<HelpText uri="galaxy.workflows.runtimeSettings.sendToNewHistory" info-icon />
|
||||
</GCheckbox>
|
||||
@@ -632,7 +632,7 @@ onBeforeMount(() => {
|
||||
|
||||
<!-- Use cached jobs -->
|
||||
<div class="settings-row">
|
||||
<GCheckbox v-model="useCachedJobs" toggle>
|
||||
<GCheckbox v-model="useCachedJobs" switch>
|
||||
Re-use jobs with identical parameters
|
||||
<HelpText uri="galaxy.workflows.runtimeSettings.useCachedJobs" info-icon />
|
||||
</GCheckbox>
|
||||
@@ -642,7 +642,7 @@ onBeforeMount(() => {
|
||||
<div v-if="isConfigLoaded && config.enable_notification_system" class="settings-row">
|
||||
<GCheckbox
|
||||
v-model="sendNotificationOnComplete"
|
||||
toggle
|
||||
switch
|
||||
data-test-id="send-notification-checkbox">
|
||||
Notify me when complete
|
||||
<HelpText uri="galaxy.workflows.runtimeSettings.sendNotification" info-icon />
|
||||
@@ -651,7 +651,7 @@ onBeforeMount(() => {
|
||||
|
||||
<!-- Export on completion -->
|
||||
<div v-if="hasWritableFileSources" class="settings-row">
|
||||
<GCheckbox :key="exportCheckboxKey" v-model="exportEnabled" toggle>
|
||||
<GCheckbox :key="exportCheckboxKey" v-model="exportEnabled" switch>
|
||||
Export results when complete
|
||||
<HelpText uri="galaxy.workflows.runtimeSettings.exportOnComplete" info-icon />
|
||||
</GCheckbox>
|
||||
@@ -676,7 +676,7 @@ onBeforeMount(() => {
|
||||
<!-- Storage options -->
|
||||
<template v-if="isConfigLoaded && config.object_store_allows_id_selection">
|
||||
<div class="settings-row">
|
||||
<GCheckbox v-model="splitObjectStore" toggle>
|
||||
<GCheckbox v-model="splitObjectStore" switch>
|
||||
Send outputs and intermediate to different storage
|
||||
<HelpText uri="galaxy.workflows.runtimeSettings.splitObjectStore" info-icon />
|
||||
</GCheckbox>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useConfig } from "@/composables/config";
|
||||
import InvocationExportWizard from "@/components/Workflow/Invocation/Export/InvocationExportWizard.vue";
|
||||
import BioComputeObjectExportCard from "@/components/Workflow/Invocation/Export/Plugins/BioComputeObject/BioComputeObjectExportCard.vue";
|
||||
|
||||
const { config } = useConfig(true);
|
||||
const { config } = useConfig();
|
||||
|
||||
defineProps({
|
||||
invocationId: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mapActions, mapState } from "pinia";
|
||||
|
||||
import { useDbKeyStore } from "@/stores/dbKeyStore";
|
||||
import { prependPath } from "@/utils/redirect";
|
||||
import { errorMessageAsString } from "@/utils/simple-error";
|
||||
|
||||
import { useDatatypeStore } from "../../stores/datatypeStore";
|
||||
|
||||
@@ -17,6 +18,7 @@ export const SimpleProviderMixin = {
|
||||
return {
|
||||
loading: false,
|
||||
item: null,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@@ -50,6 +52,7 @@ export const SimpleProviderMixin = {
|
||||
return slotFn({
|
||||
loading: this.loading,
|
||||
item: this.item,
|
||||
error: this.error,
|
||||
save: this.save,
|
||||
result: this.item,
|
||||
});
|
||||
@@ -96,9 +99,14 @@ export const DatatypesProvider = {
|
||||
...mapActions(useDatatypeStore, ["fetchUploadDatatypes"]),
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
let datatypes = this.getUploadDatatypes;
|
||||
if (datatypes == null || datatypes.length == 0) {
|
||||
await this.fetchUploadDatatypes();
|
||||
try {
|
||||
await this.fetchUploadDatatypes();
|
||||
} catch (err) {
|
||||
this.error = errorMessageAsString(err);
|
||||
}
|
||||
datatypes = this.getUploadDatatypes;
|
||||
}
|
||||
this.item = datatypes;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user