Skip to content

Commit

Permalink
Merge branch 'release_24.1' into dev
Browse files Browse the repository at this point in the history
  • Loading branch information
jdavcs committed Oct 8, 2024
2 parents 119324f + 87cddaf commit ada740e
Show file tree
Hide file tree
Showing 14 changed files with 294 additions and 44 deletions.
1 change: 1 addition & 0 deletions client/src/components/DataDialog/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export class Services {
/** Populate record data from raw record source **/
getRecord(record) {
const host = `${window.location.protocol}//${window.location.hostname}:${window.location.port}`;
record.extension = record.extension ?? record.file_ext;
record.details = record.extension || record.description;
record.time = record.update_time || record.create_time;
record.isLeaf = this.isDataset(record);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,29 @@ function focusOptionAtIndex(selected: "selected" | "unselected", index: number)
}
}
/** convert array of select options to a map of select labels to select values */
function optionsToLabelMap(options: SelectOption[]): Map<string, SelectValue> {
return new Map(options.map((o) => [o.label, o.value]));
}
function valuesToOptions(values: SelectValue[]): SelectOption[] {
function stringifyObject(value: SelectValue) {
return typeof value === "object" && value !== null ? JSON.stringify(value) : value;
}
const comparableValues = values.map(stringifyObject);
const valueSet = new Set(comparableValues);
const options: SelectOption[] = [];
props.options.forEach((option) => {
if (valueSet.has(stringifyObject(option.value))) {
options.push(option);
}
});
return options;
}
async function selectOption(event: MouseEvent, index: number): Promise<void> {
if (event.shiftKey || event.ctrlKey) {
handleHighlight(event, index, highlightUnselected);
Expand Down Expand Up @@ -168,10 +191,10 @@ async function deselectOption(event: MouseEvent, index: number) {
function selectAll() {
if (highlightUnselected.highlightedIndexes.length > 0) {
const highlightedValues = highlightUnselected.highlightedOptions.map((o) => o.value);
const selectedSet = new Set([...selected.value, ...highlightedValues]);
selected.value = Array.from(selectedSet);
selected.value = [...selected.value, ...highlightedValues];
unselectedOptionsFiltered.value.filter((o) => highlightedValues.includes(o.value));
const highlightedMap = optionsToLabelMap(highlightUnselected.highlightedOptions);
unselectedOptionsFiltered.value.filter((o) => highlightedMap.has(o.label));
} else if (searchValue.value === "") {
selected.value = props.options.map((o) => o.value);
Expand All @@ -188,13 +211,13 @@ function selectAll() {
function deselectAll() {
if (highlightSelected.highlightedIndexes.length > 0) {
const selectedSet = new Set(selected.value);
const highlightedValues = highlightSelected.highlightedOptions.map((o) => o.value);
const selectedMap = optionsToLabelMap(valuesToOptions(selected.value));
const highlightedMap = optionsToLabelMap(highlightSelected.highlightedOptions);
highlightedValues.forEach((v) => selectedSet.delete(v));
selected.value = Array.from(selectedSet);
highlightedMap.forEach((_value, label) => selectedMap.delete(label));
selected.value = Array.from(selectedMap.values());
selectedOptionsFiltered.value.filter((o) => highlightedValues.includes(o.value));
selectedOptionsFiltered.value.filter((o) => highlightedMap.has(o.label));
} else if (searchValue.value === "") {
selected.value = [];
selectedOptionsFiltered.value = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ const showPreferredObjectStoreModal = ref(false);
const historyPreferredObjectStoreId = ref(props.history.preferred_object_store_id);
const niceHistorySize = computed(() => prettyBytes(historySize.value));
const canManageStorage = computed(
() => userOwnsHistory(currentUser.value, props.history) && !currentUser.value?.isAnonymous
);
const storageLocationTitle = computed(() => {
if (isOnlyPreference.value) {
Expand Down Expand Up @@ -137,7 +140,7 @@ onMounted(() => {
variant="link"
size="sm"
class="rounded-0 text-decoration-none history-storage-overview-button"
:disabled="!userOwnsHistory(currentUser, props.history)"
:disabled="!canManageStorage"
data-description="storage dashboard button"
@click="onDashboard">
<FontAwesomeIcon :icon="faDatabase" />
Expand Down
9 changes: 6 additions & 3 deletions client/src/components/History/SwitchToHistoryLink.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useRouter } from "vue-router/composables";
import { type HistorySummary, userOwnsHistory } from "@/api";
import { Toast } from "@/composables/toast";
import { useEventStore } from "@/stores/eventStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useUserStore } from "@/stores/userStore";
import { errorMessageAsString } from "@/utils/simple-error";
Expand Down Expand Up @@ -47,8 +48,10 @@ const actionText = computed(() => {
return "View in new tab";
});
async function onClick(history: HistorySummary) {
if (canSwitch.value) {
async function onClick(event: MouseEvent, history: HistorySummary) {
const eventStore = useEventStore();
const ctrlKey = eventStore.isMac ? event.metaKey : event.ctrlKey;
if (!ctrlKey && canSwitch.value) {
if (props.filters) {
historyStore.applyFilters(history.id, props.filters);
} else {
Expand Down Expand Up @@ -78,7 +81,7 @@ function viewHistoryInNewTab(history: HistorySummary) {
class="truncate"
href="#"
:title="`<b>${actionText}</b><br>${history.name}`"
@click.stop="onClick(history)">
@click.stop="onClick($event, history)">
{{ history.name }}
</BLink>

Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Sharing/UserSharing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ defineExpose({
class="mb-4">
<BFormSelect v-model="selectedSharingOption">
<BFormSelectOption value="make_public"> Make datasets public </BFormSelectOption>
<BFormSelectOption value="make_accessible_and_shared">
<BFormSelectOption value="make_accessible_to_shared">
Make datasets private to me and users this {{ modelClass }} is shared with
</BFormSelectOption>
<BFormSelectOption value="no_changes"> Share {{ modelClass }} anyways </BFormSelectOption>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { createTestingPinia } from "@pinia/testing";
import { shallowMount } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { getLocalVue } from "tests/jest/helpers";

import sampleInvocation from "@/components/Workflow/test/json/invocation.json";
import { useUserStore } from "@/stores/userStore";

import WorkflowInvocationHeader from "./WorkflowInvocationHeader.vue";

// Constants
const WORKFLOW_OWNER = "test-user";
const OTHER_USER = "other-user";
const UNIMPORTABLE_WORKFLOW_ID = "invalid-workflow-id";
const UNIMPORTABLE_WORKFLOW_INSTANCE_ID = "invalid-instance-id";
const SAMPLE_WORKFLOW = {
id: "workflow-id",
name: "workflow-name",
owner: WORKFLOW_OWNER,
version: 1,
};
const IMPORT_ERROR_MESSAGE = "Failed to import workflow";

const SELECTORS = {
INVOKED_WORKFLOW_HEADING: "anonymous-stub[h1='true']",
RETURN_TO_INVOCATIONS_LIST_BUTTON: "bbutton-stub[title='Return to Invocations List']",
ACTIONS_BUTTON_GROUP: "bbuttongroup-stub",
EDIT_WORKFLOW_BUTTON: `bbutton-stub[title='<b>Edit</b><br>${SAMPLE_WORKFLOW.name}']`,
IMPORT_WORKFLOW_BUTTON: "anonymous-stub[title='Import this workflow']",
RUN_WORKFLOW_BUTTON: `anonymous-stub[id='${SAMPLE_WORKFLOW.id}']`,
ALERT_MESSAGE: "balert-stub",
};

// Mock the copyWorkflow function for importing a workflow
jest.mock("components/Workflow/workflows.services", () => ({
copyWorkflow: jest.fn().mockImplementation((workflowId: string) => {
if (workflowId === UNIMPORTABLE_WORKFLOW_ID) {
throw new Error(IMPORT_ERROR_MESSAGE);
}
return SAMPLE_WORKFLOW;
}),
}));

// Mock the workflow store to return the sample workflow
jest.mock("@/stores/workflowStore", () => {
const originalModule = jest.requireActual("@/stores/workflowStore");
return {
...originalModule,
useWorkflowStore: () => ({
...originalModule.useWorkflowStore(),
getStoredWorkflowByInstanceId: jest.fn().mockImplementation((instanceId: string) => {
if (instanceId === UNIMPORTABLE_WORKFLOW_INSTANCE_ID) {
return { ...SAMPLE_WORKFLOW, id: UNIMPORTABLE_WORKFLOW_ID };
}
return SAMPLE_WORKFLOW;
}),
}),
};
});

const localVue = getLocalVue();

/**
* Mounts the WorkflowInvocationHeader component with props/stores adjusted given the parameters
* @param ownsWorkflow Whether the user owns the workflow associated with the invocation
* @param hasReturnBtn Whether the component should have a return to invocations list button
* @param unimportableWorkflow Whether the workflow import should fail
* @returns The wrapper object
*/
async function mountWorkflowInvocationHeader(ownsWorkflow = true, hasReturnBtn = false, unimportableWorkflow = false) {
const wrapper = shallowMount(WorkflowInvocationHeader as object, {
propsData: {
invocation: {
...sampleInvocation,
workflow_id: !unimportableWorkflow ? sampleInvocation.workflow_id : UNIMPORTABLE_WORKFLOW_INSTANCE_ID,
},
fromPanel: !hasReturnBtn,
},
localVue,
pinia: createTestingPinia(),
});

const userStore = useUserStore();
userStore.currentUser = {
id: "1",
email: "[email protected]",
tags_used: [],
isAnonymous: false,
total_disk_usage: 0,
username: ownsWorkflow ? WORKFLOW_OWNER : OTHER_USER,
};

return { wrapper };
}

describe("WorkflowInvocationHeader renders", () => {
// Included both cases in one test because these are always constant
it("(always) the workflow name in header and run button in actions", async () => {
const { wrapper } = await mountWorkflowInvocationHeader();

const heading = wrapper.find(SELECTORS.INVOKED_WORKFLOW_HEADING);
expect(heading.text()).toBe(`Invoked Workflow: "${SAMPLE_WORKFLOW.name}"`);

const actionsGroup = wrapper.find(SELECTORS.ACTIONS_BUTTON_GROUP);
const runButton = actionsGroup.find(SELECTORS.RUN_WORKFLOW_BUTTON);
expect(runButton.attributes("title")).toContain(SAMPLE_WORKFLOW.name);
});

it("return to invocations list button if not from panel", async () => {
const { wrapper } = await mountWorkflowInvocationHeader(false, true);
const returnButton = wrapper.find(SELECTORS.RETURN_TO_INVOCATIONS_LIST_BUTTON);
expect(returnButton.text()).toBe("Invocations List");
});

it("edit button if user owns the workflow", async () => {
const { wrapper } = await mountWorkflowInvocationHeader();
const actionsGroup = wrapper.find(SELECTORS.ACTIONS_BUTTON_GROUP);
const editButton = actionsGroup.find(SELECTORS.EDIT_WORKFLOW_BUTTON);
expect(editButton.attributes("to")).toBe(
`/workflows/edit?id=${SAMPLE_WORKFLOW.id}&version=${SAMPLE_WORKFLOW.version}`
);
});

it("import button instead if user does not own the workflow", async () => {
const { wrapper } = await mountWorkflowInvocationHeader(false);
const actionsGroup = wrapper.find(SELECTORS.ACTIONS_BUTTON_GROUP);
const importButton = actionsGroup.find(SELECTORS.IMPORT_WORKFLOW_BUTTON);
expect(importButton.exists()).toBe(true);
});
});

describe("Importing a workflow in WorkflowInvocationHeader", () => {
it("should show a confirmation dialog when the import is successful", async () => {
const { wrapper } = await mountWorkflowInvocationHeader(false);
const actionsGroup = wrapper.find(SELECTORS.ACTIONS_BUTTON_GROUP);
const importButton = actionsGroup.find(SELECTORS.IMPORT_WORKFLOW_BUTTON);

// Cannot `.trigger("click")` on `AsyncButton` because it is a stubbed custom component
await importButton.props().action();
await flushPromises();

const alert = wrapper.find(SELECTORS.ALERT_MESSAGE);
expect(alert.attributes("variant")).toBe("info");
expect(alert.text()).toContain(`Workflow ${SAMPLE_WORKFLOW.name} imported successfully`);
});

it("should show an error dialog when the import fails", async () => {
const { wrapper } = await mountWorkflowInvocationHeader(false, false, true);
const actionsGroup = wrapper.find(SELECTORS.ACTIONS_BUTTON_GROUP);
const importButton = actionsGroup.find(SELECTORS.IMPORT_WORKFLOW_BUTTON);

// Cannot `.trigger("click")` on `AsyncButton` because it is a stubbed custom component
await importButton.props().action();
await flushPromises();

const alert = wrapper.find(SELECTORS.ALERT_MESSAGE);
expect(alert.attributes("variant")).toBe("danger");
expect(alert.text()).toContain(IMPORT_ERROR_MESSAGE);
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
<script setup lang="ts">
import { faClock } from "@fortawesome/free-regular-svg-icons";
import { faArrowLeft, faEdit, faHdd, faSitemap } from "@fortawesome/free-solid-svg-icons";
import { faArrowLeft, faEdit, faHdd, faSitemap, faUpload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BButton, BButtonGroup } from "bootstrap-vue";
import { BAlert, BButton, BButtonGroup } from "bootstrap-vue";
import { computed, ref } from "vue";
import { RouterLink } from "vue-router";
import type { WorkflowInvocationElementView } from "@/api/invocations";
import { useWorkflowInstance } from "@/composables/useWorkflowInstance";
import { useUserStore } from "@/stores/userStore";
import { Workflow } from "@/stores/workflowStore";

Check failure on line 12 in client/src/components/WorkflowInvocationState/WorkflowInvocationHeader.vue

View workflow job for this annotation

GitHub Actions / client-unit-test (18)

All imports in the declaration are only used as types. Use `import type`
import localize from "@/utils/localization";
import { errorMessageAsString } from "@/utils/simple-error";
import { copyWorkflow } from "../Workflow/workflows.services";
import AsyncButton from "../Common/AsyncButton.vue";
import Heading from "../Common/Heading.vue";
import SwitchToHistoryLink from "../History/SwitchToHistoryLink.vue";
import UtcDate from "../UtcDate.vue";
Expand All @@ -23,13 +31,47 @@ const props = defineProps<Props>();
const { workflow } = useWorkflowInstance(props.invocation.workflow_id);
const userStore = useUserStore();
const owned = computed(() => {
if (userStore.currentUser && workflow.value) {
return userStore.currentUser.username === workflow.value.owner;
} else {
return false;
}
});
const importErrorMessage = ref<string | null>(null);
const importedWorkflow = ref<Workflow | null>(null);
async function onImport() {
if (!workflow.value || !workflow.value.owner) {
return;
}
try {
const wf = await copyWorkflow(workflow.value.id, workflow.value.owner);
importedWorkflow.value = wf as unknown as Workflow;
} catch (error) {
importErrorMessage.value = errorMessageAsString(error, "Failed to import workflow");
}
}
function getWorkflowName(): string {
return workflow.value?.name || "...";
}
</script>

<template>
<div>
<BAlert v-if="importErrorMessage" variant="danger" dismissible show @dismissed="importErrorMessage = null">
{{ importErrorMessage }}
</BAlert>
<BAlert v-else-if="importedWorkflow" variant="info" dismissible show @dismissed="importedWorkflow = null">
<span>
Workflow <b>{{ importedWorkflow.name }}</b> imported successfully.
</span>
<RouterLink to="/workflows/list">Click here</RouterLink> to view the imported workflow in the workflows
list.
</BAlert>
<div class="d-flex flex-gapx-1">
<Heading h1 separator inline truncate size="xl" class="flex-grow-1">
Invoked Workflow: "{{ getWorkflowName() }}"
Expand Down Expand Up @@ -69,6 +111,7 @@ function getWorkflowName(): string {
</div>
<BButtonGroup vertical>
<BButton
v-if="owned"
v-b-tooltip.hover.noninteractive.html
:title="
!workflow.deleted
Expand All @@ -82,6 +125,17 @@ function getWorkflowName(): string {
<FontAwesomeIcon :icon="faEdit" />
<span v-localize>Edit</span>
</BButton>
<AsyncButton
v-else
v-b-tooltip.hover.noninteractive
size="sm"
:disabled="userStore.isAnonymous"
:title="localize('Import this workflow')"
:icon="faUpload"
variant="outline-primary"
:action="onImport">
<span v-localize>Import</span>
</AsyncButton>
<WorkflowRunButton
:id="workflow.id || ''"
:title="
Expand Down
1 change: 1 addition & 0 deletions client/src/stores/workflowStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface Workflow {
latest_id?: string;
version: number;
deleted?: boolean;
owner?: string;
}

export const useWorkflowStore = defineStore("workflowStore", () => {
Expand Down
Loading

0 comments on commit ada740e

Please sign in to comment.