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:
John Chilton
2026-10-13 13:12:36 -04:00
278 changed files with 12103 additions and 2143 deletions
+12
View File
@@ -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
+3
View File
@@ -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
+30 -10
View File
@@ -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) {
+1 -1
View File
@@ -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
+143
View File
@@ -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",
});
}
}
+4 -1
View File
@@ -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
View File
@@ -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",
+265 -1
View File
@@ -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");
});
});
});
+147 -65
View File
@@ -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
View File
@@ -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);
}
+1 -1
View File
@@ -20,7 +20,7 @@
"resolvePackageJsonExports": false
},
"vueCompilerOptions": {
"target": 2.7
"target": 3
},
"include": ["./src/**/*.ts", "./src/**/*.vue"]
}
-61
View File
@@ -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
+48
View File
@@ -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.
+4
View File
@@ -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");
});
});
+263 -2
View File
@@ -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");
});
});
});
+433 -214
View File
@@ -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");
}
});
});
@@ -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"
+19 -5
View File
@@ -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);
+10 -5
View File
@@ -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");
});
});
@@ -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();
},
);
});
});
@@ -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();
@@ -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);
});
});
+1 -35
View File
@@ -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"
@@ -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");
});
});
@@ -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>
+1 -1
View File
@@ -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);
});
});
+27 -47
View File
@@ -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>
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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() {
+12 -17
View File
@@ -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.
*/
+110
View File
@@ -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