beta history, editing ui fixes

This commit is contained in:
Mason Houtz
2021-08-17 13:37:54 -07:00
parent 7f0519ff20
commit c4951e86ab
55 changed files with 1814 additions and 981 deletions
+2 -1
View File
@@ -29,7 +29,7 @@
"axios": "^0.21.1",
"backbone": "1.4.0",
"bootstrap": "4.5.0",
"bootstrap-vue": "^2.15.0",
"bootstrap-vue": "^2.21.2",
"citation-js": "^0.5.0-alpha.5",
"d3": "3",
"date-fns": "^2.21.1",
@@ -63,6 +63,7 @@
"pouchdb-erase": "^1.0.2",
"pouchdb-find": "^7.2.2",
"pouchdb-upsert": "^2.2.0",
"pretty-bytes": "^5.6.0",
"proper-skip-list": "^4.0.2",
"pyre-to-regexp": "^0.0.5",
"regression": "^2.0.1",
@@ -1,7 +1,7 @@
<template>
<b-input-group v-if="params">
<DebouncedInput v-model.trim="filterText" v-slot="{ value, input }">
<b-form-input size="sm" :value="value" @input="input" :placeholder="'Search Filter' | localize" />
<b-form-input size="sm" :value="value" @input="input" :placeholder="'Content Filter' | localize" />
</DebouncedInput>
<b-input-group-append>
@@ -8,7 +8,7 @@ export default {
const { history_content_type } = this.item;
switch (history_content_type) {
case "dataset":
return "Dataset";
return "Subdataset";
case "dataset_collection":
return "Subcollection";
default:
@@ -11,18 +11,25 @@
import Placeholder from "./Placeholder";
import Dataset from "./Dataset";
import DatasetCollection from "./DatasetCollection";
// import SubDataset from "./SubDataset";
import Subdataset from "./Subdataset";
import Subcollection from "./Subcollection";
import "./styles.scss";
export default {
template: `
<component :is="contentItemComponent"
class="content-item p-1"
:data-ci-type="contentItemComponent"
class="content-item"
:class="{ loading }"
:tabindex="index"
:writable="writable"
v-on="$listeners"
v-bind="bindProps"
@mouseover.native.self.stop="setFocus(index)"
v-bind="$attrs"
:item="item"
:index="index"
:row-key="rowKey"
:writable="writable"
@mouseover.native.stop="setFocus(index)"
@keydown.native.arrow-up.self.stop="setFocus(index - 1)"
@keydown.native.arrow-down.self.stop="setFocus(index + 1)"
/>
@@ -32,13 +39,15 @@ export default {
Placeholder,
Dataset,
DatasetCollection,
// SubDataset,
Subdataset,
Subcollection,
},
props: {
item: { type: Object, required: true },
index: { type: Number, required: true },
index: { type: Number, required: false, default: null },
rowKey: { type: [Number, String], required: false, default: "" },
writable: { type: Boolean, required: false, default: true },
},
data: () => ({
@@ -50,8 +59,8 @@ export default {
if (this.suppressFocus) {
return;
}
const ul = this.$el.closest(".scroller");
const el = ul.querySelector(`[tabindex="${index}"]`);
const scrollContainer = this.$el.closest(".scroller");
const el = scrollContainer.querySelector(`[tabindex="${index}"]`);
if (el) {
el.focus();
}
@@ -66,9 +75,6 @@ export default {
// override me
return "Placeholder";
},
bindProps() {
return { ...this.$props, ...this.$attrs };
},
},
created() {
@@ -4,6 +4,7 @@
v-bind="$attrs"
v-on="$listeners"
:dataset="dataset"
:row-key="rowKey"
@update:dataset="onUpdate"
@delete="onDelete"
@undelete="onUndelete"
@@ -26,6 +27,7 @@ export default {
},
props: {
item: { type: Object, required: true },
rowKey: { type: [Number, String], required: false, default: "" },
},
computed: {
dataset() {
@@ -1,159 +1,170 @@
<template>
<PriorityMenu :starting-height="27">
<PriorityMenuItem
v-if="notIn(STATES.NOT_VIEWABLE, STATES.DISCARDED)"
key="view-dataset"
:title="displayButtonTitle"
:disabled="dataset.purged || isIn(STATES.UPLOAD, STATES.NEW)"
@click.stop="viewData"
icon="fas fa-eye"
/>
<PriorityMenuItem
v-if="notIn(STATES.DISCARDED)"
key="edit-dataset"
:title="editButtonTitle"
:disabled="dataset.deleted || isIn(STATES.UPLOAD, STATES.NEW)"
@click.stop="
backboneRoute('datasets/edit', {
dataset_id: dataset.id,
})
"
icon="fa fa-pencil"
/>
<PriorityMenuItem
v-if="dataset.accessible"
key="delete-dataset"
:title="deleteButtonTitle"
:disabled="dataset.purged"
@click.stop="onDeleteClick"
:icon="dataset.deleted ? 'fas fa-trash-restore' : 'fas fa-trash'"
/>
<PriorityMenuItem
v-if="expanded"
key="edit-tags"
title="Edit Dataset Tags"
:pressed="showTags"
@click.stop="$emit('update:showTags', !showTags)"
icon="fas fa-tags"
/>
<PriorityMenuItem
v-if="isIn(STATES.ERROR)"
key="show-error"
title="Error"
@click.stop="
backboneRoute('datasets/error', {
dataset_id: dataset.id,
})
"
icon="fa fa-bug"
/>
<PriorityMenuItem
v-if="!(showDownloads && dataset.hasMetaData)"
key="download"
title="Download"
:href="prependPath(dataset.getUrl('download'))"
target="_blank"
download
icon="fas fa-file-download"
/>
<PriorityMenuItem
v-if="showDownloads && dataset.hasMetaData"
key="download-metadata"
title="Download"
:href="prependPath(dataset.getUrl('download'))"
target="_blank"
download
icon="fas fa-file-download"
/>
<PriorityMenuItem
v-if="!dataset.purged && dataset.getUrl('download')"
key="copy-link"
title="Copy Link"
icon="fa fa-chain"
@click.stop="$emit('copy-link')"
/>
<div v-if="showDownloads && dataset.hasMetaData">
<PriorityMenuItem
v-for="mf in dataset.meta_files"
:key="'download-' + mf.download_url"
:title="'Download ' + mf.file_type"
:href="prependPath(dataset.getUrl('meta_download') + mf.file_type)"
target="_blank"
download
icon="fas fa-file-download"
<div>
<b-button-group>
<IconButton
v-if="notIn(STATES.NOT_VIEWABLE, STATES.DISCARDED)"
icon="eye"
:title="displayButtonTitle"
:disabled="dataset.purged || isIn(STATES.UPLOAD, STATES.NEW)"
@click.stop="viewData"
variant="link"
class="px-1"
/>
<IconButton
v-if="notIn(STATES.DISCARDED)"
icon="pen"
:title="editButtonTitle"
:disabled="dataset.deleted || isIn(STATES.UPLOAD, STATES.NEW)"
@click.stop="backboneRoute('datasets/edit', { dataset_id: dataset.id })"
variant="link"
class="px-1"
/>
<IconButton
v-if="dataset.accessible"
:icon="dataset.deleted ? 'trash-restore' : 'trash'"
:title="deleteButtonTitle"
:disabled="dataset.purged"
v-b-modal="bsId('delete-modal')"
variant="link"
class="px-1"
@click.stop
/>
</div>
<PriorityMenuItem
v-if="dataset.rerunnable && dataset.creating_job && notIn(STATES.UPLOAD, STATES.NOT_VIEWABLE)"
key="run-job"
title="Run job again"
:href="prependPath(dataset.getUrl('rerun'))"
@click.stop="
backboneRoute('/', {
job_id: dataset.creating_job,
})
"
icon="fa fa-redo"
/>
<b-dropdown
size="sm"
variant="link"
:text="'Dataset Operations' | l"
toggle-class="p-1 pl-2"
class="flex-grow-0"
v-if="expanded"
boundary="window"
>
<template v-slot:button-content>
<Icon icon="ellipsis-v" variant="link" />
<span class="sr-only">Dataset Operations</span>
</template>
<PriorityMenuItem
v-if="showViz && hasViz && isIn(STATES.OK, STATES.FAILED_METADATA)"
key="visualize"
title="Visualize Data"
@click.stop.prevent="visualize"
icon="fa-bar-chart"
/>
<b-dropdown-item
v-if="isIn(STATES.ERROR)"
key="show-error"
title="Error"
@click.stop="
backboneRoute('datasets/error', {
dataset_id: dataset.id,
})
"
>
<Icon icon="exclamation-triangle" class="mr-1" />
<span v-localize>Show Error</span>
</b-dropdown-item>
<PriorityMenuItem
v-if="currentUser && currentUser.id && dataset.creating_job"
key="tool-tip"
title="Tool Help"
@click.stop="showToolHelp(dataset.creating_job)"
icon="fa fa-question"
/>
<b-dropdown-item
v-if="!dataset.purged && dataset.getUrl('download')"
title="Copy Link"
@click.stop="$emit('copy-link')"
>
<Icon icon="link" class="mr-1" />
<span v-localize>Copy Link</span>
</b-dropdown-item>
<PriorityMenuItem
v-if="notIn(STATES.NOT_VIEWABLE)"
key="dataset-details"
title="View Details"
@click.stop.prevent="
iframeAdd({
path: dataset.getUrl('show_params'),
title: 'Dataset details',
})
"
icon="fa fa-info-circle"
/>
</PriorityMenu>
<b-dropdown-item
v-if="!(showDownloads && dataset.hasMetaData)"
title="Download"
:href="prependPath(dataset.getUrl('download'))"
target="_blank"
download
>
<Icon icon="download" class="mr-1" />
<span v-localize>Download</span>
</b-dropdown-item>
<b-dropdown-group v-if="showDownloads && dataset.hasMetaData">
<b-dropdown-item
v-for="mf in dataset.meta_files"
:key="'download-' + mf.download_url"
:title="'Download ' + mf.file_type"
:href="prependPath(dataset.getUrl('meta_download') + mf.file_type)"
target="_blank"
download
>
<Icon icon="download" class="mr-1" />
<span v-localize>{{ "Download " + mf.file_type }}</span>
</b-dropdown-item>
</b-dropdown-group>
<b-dropdown-item
v-if="dataset.rerunnable && dataset.creating_job && notIn(STATES.UPLOAD, STATES.NOT_VIEWABLE)"
title="Run job again"
:href="prependPath(dataset.getUrl('rerun'))"
@click.stop="
backboneRoute('/', {
job_id: dataset.creating_job,
})
"
>
<Icon icon="play" class="mr-1" />
<span v-localize>Run job again</span>
</b-dropdown-item>
<b-dropdown-item
v-if="showViz && hasViz && isIn(STATES.OK, STATES.FAILED_METADATA)"
title="Visualize Data"
@click.stop.prevent="visualize"
>
<Icon icon="chart-area" class="mr-1" />
<span v-localize>Visualize Data</span>
</b-dropdown-item>
<b-dropdown-item
v-if="currentUser && currentUser.id && dataset.creating_job"
title="Tool Help"
@click.stop="showToolHelp(dataset.creating_job)"
>
<Icon icon="question" class="mr-1" />
<span v-localize>Tool Help</span>
</b-dropdown-item>
<b-dropdown-item
v-if="notIn(STATES.NOT_VIEWABLE)"
key="dataset-details"
title="View Dataset Details"
@click.stop.prevent="
iframeAdd({
path: dataset.getUrl('show_params'),
title: 'View Dataset Details',
})
"
>
<Icon icon="info-circle" class="mr-1" />
<span v-localize>View Dataset Details</span>
</b-dropdown-item>
</b-dropdown>
</b-button-group>
<!-- modals -->
<b-modal :id="bsId('delete-modal')" title="Delete Dataset?" title-tag="h2" @ok="onDeleteClick">
<p v-localize>Really delete this dataset?</p>
</b-modal>
</div>
</template>
<script>
import { mapGetters } from "vuex";
import { Dataset, STATES } from "../../model";
import { PriorityMenu, PriorityMenuItem } from "components/PriorityMenu";
import { Dataset } from "../../model";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import IconButton from "components/IconButton";
export default {
mixins: [legacyNavigationMixin],
inject: ["STATES"],
components: {
PriorityMenu,
PriorityMenuItem,
IconButton,
},
props: {
dataset: { type: Dataset, required: true },
expanded: { type: Boolean, required: true },
showTags: { type: Boolean, required: false, default: false },
},
data() {
@@ -163,11 +174,6 @@ export default {
};
},
created() {
// make available to template
this.STATES = STATES;
},
computed: {
...mapGetters("user", ["currentUser"]),
...mapGetters("config", ["config"]),
@@ -180,10 +186,10 @@ export default {
if (this.dataset.purged) {
return "Cannot display datasets removed from disk";
}
if (this.dataset.state == STATES.UPLOAD) {
if (this.dataset.state == this.STATES.UPLOAD) {
return "This dataset must finish uploading before it can be viewed";
}
if (this.dataset.state == STATES.NEW) {
if (this.dataset.state == this.STATES.NEW) {
return "This dataset is not yet viewable";
}
return "View data";
@@ -196,7 +202,7 @@ export default {
if (this.dataset.purged) {
return "Cannot edit attributes of datasets removed from disk";
}
const unreadyStates = new Set([STATES.UPLOAD, STATES.NEW]);
const unreadyStates = new Set([this.STATES.UPLOAD, this.STATES.NEW]);
if (unreadyStates.has(this.dataset.state)) {
return "This dataset is not yet editable";
}
@@ -218,7 +224,7 @@ export default {
if (!this.dataset.hasData) {
return false;
}
const okStates = new Set([STATES.OK, STATES.FAILED_METADATA, STATES.ERROR]);
const okStates = new Set([this.STATES.OK, this.STATES.FAILED_METADATA, this.STATES.ERROR]);
return okStates.has(this.dataset.state);
},
@@ -278,6 +284,11 @@ export default {
const eventName = this.dataset.deleted ? "undelete" : "delete";
this.$emit(eventName);
},
// bootstrap demands ids instead of simply using a ref so I guess we're doing this
bsId(suffix) {
return `dataset-${this.dataset.id}-${suffix}`;
},
},
};
</script>
@@ -8,7 +8,7 @@ either through the props, and make updates through the events -->
<template>
<div
class="dataset history-content"
:id="typedId"
:data-content-type-id="typeId"
:class="{ expanded, collapsed, selected }"
:data-state="dataset.state"
@keydown.arrow-left.self.stop="$emit('update:expanded', false)"
@@ -16,73 +16,101 @@ either through the props, and make updates through the events -->
@keydown.space.self.stop.prevent="$emit('update:selected', !selected)"
>
<!-- name, state buttons, menus -->
<nav class="content-top-menu" @click.stop="$emit('update:expanded', !expanded)">
<div class="content-status-indicators" @click.stop>
<b-check
class="selector"
v-if="showSelection"
:checked="selected"
@change="$emit('update:selected', $event)"
<nav class="p-1 d-flex align-items-center cursor-pointer" @click.stop="$emit('update:expanded', !expanded)">
<div class="d-flex align-items-center flex-grow-1 overflow-hidden">
<div class="pl-1" v-if="showSelectionBox">
<b-check class="selector" :checked="selected" @change="$emit('update:selected', $event)"></b-check>
</div>
<StatusIcon
v-if="!collapsed"
class="status-icon px-1"
:state="dataset.state"
@click.stop="onStatusClick"
/>
<StatusIcon v-if="!ok" class="status-icon px-1" :state="dataset.state" @click.stop="onStatusClick" />
<StateBtn
v-if="!dataset.visible"
<IconButton
v-if="!collapsed && !dataset.visible"
class="px-1"
state="hidden"
title="Unhide"
icon="fa fa-eye-slash"
icon="eye-slash"
@click.stop="$emit('unhide')"
/>
<StateBtn
v-if="dataset.isDeleted && !dataset.purged"
<IconButton
v-if="!collapsed && dataset.isDeleted && !dataset.purged"
class="px-1"
state="deleted"
title="Undelete"
icon="fas fa-trash-restore"
icon="trash-restore"
@click.stop="$emit('undelete')"
/>
<div class="content-title p-1 overflow-hidden">
<h5 class="text-truncate" v-if="collapsed">
<!-- <span class="hid">{{ dataset.hid }}</span> -->
<span class="name">{{ dataset.title }}</span>
</h5>
</div>
</div>
<div class="content-title">
<h5 class="text-truncate">
<span class="hid">{{ dataset.hid }}</span>
<span v-if="collapsed || !dataset.canEditName" class="name">{{ dataset.title }}</span>
</h5>
</div>
<div class="content-item-menu">
<div class="d-flex">
<slot name="menu">
<DatasetMenu :dataset="dataset" :expanded="expanded" v-on="$listeners" :show-tags.sync="showTags" />
<DatasetMenu :dataset="dataset" :expanded="expanded" v-on="$listeners" />
</slot>
</div>
</nav>
<!--- read-only tags with name: prefix -->
<div v-if="collapsed && dataset.nameTags.length" class="nametags p-1">
<!--- read-only tags with name: prefix
<div v-if="collapsed && dataset.nameTags.length" class="nametags p-2">
<Nametag v-for="tag in dataset.nameTags" :key="tag" :tag="tag" />
</div>
</div> -->
<!-- expanded view with editors -->
<header v-if="expanded" class="p-2 details">
<ClickToEdit
v-if="dataset.canEditName"
tag-name="h4"
:value="dataset.name"
@input="$emit('update:dataset', { name: $event })"
:display-label="dataset.title"
:tooltip-title="'Edit dataset name...' | localize"
tooltip-placement="left"
/>
<div v-if="expanded" class="p-3 details">
<div class="d-flex">
<div class="flex-grow-1">
<h4>{{ name || "(Dataset Name)" }}</h4>
<Annotation
class="mt-1"
:value="dataset.annotation"
@input="$emit('update:dataset', { annotation: $event })"
/>
<template v-if="!editing">
<p v-if="annotation">{{ annotation }}</p>
<div v-if="dataset.nameTags.length" class="nametags mt-2">
<Nametag v-for="tag in dataset.nameTags" :key="tag" :tag="tag" />
</div>
</template>
<ContentTags v-if="showTags" class="mt-2" :content="dataset" />
<div v-else-if="dataset.nameTags.length" class="nametags mt-2">
<Nametag v-for="tag in dataset.nameTags" :key="tag" :tag="tag" />
<div v-else class="my-3">
<b-textarea
class="mb-3"
v-model="name"
placeholder="Dataset Name"
trim
max-rows="4"
></b-textarea>
<b-textarea
class="mb-3"
v-model="annotation"
placeholder="Annotation (optional)"
trim
max-rows="4"
></b-textarea>
<ContentTags class="mt-2" :content="dataset" />
</div>
</div>
<EditorMenu
class="ml-3 flex-grow-0 d-flex flex-column"
v-if="writable"
model-name="Collection"
:editing.sync="editing"
:writable="writable"
:valid="valid"
:dirty="dirty"
@save="save"
@revert="revert"
/>
</div>
<div class="details">
@@ -102,41 +130,44 @@ either through the props, and make updates through the events -->
</div>
<pre v-if="dataset.peek" class="dataset-peek p-1" v-html="dataset.peek"></pre>
</div>
</header>
</div>
</div>
</template>
<script>
import { Dataset, STATES } from "../../model";
import ClickToEdit from "components/ClickToEdit";
import Annotation from "components/Annotation";
import { Nametag } from "components/Nametags";
import { StatusIcon, StateBtn } from "../../StatusIcon";
import StatusIcon from "../../StatusIcon";
import DatasetMenu from "./DatasetMenu";
import DatasetSummary from "./Summary";
import ContentTags from "../../ContentTags";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import IconButton from "components/IconButton";
import EditorMenu from "../../EditorMenu";
export default {
mixins: [legacyNavigationMixin],
components: {
ClickToEdit,
Annotation,
StatusIcon,
StateBtn,
DatasetMenu,
DatasetSummary,
ContentTags,
Nametag,
IconButton,
EditorMenu,
},
props: {
dataset: { type: Dataset, required: true },
expanded: { type: Boolean, required: true },
selected: { type: Boolean, required: false, default: false },
showSelection: { type: Boolean, required: false, default: false },
writable: { type: Boolean, required: false, default: true },
selectable: { type: Boolean, required: false, default: true },
},
data() {
return {
showTags: false,
editing: false,
tempContent: this.dataset.clone(),
};
},
computed: {
@@ -146,11 +177,33 @@ export default {
typeId() {
return this.dataset.type_id;
},
ok() {
return this.dataset.state == "ok";
isEditing() {
return this.writable && this.editing;
},
typedId() {
return `dataset-${this.dataset.id}`;
valid() {
return true;
},
dirty() {
return !this.tempContent.equals(this.dataset);
},
name: {
get() {
return this.tempContent?.name || "";
},
set(newVal) {
this.patchTempContent({ name: newVal });
},
},
annotation: {
get() {
return this.tempContent?.annotation || "";
},
set(newVal) {
this.patchTempContent({ annotation: newVal });
},
},
showSelectionBox() {
return this.selectable && this.showSelection;
},
},
methods: {
@@ -163,6 +216,23 @@ export default {
console.log("unhandled status icon click", this.dataset.state);
}
},
patchTempContent(props = {}) {
this.tempContent = this.tempContent.patch(props);
},
save() {
if (this.valid) {
this.$emit("update:dataset", this.tempContent);
this.editing = false;
}
},
reset() {
this.tempContent = this.dataset.clone();
},
revert() {
if (this.dirty) {
this.reset();
}
},
},
};
</script>
@@ -1,18 +1,20 @@
<template>
<b-dropdown no-caret right variant="link" size="sm" boundary="window" toggle-class="p-1">
<b-dropdown no-caret right variant="link" size="sm" boundary="window" toggle-class="p-1 pl-3">
<template v-slot:button-content>
<i class="fas fa-trash" />
<Icon icon="trash" variant="link" />
<span class="sr-only">Delete Collection</span>
</template>
<b-dropdown-item @click.stop="$emit('delete')"> Delete Collection Only </b-dropdown-item>
<b-dropdown-item @click.stop="$emit('delete')">
<span v-localize>Delete Collection Only </span>
</b-dropdown-item>
<b-dropdown-item @click.stop="$emit('delete', { recursive: true })">
Delete Contained Datasets
<span v-localize>Delete Contained Datasets</span>
</b-dropdown-item>
<b-dropdown-item @click.stop="$emit('delete', { recursive: true, purge: true })">
Purge Contained Datasets
<span v-localize>Purge Contained Datasets</span>
</b-dropdown-item>
</b-dropdown>
</template>
@@ -9,16 +9,17 @@
:data-state="dsc.state"
@keydown.arrow-right.self.stop="$emit('viewCollection')"
@keydown.space.self.stop.prevent="$emit('update:selected', !selected)"
@click.stop="$emit('viewCollection')"
>
<nav class="content-top-menu">
<div class="content-status-indicators mr-1" @click.stop>
<b-check
class="selector"
v-if="showSelection"
:checked="selected"
@change="$emit('update:selected', $event)"
/>
<nav class="p-1 d-flex align-items-center cursor-pointer">
<div class="d-flex align-items-center flex-grow-1 overflow-hidden">
<div class="pl-1" v-if="showSelection">
<b-check
class="selector"
:checked="selected"
@click.stop
@change="$emit('update:selected', $event)"
/>
</div>
<StatusIcon
v-if="dsc.state != 'ok'"
@@ -27,44 +28,45 @@
@click.stop="onStatusClick"
/>
<StateBtn
<IconButton
v-if="!dsc.visible"
class="px-1"
state="hidden"
title="Unhide"
icon="fa fa-eye-slash"
icon="eye-slash"
@click.stop="$emit('unhide')"
/>
<StateBtn
<IconButton
v-if="dsc.deleted"
class="px-1"
state="deleted"
title="Undelete"
icon="fas fa-trash-restore"
icon="trash-restore"
@click.stop="$emit('undelete')"
/>
<StateBtn
<IconButton
class="px-1"
state="ok"
title="Collection"
icon="fas fa-folder"
icon="folder"
@click.stop="$emit('viewCollection')"
variant="link"
/>
<div class="content-title flex-grow-1 overflow-hidden" @click.stop="$emit('viewCollection')">
<h5 class="text-truncate">
<!-- <span class="hid">{{ dsc.hid }}</span> -->
<span class="name">{{ dsc.name }}</span>
<span class="description">
({{ dsc.collectionType | localize }} {{ dsc.collectionCountDescription | localize }})
</span>
</h5>
</div>
</div>
<div class="content-title mr-1">
<h5 class="text-truncate">
<span class="hid">{{ dsc.hid }}</span>
<span class="name">{{ dsc.name }}</span>
<span class="description">
({{ dsc.collectionType | localize }} {{ dsc.collectionCountDescription | localize }})
</span>
</h5>
</div>
<div class="content-item-menu">
<div class="d-flex" v-if="writable">
<slot name="menu">
<DscMenu v-if="!dsc.deleted" v-on="$listeners" />
</slot>
@@ -72,7 +74,7 @@
</nav>
<!--- read-only tags with name: prefix -->
<div v-if="dsc.nameTags.length" class="nametags p-1">
<div v-if="dsc.nameTags.length" class="nametags p-2">
<Nametag v-for="tag in dsc.nameTags" :key="tag" :tag="tag" />
</div>
@@ -82,23 +84,25 @@
<script>
import { DatasetCollection } from "../../model/DatasetCollection";
import { StatusIcon, StateBtn } from "../../StatusIcon";
import StatusIcon from "../../StatusIcon";
import JobStateProgress from "./JobStateProgress";
import DscMenu from "./DscMenu";
import { Nametag } from "components/Nametags";
import IconButton from "components/IconButton";
export default {
components: {
StatusIcon,
StateBtn,
JobStateProgress,
DscMenu,
Nametag,
IconButton,
},
props: {
dsc: { type: DatasetCollection, required: true },
selected: { type: Boolean, required: false, default: false },
showSelection: { type: Boolean, required: false, default: false },
writable: { type: Boolean, required: false, default: true },
},
methods: {
onStatusClick() {
@@ -28,7 +28,6 @@ import { STATES } from "../model";
export default {
props: {
item: { type: Object, required: true },
index: { type: Number, required: true },
},
computed: {
dsc() {
@@ -0,0 +1,62 @@
<!-- a read-only dataset inside a collection. The collection query does not return enough
information to properly render the dataset, so what we do is look it up on expand, checking the
cache first to see if we already have the data -->
<template>
<DatasetUI
v-if="dataset"
v-bind="$attrs"
v-on="$listeners"
:dataset="dataset"
:writable="false"
:selectable="false"
:row-key="rowKey"
@update:expanded="onExpand"
/>
</template>
<script>
import { Dataset } from "../model";
import { getContentByTypeId, cacheContent } from "../caching";
import { getContentDetails } from "../model/queries";
import DatasetUI from "./Dataset/DatasetUI";
export default {
components: {
DatasetUI,
},
props: {
item: { type: Object, required: true },
},
data() {
return {
localItem: { ...this.item },
loading: false,
};
},
computed: {
dataset() {
return new Dataset(this.localItem);
},
},
methods: {
onExpand(val) {
if (val) {
this.loadDetails();
}
this.$emit("update:expand", val);
},
async loadDetails() {
const { type_id, history_id } = this.item;
const existingDataset = await getContentByTypeId(history_id, type_id);
if (existingDataset) {
this.localItem = existingDataset;
} else {
const loadedDataset = await getContentDetails(this.item);
await cacheContent(loadedDataset);
this.localItem = loadedDataset;
}
},
},
};
</script>
@@ -1,9 +1,6 @@
/**
* Scrolling components for history contents and collection contents.
*/
import "./styles.scss";
export { default as ContentItem } from "./ContentItem";
export { default as CollectionContentItem } from "./CollectionContentItem";
export { default as HistoryContentItem } from "./HistoryContentItem";
@@ -3,7 +3,7 @@
.content-item {
outline: none;
border-bottom: 1px solid white;
border-bottom: 2px solid white;
&:focus-within {
box-shadow: inset 0 0 1em 0.25em rgba(255, 255, 255, 0.25);
}
@@ -11,36 +11,9 @@
// Nav across the top of the content item
.content-item .content-top-menu {
/* a fixed-height is important for proper
functioning of the PriorityMenuItem dropdown since
it relies on flexbox and overflow */
min-height: 2em;
cursor: pointer;
// flex parent
display: flex;
align-items: center;
justify-content: space-between;
// error, clock, etc.
.content-status-indicators {
// d-flex mr-1 align-items-center
display: flex;
align-items: center;
&:blank {
display: none;
}
}
// name & labelling
.content-title {
// flex child
flex-grow: 1;
overflow: hidden;
.hid {
color: adjust-color($text-color, $alpha: -0.6);
font-size: 80%;
@@ -56,24 +29,4 @@
font-weight: 700;
}
}
// priority menu on right side
.content-item-menu {
display: flex;
justify-content: flex-end;
}
}
// Setting a max-width here limits the number of icons that
// will show. The priority menu is css awayre
.content-item.collapsed .content-item-menu {
max-width: 90px;
}
// .content-menu button:hover,
// .content-menu .show > button.dropdown-toggle {
// background-color: transparent;
// border-color: transparent;
// }
}
@@ -1,136 +1,168 @@
<template>
<section>
<header class="d-flex align-items-center justify-content-between">
<h6 class="text-nowrap mr-3">
<span v-localize>Matches</span>
<a href="#" @click="showFilter = !showFilter">
<span>{{ totalMatches }}</span>
</a>
</h6>
<PriorityMenu style="max-width: 50%" :starting-height="27">
<PriorityMenuItem
key="filter"
<nav class="content-operations-menu d-flex justify-content-between bg-secondary">
<b-button-group>
<IconButton
:title="hasSelection ? 'Select All Contents' : 'Unselect All Contents'"
icon="check-square"
:disabled="!totalMatches"
:pressed="hasSelection"
@click="toggleSelectAll"
/>
<IconButton
title="Filter History Content"
icon="fa fa-filter"
@click="showFilter = !showFilter"
icon="filter"
:disabled="!totalMatches"
:pressed="showFilter"
@click="showFilter = !showFilter"
/>
<PriorityMenuItem
key="selection"
title="Operations on multiple datasets"
class="show-history-content-selectors-btn"
icon="fas fa-check-square"
@click="$emit('update:show-selection', !showSelection)"
:pressed="showSelection"
<IconButton
title="Collapse Expanded Items"
icon="compress"
:disabled="!expandedCount"
@click="$emit('collapseAllContent')"
/>
</b-button-group>
<PriorityMenuItem
key="copy-datasets"
class="copy-datasets-menu-item"
title="Copy Datasets"
icon="fas fa-copy"
@click="iframeRedirect('/dataset/copy_datasets')"
/>
<PriorityMenuItem
key="collapse-expanded"
title="Collapse Expanded Datasets"
icon="fas fa-compress-alt"
@click="emit('collapseAllContent')"
/>
<PriorityMenuItem
key="show-hidden-datasets"
title="Unhide All Hidden Datasets"
icon="fas fa-eye"
@click="$bvModal.show('show-hidden-content')"
/>
<PriorityMenuItem
key="delete-hidden"
title="Delete Hidden Datasets"
icon="fas fa-trash"
@click="$bvModal.show('delete-hidden-content')"
/>
<PriorityMenuItem
key="purge-hidden"
title="Purge Hidden Datasets"
icon="fas fa-burn"
@click="$bvModal.show('purge-deleted-content')"
/>
</PriorityMenu>
</header>
<transition name="shutterfade">
<content-filters v-if="showContentFilters" class="content-filters mt-1" :params.sync="localParams" />
</transition>
<transition name="shutterfade">
<b-button-toolbar
v-if="showSelection"
class="content-selection justify-content-between mt-1 list-action-menu"
>
<b-button-group>
<b-button size="sm" @click="$emit('selectAllContent')">
{{ "Select All" | localize }}
</b-button>
<b-button size="sm" @click="$emit('resetSelection')">
{{ "Unselect All" | localize }}
</b-button>
</b-button-group>
<b-button-group>
<b-dropdown
class="ml-auto history-contents-list-action-menu-btn"
class="history-contents-list-action-menu-btn"
size="sm"
text="With Selected"
text="Selection"
:disabled="!hasSelection"
>
<b-dropdown-item @click="hideSelected">
{{ "Hide Datasets" | localize }}
<b-dropdown-text id="history-op-selected-content">
<span v-localize v-if="hasSelection">With {{ numSelected }} selected items...</span>
<span v-localize v-else>You don't have any selected content.</span>
</b-dropdown-text>
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:hide-selected-content>
<span v-localize>Hide</span>
</b-dropdown-item>
<b-dropdown-item @click="unhideSelected">
{{ "Unhide Datasets" | localize }}
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:show-selected-content>
<span v-localize>Unhide</span>
</b-dropdown-item>
<b-dropdown-item @click="deleteSelected">
{{ "Delete Datasets" | localize }}
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:delete-selected-content>
<span v-localize>Delete</span>
</b-dropdown-item>
<b-dropdown-item @click="undeleteSelected">
{{ "Undelete Datasets" | localize }}
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:restore-selected-content>
<span v-localize>Undelete</span>
</b-dropdown-item>
<b-dropdown-item @click="purgeSelected">
{{ "Permanently Delete Datasets" | localize }}
</b-dropdown-item>
<b-dropdown-item @click="buildDatasetList">
{{ "Build Dataset List" | localize }}
</b-dropdown-item>
<b-dropdown-item @click="buildDatasetPair">
{{ "Build Dataset Pair" | localize }}
</b-dropdown-item>
<b-dropdown-item @click="buildListOfPairs">
{{ "Build List of Dataset Pairs" | localize }}
</b-dropdown-item>
<b-dropdown-item @click="buildCollectionFromRules">
{{ "Build Collection from Rules" | localize }}
<b-dropdown-item
aria-describedby="history-op-selected-content"
v-b-modal:purge-selected-content
:disabled="!hasSelection"
>
<span v-localize>Permanently Delete</span>
<span v-if="numSelected">{{ numSelected }} items</span>
<span v-else>(Disabled)</span>
</b-dropdown-item>
</b-dropdown>
</b-button-toolbar>
<b-dropdown
class="history-contents-list-action-menu-btn"
size="sm"
text="History"
:disabled="!totalMatches"
>
<b-dropdown-text id="history-op-all-content">
<span v-localize>With entire history...</span>
</b-dropdown-text>
<b-dropdown-item v-b-modal:show-all-hidden-content aria-describedby="history-op-all-content">
<span v-localize>Unhide All Hidden Content</span>
</b-dropdown-item>
<b-dropdown-item v-b-modal:delete-all-hidden-content aria-describedby="history-op-all-content">
<span v-localize>Delete All Hidden Content</span>
</b-dropdown-item>
<b-dropdown-item v-b-modal:purge-all-deleted-content aria-describedby="history-op-all-content">
<span v-localize>Purge All Hidden Content</span>
</b-dropdown-item>
</b-dropdown>
<b-dropdown
class="history-contents-list-action-menu-btn"
size="sm"
text="Create New Content"
:disabled="!totalMatches"
>
<template v-slot:button-content>
<Icon icon="plus" />
<span class="sr-only" v-localize>Create New Content</span>
</template>
<b-dropdown-text id="history-op-new-content">
<span>Create new content</span>
</b-dropdown-text>
<b-dropdown-item
aria-describedby="history-op-new-content"
@click="iframeRedirect('/dataset/copy_datasets')"
>
<span v-localize>Copy Datasets</span>
</b-dropdown-item>
<b-dropdown-item aria-describedby="history-op-new-content" @click="buildDatasetList">
<span v-localize>Build Dataset List</span>
</b-dropdown-item>
<b-dropdown-item aria-describedby="history-op-new-content" @click="buildDatasetPair">
<span v-localize>Build Dataset Pair</span>
</b-dropdown-item>
<b-dropdown-item aria-describedby="history-op-new-content" @click="buildListOfPairs">
<span v-localize>Build List of Dataset Pairs</span>
</b-dropdown-item>
<b-dropdown-item aria-describedby="history-op-new-content" @click="buildCollectionFromRules">
<span v-localize>Build Collection from Rules</span>
</b-dropdown-item>
</b-dropdown>
</b-button-group>
</nav>
<transition name="shutterfade">
<content-filters v-if="showContentFilters" class="content-filters p-2" :params.sync="localParams" />
</transition>
<!-- #region Menus and popovers -->
<b-modal id="show-hidden-content" title="Show Hidden Datasets" title-tag="h2" @ok="unhideAll">
<p>{{ "Really unhide all hidden datasets?" | localize }}</p>
<b-modal id="hide-selected-content" title="Hide Selected Content?" title-tag="h2" @ok="hideSelected">
<p v-localize>Really hide {{ numSelected }} content items?</p>
</b-modal>
<b-modal id="delete-hidden-content" title="Delete Hidden Datasets" title-tag="h2" @ok="deleteAllHidden">
<p>{{ "Really delete all hidden datasets?" | localize }}</p>
<b-modal id="show-selected-content" title="Show Selected Content?" title-tag="h2" @ok="unhideSelected">
<p v-localize>Really show {{ numSelected }} content items?</p>
</b-modal>
<b-modal id="purge-deleted-content" title="Purge Deleted Datasets" title-tag="h2" @ok="purgeAllDeleted">
<p>{{ "Really delete all deleted datasets permanently? This cannot be undone." | localize }}</p>
<b-modal id="delete-selected-content" title="Delete Selected Content?" title-tag="h2" @ok="deleteSelected">
<p v-localize>Really delete {{ numSelected }} content items?</p>
</b-modal>
<b-modal id="restore-selected-content" title="Restore Selected Content?" title-tag="h2" @ok="undeleteSelected">
<p v-localize>Really restore {{ numSelected }} content items?</p>
</b-modal>
<b-modal id="purge-selected-content" title="Purge Selected Content?" title-tag="h2" @ok="purgeSelected">
<p v-localize>Permanently delete {{ numSelected }} content items? This cannot be undone.</p>
</b-modal>
<b-modal id="show-all-hidden-content" title="Show Hidden Datasets" title-tag="h2" @ok="unhideAll">
<p v-localize>Really unhide all hidden datasets?</p>
</b-modal>
<b-modal id="delete-all-hidden-content" title="Delete Hidden Datasets" title-tag="h2" @ok="deleteAllHidden">
<p v-localize>Really delete all hidden datasets?</p>
</b-modal>
<b-modal id="purge-all-deleted-content" title="Purge Deleted Datasets" title-tag="h2" @ok="purgeAllDeleted">
<p v-localize>"Really delete all deleted datasets permanently? This cannot be undone.</p>
</b-modal>
<!-- #endregion -->
@@ -152,18 +184,16 @@ import {
} from "./model";
import { createDatasetCollection } from "./model/queries";
import { cacheContent } from "./caching";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import ContentFilters from "./ContentFilters";
import { PriorityMenu, PriorityMenuItem } from "components/PriorityMenu";
import { buildCollectionModal } from "./adapters/buildCollectionModal";
import ContentFilters from "./ContentFilters";
import IconButton from "components/IconButton";
export default {
mixins: [legacyNavigationMixin],
components: {
ContentFilters,
PriorityMenu,
PriorityMenuItem,
IconButton,
},
props: {
history: { type: History, required: true },
@@ -171,6 +201,8 @@ export default {
contentSelection: { type: Map, required: true },
showSelection: { type: Boolean, required: true },
totalMatches: { type: Number, required: true },
expandedCount: { type: Number, required: false, default: 0 },
debug: { type: Boolean, default: false },
},
data() {
return {
@@ -178,13 +210,11 @@ export default {
};
},
computed: {
// pop it open if anything is selected
showContentFilters() {
const { showHidden, showDeleted, filterText } = this.params;
return this.showFilter || showHidden || showDeleted || filterText;
},
// pass-through
localParams: {
get() {
return this.params;
@@ -194,8 +224,12 @@ export default {
},
},
numSelected() {
return this.contentSelection.size || 0;
},
hasSelection() {
return this.contentSelection.size > 0;
return this.numSelected > 0;
},
countHidden() {
@@ -203,22 +237,29 @@ export default {
},
},
methods: {
toggleSelectAll() {
if (this.hasSelection) {
this.$emit("resetSelection");
this.$emit("update:show-selection", false);
} else {
this.$emit("selectAllContent");
this.$emit("update:show-selection", true);
}
},
// #region history-wide bulk updates, does server query first to determine "selection"
async unhideAll(evt) {
await unhideAllHiddenContent(this.history);
this.$emit("manualReload");
evt.vueTarget.hide();
},
async deleteAllHidden(evt) {
await deleteAllHiddenContent(this.history);
this.$emit("manualReload");
evt.vueTarget.hide();
},
async purgeAllDeleted(evt) {
await purgeAllDeletedContent(this.history);
this.$emit("manualReload");
evt.vueTarget.hide();
},
// #endregion
@@ -243,7 +284,10 @@ export default {
async runOnSelection(fn) {
const items = Array.from(this.contentSelection.values());
const type_ids = items.map((o) => o.type_id);
await fn(this.history, type_ids);
const results = await fn(this.history, type_ids);
if (this.debug) {
console.log("operation results", results);
}
this.$emit("resetSelection");
this.$emit("manualReload");
},
@@ -282,3 +326,10 @@ export default {
},
};
</script>
<style lang="scss">
// remove borders around buttons in menu
.content-operations-menu .btn-group .btn {
border-color: transparent !important;
}
</style>
+1 -12
View File
@@ -1,5 +1,5 @@
<template>
<b-modal ref="copyModal" v-model="showModal" :title="title" title-tag="h2">
<b-modal v-bind="$attrs" v-on="$listeners" :title="title" title-tag="h2">
<transition name="fade">
<b-alert :show="currentUser.isAnonymous" variant="warning" v-localize>
As an anonymous user, unless you login or register, you will lose your current history after copying
@@ -51,7 +51,6 @@ import { mapActions, mapGetters } from "vuex";
export default {
props: {
history: { type: History, required: true },
value: { type: Boolean, required: false, default: false },
},
data() {
return {
@@ -81,16 +80,6 @@ export default {
formValid() {
return this.newNameValid;
},
showModal: {
get() {
return this.value;
},
set(newVal, oldVal) {
if (newVal !== oldVal) {
this.$emit("input", newVal);
}
},
},
},
methods: {
...mapActions("betaHistory", ["copyHistory"]),
@@ -1,45 +1,108 @@
<!-- Edits a collection, if that collection is editable. -->
<template>
<section class="history-details controls">
<ClickToEdit
:value="dsc.name"
v-if="writable"
@input="(name) => $emit('update:dsc', { name })"
tag-name="h3"
class="history-title mt-3"
:tooltip-title="'Rename collection...' | localize"
/>
<h3 v-else class="history-title mt-3">{{ dsc.name }}</h3>
<section class="history-details d-flex">
<div class="flex-grow-1">
<h3 class="history-title">{{ dscName || "(Collection Name)" }}</h3>
<p class="mt-1">
<i class="fas fa-folder"></i>
a {{ dsc.collectionType | localize }}
{{ dsc.collectionCount | localize }}
</p>
<p class="mt-1">
<i class="fas fa-folder"></i>
a {{ dsc.collectionType | localize }}
{{ dsc.collectionCount | localize }}
</p>
<div v-if="isEditing" class="mt-3">
<b-textarea v-model="dscName" placeholder="Collection Name" trim max-rows="4"></b-textarea>
<ContentTags :content="dsc" class="mt-3" />
</div>
<transition name="shutterfade">
<ContentTags v-if="writable" :content="dsc" />
<div v-else-if="dsc.tags && dsc.tags.length" class="nametags p-1">
<div v-else-if="dsc.tags && dsc.tags.length">
<Nametag v-for="tag in dsc.tags" :key="tag" :tag="tag" />
</div>
</transition>
</div>
<EditorMenu
class="ml-3 flex-grow-0 d-flex flex-column"
v-if="writable"
model-name="Collection"
:editing.sync="editing"
:writable="writable"
:valid="valid"
:dirty="dirty"
@save="save"
@revert="revert"
/>
</section>
</template>
<script>
import { DatasetCollection } from "../model";
import ClickToEdit from "components/ClickToEdit";
import { Nametag } from "components/Nametags";
import ContentTags from "../ContentTags";
import EditorMenu from "../EditorMenu";
export default {
components: {
ClickToEdit,
ContentTags,
Nametag,
EditorMenu,
},
props: {
dsc: { type: DatasetCollection, required: true },
writable: { type: Boolean, required: true },
},
data() {
return {
editing: false,
tempContent: this.dsc.clone(),
};
},
computed: {
dscId() {
return this.dsc.id;
},
isEditing() {
return this.writable && this.editing;
},
valid() {
return this.tempContent.name.length > 0;
},
dirty() {
return !this.tempContent.equals(this.dsc);
},
dscName: {
get() {
return this.tempContent.name;
},
set(newName) {
this.patchTempDsc({ name: newName });
},
},
},
watch: {
dscId(newId, oldId) {
if (newId != oldId) {
this.reset();
}
},
},
methods: {
reset() {
this.tempContent = this.dsc.clone();
},
patchTempDsc(props = {}) {
this.tempContent = this.tempContent.patch(props);
},
save() {
if (this.valid) {
this.$emit("update:dsc", this.tempContent);
this.editing = false;
}
},
revert() {
if (this.dirty) {
this.reset();
}
},
},
};
</script>
@@ -9,10 +9,19 @@
v-slot="{ isExpanded, setExpanded }"
>
<Layout class="dataset-collection-panel">
<template v-slot:nav>
<template v-slot:globalNav>
<TopNav :history="history" :selected-collections="selectedCollections" v-on="$listeners" />
</template>
<template v-slot:localNav>
<IconButton
icon="download"
title="Download Collection"
:href="downloadCollectionUrl"
download
/>
</template>
<template v-slot:details>
<Details :dsc="dsc" :writable="writable" @update:dsc="updateDsc(dsc, $event)" />
</template>
@@ -23,13 +32,16 @@
:class="{ loadingBackground: loading }"
v-bind="payload"
@scroll="setScrollPos"
:debug="false"
>
<template v-slot="{ item, index }">
<template v-slot="{ item, index, rowKey }">
<CollectionContentItem
:item="item"
:index="index"
:row-key="rowKey"
:expanded="isExpanded(item)"
:writable="writable"
:writable="false"
:selectable="false"
@update:expanded="setExpanded(item, $event)"
@viewCollection="$emit('viewCollection', item)"
/>
@@ -55,6 +67,7 @@ import Scroller from "../Scroller";
import { CollectionContentItem } from "../ContentItem";
import { reportPayload } from "../providers/ContentProvider/helpers";
import IconButton from "components/IconButton";
export default {
filters: {
@@ -69,6 +82,7 @@ export default {
Scroller,
CollectionContentItem,
ExpandedItems,
IconButton,
},
props: {
history: { type: History, required: true },
@@ -86,14 +100,22 @@ export default {
writable() {
return this.isRoot;
},
rootCollection() {
return this.selectedCollections[0];
},
downloadCollectionUrl() {
let url = "";
if (this.rootCollection) {
url = `${this.rootCollection.url}/download`;
}
return url;
},
},
methods: {
// change the data of the root collection, anything past the root
// collection is part of the dataset collection, which i believe is supposed to
// be immutable, so only edit name, tags, blah of top-level selected collection,
async updateDsc(collection, fields) {
// console.log("updateDsc", this.writable, collection, fields);
if (this.writable) {
const ajaxResult = await updateContentFields(collection, fields);
await cacheContent({ ...collection, ...ajaxResult });
@@ -1,46 +1,34 @@
<template>
<div class="navigation">
<b-dropdown v-if="breadCrumbOptions.length" class="mr-auto" size="sm" text="Go to..." boundary="viewport">
<b-dropdown-item @click="close"> History: {{ history.name }} </b-dropdown-item>
<b-dropdown-item v-for="option in breadCrumbOptions" :key="option.key" @click="reselect(option.value)">
{{ option.text }}
</b-dropdown-item>
</b-dropdown>
<b-dropdown v-if="breadCrumbOptions.length" size="sm" text="Return to..." boundary="viewport" no-caret>
<template v-slot:button-content>
<Icon icon="arrow-up" class="mr-1" />
<b class="text-nowrap" v-localize>Return to...</b>
</template>
<b-button v-else size="sm" class="mr-auto back" @click="close"> Back to: {{ history.name }} </b-button>
<b-dropdown-item @click="close">
<span>History: {{ history.name }}</span>
</b-dropdown-item>
<PriorityMenu :starting-height="27">
<PriorityMenuItem
key="download-collection"
title="Download Collection"
icon="fas fa-file-download"
tag="a"
download
:href="downloadCollectionUrl"
/>
<PriorityMenuItem key="back-up" title="Back One" icon="fas fa-level-up-alt" @click="back" />
</PriorityMenu>
</div>
<b-dropdown-item v-for="option in breadCrumbOptions" :key="option.key" @click="reselect(option.value)">
<span>{{ option.text }}</span>
</b-dropdown-item>
</b-dropdown>
<b-button v-else size="sm" title="`Return to: ${history.name}`" @click="close">
<Icon icon="arrow-up" class="mr-1" />
<b class="text-nowrap">Return to: {{ history.name }}</b>
</b-button>
</template>
<script>
import { History } from "../model";
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
export default {
components: {
PriorityMenuItem,
PriorityMenu,
},
props: {
history: { type: History, required: true },
selectedCollections: { type: Array, required: true, validate: (val) => val.length > 0 },
},
computed: {
rootCollection() {
return this.selectedCollections[0];
},
// List for the dropdown. Should be the history and every parent
// collection excepting the current one.
breadCrumbOptions() {
@@ -54,14 +42,6 @@ export default {
});
return options;
},
downloadCollectionUrl() {
let url = "";
if (this.rootCollection) {
url = `${this.rootCollection.url}/download`;
}
return url;
},
},
methods: {
reselect(newList) {
@@ -2,20 +2,17 @@
<CurrentUser class="d-flex flex-column" v-slot="{ user }">
<UserHistories v-if="user" :user="user" v-slot="{ currentHistory, histories, handlers }">
<HistoryPanel v-if="currentHistory" :history="currentHistory" v-on="handlers" id="current-history-panel">
<template v-slot:nav>
<div>
<HistorySelector
:histories="histories"
:current-history="currentHistory"
:show-modal="showModal"
@update:currentHistory="handlers.setCurrentHistory"
@hideModal="showModalHandler"
/>
<HistoriesMenu v-on="handlers" @selectHistoryModal="showModalHandler" />
</div>
<template v-slot:globalNav>
<HistoriesMenu
v-on="handlers"
:histories="histories"
:current-history="currentHistory"
title="Current History"
/>
</template>
</HistoryPanel>
<!-- still loading -->
<div v-else class="flex-grow-1 loadingBackground h-100">
<span class="sr-only" v-localize>Loading History...</span>
</div>
@@ -27,7 +24,6 @@
import CurrentUser from "components/providers/CurrentUser";
import UserHistories from "./providers/UserHistories";
import HistoryPanel from "./HistoryPanel";
import HistorySelector from "./HistorySelector";
import HistoriesMenu from "./HistoriesMenu";
export default {
@@ -35,18 +31,12 @@ export default {
CurrentUser,
UserHistories,
HistoryPanel,
HistorySelector,
HistoriesMenu,
},
data() {
return {
showModal: false,
};
},
methods: {
showModalHandler(event) {
this.showModal = !this.showModal;
},
},
// data() {
// return {
// showModal: false,
// };
// },
};
</script>
@@ -0,0 +1,73 @@
<template>
<nav class="edit-controls">
<IconButton
:title="label('Edit') | l"
class="edit-button mb-1"
:pressed="editing"
icon="pen"
@click="$emit('update:editing', !editing)"
/>
<template v-if="editing">
<!-- save -->
<IconButton
:title="label('Save') | l"
class="save-button mb-1"
:class="{ disabled: !valid }"
icon="save"
@click="$emit('save')"
/>
<!-- revert -->
<IconButton
:title="label('Revert') | l"
class="revert-changes-button mb-1"
:class="{ disabled: !dirty }"
icon="undo"
@click="$emit('revert')"
/>
<!-- form status -->
<IconButton
:title="statusLabel | l"
class="status-button mb-1"
:class="{ valid: valid, disabled: !valid }"
:icon="valid ? 'check' : 'exclamation-triangle'"
variant="link"
/>
</template>
</nav>
</template>
<script>
import IconButton from "components/IconButton";
export default {
components: {
IconButton,
},
props: {
modelName: { type: String, required: true },
editing: { type: Boolean, required: true },
dirty: { type: Boolean, required: false, default: false },
valid: { type: Boolean, required: false, default: false },
},
computed: {
statusLabel() {
const status = this.valid ? "Valid" : "Invalid";
return `${status} ${this.modelName}`;
},
},
methods: {
label(verb) {
return `${verb} ${this.modelName}`;
},
},
};
</script>
<style lang="scss">
.edit-controls .disabled {
opacity: 0.25;
}
</style>
+67 -50
View File
@@ -1,66 +1,83 @@
<!-- menu allowing user to change the current history, make a new one, basically anything that's
"above" editing the current history -->
<template>
<PriorityMenu :starting-height="27">
<PriorityMenuItem
key="select-new-history"
title="Select History"
icon="fa fa-folder-open"
@click="$emit('selectHistoryModal')"
<div>
<b-dropdown
size="sm"
variant="link"
:text="title | l"
toggle-class="text-decoration-none"
no-caret
class="histories-operation-menu"
data-description="histories operation menu"
>
<template v-slot:button-content>
<Icon class="mr-1" icon="folder" />
<span id="click-me-fool" class="text-nowrap">{{ title | l }}</span>
</template>
<b-dropdown-text>
<span>You have {{ histories.length }} histories.</span>
</b-dropdown-text>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item v-b-modal.history-selector-modal>
<Icon class="mr-1" icon="exchange-alt" />
<span v-localize>Change the Current History</span>
</b-dropdown-item>
<b-dropdown-item @click="$emit('createNewHistory')">
<Icon class="mr-1" icon="plus" />
<span v-localize>Create a New History</span>
</b-dropdown-item>
<b-dropdown-item @click="backboneRoute('/histories/list')">
<Icon class="mr-1" icon="list" />
<span v-localize>View Saved Histories</span>
</b-dropdown-item>
<b-dropdown-item @click="redirect('/history/view_multiple')">
<Icon class="mr-1" icon="columns" />
<span v-localize>Show Histories Side-by-Side</span>
</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item @click="switchToLegacyHistoryPanel">
<Icon class="mr-1" icon="arrow-up" />
<span v-localize>Return to legacy panel</span>
</b-dropdown-item>
</b-dropdown>
<!-- modals -->
<HistorySelectorModal
id="history-selector-modal"
:histories="histories"
:current-history="currentHistory"
@selectHistory="$emit('setCurrentHistory', $event)"
/>
<PriorityMenuItem
key="create-new-history"
title="Create New History"
class="history-new-button"
icon="fa fa-plus"
@click="$emit('createNewHistory')"
/>
<PriorityMenuItem
key="view-all-histories"
title="View All Histories"
icon="fa fa-columns"
class="history-view-multi-button"
@click="redirect('/history/view_multiple')"
/>
<PriorityMenuItem
key="saved-histories"
title="Saved Histories"
icon="fas fa-save"
@click="backboneRoute('/histories/list')"
/>
<PriorityMenuItem
key="clear-history-cache"
title="Refresh History"
icon="fa fa-refresh"
class="history-refresh-button"
@click.stop="clearCache"
/>
<PriorityMenuItem
key="use-legacy-history"
title="Return to legacy history panel"
icon="fas fa-exchange-alt"
@click="switchToLegacyHistoryPanel"
/>
</PriorityMenu>
</div>
</template>
<script>
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
import { wipeDatabase, clearHistoryDateStore } from "./caching";
import { History } from "./model";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import { switchToLegacyHistoryPanel } from "./adapters/betaToggle";
import { reloadPage } from "utils/redirect";
import HistorySelectorModal from "./HistorySelectorModal";
export default {
mixins: [legacyNavigationMixin],
components: {
PriorityMenu,
PriorityMenuItem,
HistorySelectorModal,
},
props: {
histories: { type: Array, required: true },
currentHistory: { type: History, required: true },
title: { type: String, required: false, default: "Histories" },
},
methods: {
async clearCache() {
await wipeDatabase();
await clearHistoryDateStore();
reloadPage();
},
switchToLegacyHistoryPanel,
},
};
+15 -6
View File
@@ -10,7 +10,7 @@
<ExpandedItems
:scope-key="history.id"
:get-item-key="(item) => item.type_id"
v-slot="{ isExpanded, setExpanded }"
v-slot="{ expandedCount, isExpanded, setExpanded, collapseAll }"
>
<SelectedItems
:scope-key="history.id"
@@ -26,16 +26,20 @@
}"
>
<Layout>
<template v-slot:nav>
<slot name="nav"></slot>
<template v-slot:globalNav>
<slot name="globalNav" :history="history"></slot>
</template>
<template v-slot:localNav>
<HistoryMenu :history="history" v-on="$listeners" />
</template>
<template v-slot:details>
<HistoryDetails class="history-details" :history="history" v-on="$listeners" />
<HistoryDetails :history="history" v-on="$listeners" />
</template>
<template v-slot:messages>
<HistoryMessages class="history-messages m-2" :history="history" />
<HistoryMessages class="m-2" :history="history" />
</template>
<template v-slot:listcontrols>
@@ -51,6 +55,8 @@
@resetSelection="resetSelection"
@selectAllContent="selectItems(payload.contents)"
@manualReload="manualReload"
:expanded-count="expandedCount"
@collapseAllContent="collapseAll"
/>
</template>
@@ -65,10 +71,11 @@
:debug="false"
@scroll="setScrollPos"
>
<template v-slot="{ item, index }">
<template v-slot="{ item, index, rowKey }">
<HistoryContentItem
:item="item"
:index="index"
:row-key="rowKey"
:show-selection="showSelection"
:expanded="isExpanded(item)"
@update:expanded="setExpanded(item, $event)"
@@ -101,6 +108,7 @@ import ToolHelpModal from "./ToolHelpModal";
import Scroller from "./Scroller";
import { HistoryContentItem } from "./ContentItem";
import { reportPayload } from "./providers/ContentProvider/helpers";
import HistoryMenu from "./HistoryMenu";
export default {
filters: {
@@ -118,6 +126,7 @@ export default {
HistoryContentItem,
ExpandedItems,
SelectedItems,
HistoryMenu,
},
props: {
history: { type: History, required: true },
+101 -171
View File
@@ -1,215 +1,145 @@
<template>
<header>
<div class="d-flex justify-content-between align-items-center actions">
<h6 class="text-nowrap text-truncate mr-auto">
<span class="size history-size">{{ history.niceSize | localize }}</span>
</h6>
<!--
History object editor. Change name, edit tags, description.
Emit new History model object when done via .sync syntax
-->
<PriorityMenu style="max-width: 50%" :starting-height="28">
<PriorityMenuItem
key="edit-history-tags"
title="Edit History Tags"
class="history-tag-btn"
icon="fas fa-tags"
:pressed="showTags"
@click="showTags = !showTags"
/>
<PriorityMenuItem
key="edit-history-annotation"
title="Edit Annotation"
class="history-annotate-btn"
icon="fas fa-comment"
:pressed="showAnnotation"
@click="showAnnotation = !showAnnotation"
/>
<PriorityMenuItem
key="copy-history"
title="Copy History"
icon="fas fa-copy"
@click="showCopyModal = !showCopyModal"
/>
<PriorityMenuItem
key="share"
title="Share or Publish"
icon="fas fa-share-alt"
@click="backboneRoute('/histories/sharing', { id: history.id })"
/>
<PriorityMenuItem
key="delete-this-history"
title="Delete this History"
icon="fas fa-trash"
@click="showDeleteModal = !showDeleteModal"
/>
<PriorityMenuItem
key="purge-this-history"
title="Purge this History"
icon="fas fa-burn"
@click="showPurgeModal = !showPurgeModal"
/>
<PriorityMenuItem
key="structure"
title="Show Structure"
icon="fas fa-code-branch"
@click="backboneRoute('/histories/show_structure')"
/>
<PriorityMenuItem
key="extract-workflow"
title="Extract Workflow"
icon="fas fa-file-export"
@click="iframeRedirect('/workflow/build_from_current_history')"
/>
<PriorityMenuItem
key="set-permissions"
title="Set Permissions"
icon="fas fa-user-lock"
@click="backboneRoute('/histories/permissions', { id: history.id })"
/>
<PriorityMenuItem
key="make-private"
title="Make Private"
icon="fas fa-lock"
@click="showPrivacyModal = !showPrivacyModal"
/>
<PriorityMenuItem
key="resume-paused-jobs"
title="Resume Paused Jobs"
icon="fas fa-play"
@click="iframeRedirect('/history/resume_paused_jobs?current=True')"
/>
<PriorityMenuItem
key="export-citations"
title="Export Tool Citations"
icon="fas fa-stream"
@click="backboneRoute('/histories/citations', { id: history.id })"
/>
<PriorityMenuItem
key="export-history-to-file"
title="Export History to File"
icon="fas fa-file-archive"
@click="backboneRoute(exportLink)"
/>
</PriorityMenu>
<template>
<section class="history-details d-flex">
<div class="flex-grow-1">
<h3>{{ historyName || "(History Name)" }}</h3>
<h5 class="history-size">{{ history.size | niceFileSize }}</h5>
<!-- display title, annotation, tags -->
<div v-if="!editing">
<p v-if="annotation" class="mt-1">{{ annotation }}</p>
<HistoryTags class="mt-2" :history="history" :disabled="true" />
</div>
<!-- edit form, change title, annotation, or tags -->
<div v-else class="mt-3">
<b-textarea
class="mb-2"
v-model="historyName"
placeholder="History Name"
trim
max-rows="4"
></b-textarea>
<b-textarea
class="mb-2"
v-model="annotation"
placeholder="Annotation (optional)"
trim
max-rows="4"
></b-textarea>
<HistoryTags class="mt-3" :history="history" />
</div>
</div>
<!-- click to edit the name of the history -->
<ClickToEdit
class="history-title mt-2"
tag-name="h3"
v-model="historyName"
:title="'Rename history...' | localize"
v-b-tooltip.hover="{ boundary: 'viewport', placement: 'left' }"
<EditorMenu
class="ml-3 flex-grow-0 d-flex flex-column"
v-if="writable"
model-name="History"
:editing.sync="editing"
:writable="writable"
:valid="valid"
:dirty="dirty"
@save="save"
@revert="revert"
/>
<transition name="shutterfade">
<Annotation v-if="showAnnotation" class="history-annotation mt-2" v-model="annotation" />
</transition>
<transition name="shutterfade">
<HistoryTags v-if="showTags" class="mt-2" :history="history" />
</transition>
<!-- #region modals -->
<CopyHistoryModal v-model="showCopyModal" :history="history" />
<b-modal v-model="showPrivacyModal" title="Make History Private" title-tag="h2" @ok="makePrivate">
<p v-localize>
This will make all the data in this history private (excluding library datasets), and will set
permissions such that all new data is created as private. Any datasets within that are currently shared
will need to be re-shared or published. Are you sure you want to do this?
</p>
</b-modal>
<b-modal v-model="showDeleteModal" title="Delete History?" title-tag="h2" @ok="deleteHistory">
<p v-localize>Really delete the current history?</p>
</b-modal>
<b-modal v-model="showPurgeModal" title="Permanently Delete History?" title-tag="h2" @ok="purgeHistory">
<p v-localize>Really delete the current history permanently? This cannot be undone.</p>
</b-modal>
<!-- #endregion -->
</header>
</section>
</template>
<script>
import prettyBytes from "pretty-bytes";
import { History } from "./model";
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import ClickToEdit from "components/ClickToEdit";
import Annotation from "components/Annotation";
import HistoryTags from "./HistoryTags";
import CopyHistoryModal from "./CopyModal";
import EditorMenu from "./EditorMenu";
export default {
mixins: [legacyNavigationMixin],
components: {
PriorityMenu,
PriorityMenuItem,
HistoryTags,
Annotation,
ClickToEdit,
CopyHistoryModal,
EditorMenu,
},
filters: {
niceFileSize(rawSize = 0) {
return prettyBytes(rawSize);
},
},
props: {
history: { type: History, required: true },
writable: { type: Boolean, required: false, default: true },
},
data() {
return {
showTags: false,
showAnnotation: false,
showCopyModal: false,
showPrivacyModal: false,
showDeleteModal: false,
showPurgeModal: false,
editing: false,
tempHistory: this.history.clone(),
};
},
computed: {
historyId() {
return this.history.id;
},
isEditing() {
return this.writable && this.editing;
},
valid() {
return this.tempHistory.name.length > 0;
},
dirty() {
return !this.tempHistory.equals(this.history);
},
annotation: {
get() {
return this.history.annotation || "";
return this.tempHistory.annotation;
},
set(annotation) {
if (annotation.length && annotation !== this.history.annotation) {
this.updateFields({ annotation });
}
this.patchTempHistory({ annotation });
},
},
historyName: {
get() {
return this.history?.name || "";
return this.tempHistory.name;
},
set(name) {
if (name.length && name !== this.history.name) {
this.updateFields({ name });
}
this.patchTempHistory({ name });
},
},
exportLink() {
return `histories/${this.history.id}/export`;
tags: {
get() {
return this.tempHistory.tags || [];
},
set(newTags) {
this.patchTempHistory({ tags: newTags });
},
},
},
watch: {
historyId(newId, oldId) {
if (newId != oldId) {
this.reset();
}
},
},
methods: {
async updateFields(fields = {}) {
this.$emit("updateHistory", { id: this.history.id, ...fields });
reset() {
this.tempHistory = this.history.clone();
},
async deleteHistory(evt) {
this.$emit("deleteHistory", this.history);
evt.vueTarget.hide();
patchTempHistory(props = {}) {
this.tempHistory = this.tempHistory.patch(props);
},
async purgeHistory(evt) {
this.$emit("purgeHistory", this.history);
evt.vueTarget.hide();
save() {
if (this.valid) {
this.$emit("update:history", this.tempHistory);
this.editing = false;
}
},
async makePrivate(evt) {
this.$emit("secureHistory", this.history);
evt.vueTarget.hide();
revert() {
if (this.dirty) {
this.reset();
}
},
},
};
</script>
<style lang="scss">
@import "scss/transitions.scss";
</style>
@@ -0,0 +1,119 @@
<template>
<div>
<b-dropdown size="sm" variant="link" text="Histories" toggle-class="text-decoration-none">
<template v-slot:button-content>
<Icon icon="wrench" />
<span class="sr-only">Operations on the current history</span>
</template>
<b-dropdown-item v-b-modal:copy-history-modal>
<Icon icon="copy" class="mr-1" />
<span v-localize>Copy this History</span>
</b-dropdown-item>
<b-dropdown-item v-b-modal:delete-history-modal>
<Icon icon="trash" class="mr-1" />
<span v-localize>Delete this History</span>
</b-dropdown-item>
<b-dropdown-item v-b-modal:purge-history-modal>
<Icon icon="burn" class="mr-1" />
<span v-localize>Purge this History</span>
</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item @click="iframeRedirect('/history/resume_paused_jobs?current=True')">
<Icon icon="play" class="mr-1" />
<span v-localize>Resume Paused Jobs</span>
</b-dropdown-item>
<b-dropdown-item @click="iframeRedirect('/workflow/build_from_current_history')">
<Icon icon="file-export" class="mr-1" />
<span v-localize>Extract Workflow</span>
</b-dropdown-item>
<b-dropdown-item @click="backboneRoute('/histories/show_structure')">
<Icon icon="code-branch" class="mr-1" />
<span v-localize>Show Structure</span>
</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item @click="backboneRoute('/histories/sharing', { id: history.id })">
<Icon icon="share-alt" class="mr-1" />
<span v-localize>Share or Publish</span>
</b-dropdown-item>
<b-dropdown-item @click="backboneRoute('/histories/permissions', { id: history.id })">
<Icon icon="user-lock" class="mr-1" />
<span v-localize>Set Permissions</span>
</b-dropdown-item>
<b-dropdown-item v-b-modal:history-privacy-modal>
<Icon icon="lock" class="mr-1" />
<span v-localize>Make Private</span>
</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item @click="backboneRoute('/histories/citations', { id: history.id })">
<Icon icon="stream" class="mr-1" />
<span v-localize>Export Tool Citations</span>
</b-dropdown-item>
<b-dropdown-item @click="backboneRoute(history.exportLink)">
<Icon icon="file-archive" class="mr-1" />
<span v-localize>Export History to File</span>
</b-dropdown-item>
</b-dropdown>
<!-- #region modals -->
<CopyHistoryModal id="copy-history-modal" :history="history" />
<b-modal
id="history-privacy-modal"
title="Make History Private"
title-tag="h2"
@ok="$emit('secureHistory', history)"
>
<p v-localize>
This will make all the data in this history private (excluding library datasets), and will set
permissions such that all new data is created as private. Any datasets within that are currently shared
will need to be re-shared or published. Are you sure you want to do this?
</p>
</b-modal>
<b-modal id="delete-history-modal" title="Delete History?" title-tag="h2" @ok="$emit('deleteHistory', history)">
<p v-localize>Really delete the current history?</p>
</b-modal>
<b-modal
id="purge-history-modal"
title="Permanently Delete History?"
title-tag="h2"
@ok="$emit('purgeHistory', history)"
>
<p v-localize>Really delete the current history permanently? This cannot be undone.</p>
</b-modal>
<!-- #endregion -->
</div>
</template>
<script>
import { History } from "./model";
import CopyHistoryModal from "./CopyModal";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
export default {
mixins: [legacyNavigationMixin],
components: {
CopyHistoryModal,
},
props: {
history: { type: History, required: true },
},
};
</script>
@@ -0,0 +1,106 @@
<template>
<b-modal ref="modal" v-on="$listeners" v-bind="$attrs" :title="'Switch to History' | l">
<b-form-group :description="'Filter histories' | l">
<b-input v-model="filter" :placeholder="'Search Filter' | l" />
</b-form-group>
<b-table
ref="history-list"
striped
hover
sticky-header="50vh"
v-model="currentRows"
primary-key="id"
:fields="fields"
:filter="filter"
:items="histories"
:per-page="perPage"
:current-page="currentPage"
:selectable="true"
select-mode="single"
selected-variant="success"
@row-selected="switchToHistory"
>
<template v-slot:cell(tags)="row">
<stateless-tags :value="row.item.tags" :disabled="true" />
</template>
<template v-slot:cell(update_time)="data">
<UtcDate :date="data.value" mode="elapsed" />
</template>
</b-table>
<template v-slot:modal-footer>
<b-pagination v-model="currentPage" :total-rows="filteredRowCount" :per-page="perPage"></b-pagination>
</template>
</b-modal>
</template>
<script>
import { StatelessTags } from "components/Tags";
import UtcDate from "components/UtcDate";
import { debounce } from "underscore";
export default {
components: {
StatelessTags,
UtcDate,
},
props: {
currentHistory: { type: Object, required: true },
histories: { type: Array, default: () => [] },
perPage: { type: Number, required: false, default: 50 },
},
data() {
return {
filter: null,
currentPage: 1,
currentRows: [],
};
},
computed: {
filteredRowCount() {
return this.currentRows.length;
},
selectedIndex() {
return this.currentRows.findIndex((h) => h.id == this.currentHistory.id);
},
},
created() {
this.fields = [
{ key: "name", sortable: true },
{ key: "tags", sortable: true },
{ key: "update_time", label: "Updated", sortable: true },
];
this.debounceSelectCurrentRow = debounce(this.selectCurrentRow, 100);
},
watch: {
currentRows() {
this.selectCurrentRow();
},
filteredRowCount(newVal, oldVal) {
if (newVal != oldVal) {
this.currentPage = 1;
}
},
selectedIndex(idx, oldIdx) {
if (idx != oldIdx) {
this.debounceSelectCurrentRow();
}
},
},
methods: {
switchToHistory(selected) {
if (selected.length == 1) {
this.$emit("selectHistory", selected[0]);
}
},
selectCurrentRow() {
const idx = this.selectedIndex;
const list = this.$refs["history-list"];
if (list && idx > -1) {
list.selectRow(idx);
}
},
},
};
</script>
@@ -1,5 +1,5 @@
<template>
<Tags :tags="history.tags" :store-key="storeKey" :tag-service="historyTagService" />
<Tags :tags="history.tags" :store-key="storeKey" :tag-service="historyTagService" :disabled="disabled" />
</template>
<script>
@@ -48,6 +48,7 @@ export default {
components: { Tags },
props: {
history: { type: History, required: true },
disabled: { type: Boolean, required: false, default: false },
},
computed: {
storeKey() {
+20 -39
View File
@@ -4,14 +4,17 @@ amateur-hour baby-tool like Bootstrap instead of taking the afternoon
to learn how CSS is supposed to work. -->
<template>
<section class="history d-flex flex-column">
<header class="p-2">
<!-- optional slot for injecting global navigation -->
<nav v-if="$slots.nav" class="top-nav pb-2">
<slot name="nav"></slot>
<section class="history beta d-flex flex-column">
<header>
<nav class="d-flex justify-content-between bg-secondary">
<slot name="globalNav">Global Nav Default</slot>
<slot name="localNav">Local nav Default</slot>
</nav>
<!-- details about the thing you're looking at -->
<slot name="details"></slot>
<div class="px-3 py-4 details">
<slot name="details"></slot>
</div>
</header>
<!-- warnings, messages, dirty limericks -->
@@ -20,12 +23,12 @@ to learn how CSS is supposed to work. -->
</section>
<!-- controls atop the list -->
<section v-if="$slots.listcontrols" class="p-2">
<section v-if="$slots.listcontrols" class="list-controls">
<slot name="listcontrols"></slot>
</section>
<!-- scrolling list -->
<section class="scroller flex-grow-1">
<section class="position-relative flex-grow-1 scroller">
<slot name="listing"></slot>
</section>
@@ -37,13 +40,8 @@ to learn how CSS is supposed to work. -->
</template>
<style lang="scss">
@import "scss/mixins.scss";
@import "scss/transitions.scss";
@import "scss/loadingBackground.scss";
/* TODO: css reset? */
.history {
.history.beta {
/* TODO: css reset? */
h1,
h2,
h3,
@@ -52,31 +50,14 @@ to learn how CSS is supposed to work. -->
h6 {
margin: 0;
}
}
/*
container css for first child of top-nav,
flex horizontally
.list-controls {
margin-bottom: 2px;
}
Vue can't put in multi-root slot contents so we have to do some kung-fu to get
the layout right on the top-nav. There will have to be an extra div container
around the contents of the nav slot, so we'll style that right here so the user
doesn't need to do it manually every time. */
.history > header > .top-nav > * {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.history .messages:empty {
display: none;
}
/* make sure scroller actually scrolls */
.history .scroller {
position: relative;
.list-controls:empty,
.messages:empty {
display: none;
}
}
</style>
+50 -28
View File
@@ -3,9 +3,14 @@
<!-- mousewheel moves up and down linearly, allows for fine-tuning of view by moving just a
few rows at a time -->
<div ref="listing" class="listing">
<ul>
<li v-for="(item, index) in itemWindow" :key="getItemKey(item, index)">
<slot :item="item" :index="index" :row-key="getItemKey(item, index)">
<ul class="list-unstyled m-0">
<li
v-for="(item, index) in itemWindow"
:key="getItemKey(item)"
:data-row-index="index"
:data-row-key="getItemKey(item)"
>
<slot :item="item" :index="index" :row-key="getItemKey(item)">
<pre>{{ item }}</pre>
</slot>
</li>
@@ -15,6 +20,7 @@
<!-- big blank scroll region uses scroll bar to drag view to entirely new region, calculated
by visual height of the scroller knob --->
<div
v-if="overlayActive"
v-show="showScroller"
ref="scrollSliderContainer"
class="scrollSliderContainer"
@@ -23,6 +29,12 @@
>
<div ref="scrollSlider"></div>
</div>
<!-- debugging overlay -->
<div v-if="debug" class="debugOverlay">
<pre>{{ $attrs }}</pre>
<pre>{{ debugProps }}</pre>
</div>
</div>
</template>
@@ -75,10 +87,26 @@ export default {
// width of scroll bar container
sbWidth: 30,
// whether to activate the 2nd scrollbar which does a fast-seek on large histories
overlayActive: true,
};
},
computed: {
debugProps() {
return {
manualStartIndex: this.manualStartIndex,
keyField: this.keyField,
contentsLength: this.contents.length,
startKeyIndex: this.startKeyIndex,
totalMatches: this.totalMatches,
topRows: this.topRows,
bottomRows: this.bottomRows,
pageSize: this.pageSize,
};
},
// The index to start rendering content at, can be manually adjusted with
// the mouse wheel, and must be updated when new contents come in
itemStartIndex() {
@@ -144,7 +172,7 @@ export default {
// non-reactive data
this.suppressEvents = false;
this.suppressionTimeout = null;
this.debouncedUpdateCursor = debounce(this.updateCursor, 100);
// this.debouncedUpdateCursor = debounce(this.updateCursor, 100);
this.debouncedAdjustScrollTop = debounce(this.adjustScrollTop, 100);
},
@@ -193,7 +221,8 @@ export default {
// value which we send out on scrollPos
onScroll() {
this.debouncedUpdateCursor();
// this.debouncedUpdateCursor();
this.updateCursor();
},
// determines cursor from scrollTop, need to wait until after render to calculate
@@ -290,22 +319,15 @@ export default {
</script>
<style lang="scss">
@import "scss/mixins.scss";
.scrollContainer,
.listing,
.scrollSliderContainer {
@include absfill();
}
.scrollContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
.listing,
.scrollSliderContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.listing {
overflow-y: scroll;
z-index: 0;
@@ -326,14 +348,14 @@ export default {
}
}
ul {
list-style: none;
}
ul,
li {
padding: 0;
margin: 0;
.debugOverlay {
background: rgba(255, 255, 255, 0.75);
z-index: 2;
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: auto;
}
}
</style>
+32 -59
View File
@@ -1,82 +1,55 @@
<template>
<StateBtn
v-if="inState(STATES.RUNNING)"
:state="state"
icon="upload-symbol upload-icon fa fa-spinner fa-spin"
<IconButton
v-if="stateIcon"
v-b-tooltip
:title="theTitle"
:icon="stateIcon"
:variant="variant"
v-on="$listeners"
v-bind="$attrs"
/>
<StateBtn v-else-if="inState(STATES.QUEUED, STATES.NEW)" :state="state" icon="fas fa-clock" v-on="$listeners" />
<StateBtn v-else-if="inState(STATES.PAUSED)" :state="state" icon="fas fa-pause" v-on="$listeners" />
<StateBtn v-else-if="inState(STATES.ERROR)" :state="state" icon="fas fa-exclamation-triangle" v-on="$listeners" />
<StateBtn v-else-if="inState(STATES.MIXED)" :state="state" icon="fas fa-exclamation-circle" v-on="$listeners" />
<span v-else class="sr-only">
{{ state | localize }}
</span>
</template>
<script>
import capitalize from "underscore.string/capitalize";
import IconButton from "components/IconButton";
export const StateBtn = {
template: `
<b-button class="state-button"
v-b-tooltip
:title="theTitle | localize"
size="sm"
variant="link"
v-on="$listeners"
v-bind="$attrs">
<i :class="icon" />
<span class="sr-only">{{ theTitle | localize }}</span>
</b-button>
`,
export default {
inject: ["STATES"],
components: {
IconButton,
},
props: {
state: { type: String, required: true },
icon: { type: String, required: true },
title: { type: String, required: false, default: "" },
variant: { type: String, required: false, default: "link" },
},
inject: ["STATES"],
computed: {
stateIcon() {
if (this.inState(this.STATES.QUEUED, this.STATES.NEW)) {
return "clock";
}
if (this.inState(this.STATES.PAUSED)) {
return "pause";
}
if (this.inState(this.STATES.ERROR)) {
return "exclamation-triangle";
}
if (this.inState(this.STATES.MIXED)) {
return "exclamation-circle";
}
if (this.inState(this.STATES.OK)) {
return "check";
}
return null;
},
theTitle() {
return this.title || capitalize(this.state);
},
},
};
export const StatusIcon = {
components: {
StateBtn,
},
props: {
state: { type: String, required: true },
},
inject: ["STATES"],
methods: {
inState(...states) {
return states.includes(this.state);
},
},
};
export default StatusIcon;
</script>
<style lang="css">
/* Override ham-fisted base css rules */
button.state-button,
button.state-button.disabled,
button.state-button:disabled,
button.state-button:focus,
button.state-button:hover,
button.state-button:not(:disabled):active {
background-color: transparent !important;
border-style: none !important;
box-shadow: none !important;
}
</style>
@@ -10,13 +10,14 @@ import { bulkCacheDscContent } from "./db";
import { SearchParams } from "../model/SearchParams";
import { prependPath } from "./workerConfig";
import { summarizeCacheOperation, dateStore } from "./loadHistoryContents";
import { show } from "utils/observable";
/**
* Load collection content (drill down)
* Params: contents_url + search params + element_index
*/
export const loadDscContent = (cfg = {}) => {
// const { onceEvery = 10 * 1000 } = cfg;
const { debug = false } = cfg;
return publish((inputs$) => {
const url$ = inputs$.pipe(nth(0));
@@ -24,6 +25,7 @@ export const loadDscContent = (cfg = {}) => {
return inputs$.pipe(
map(buildDscContentUrl),
map(prependPath),
show(debug, (url) => console.log("Sending collection request:", url)),
requestWithUpdateTime({ dateStore }),
pluck("response"),
withLatestFrom(url$),
@@ -1,42 +0,0 @@
import { pipe } from "rxjs";
import { map } from "rxjs/operators";
/**
* Bust incoming numeric source values into blocks of designated size.
*
* @param {*} chunkSize
* @param {*} ceil
*/
// prettier-ignore
export const chunk = (chunkSize, ceil = false) => pipe(
map((chunkMe) => {
const rawVal = chunkMe / chunkSize;
return chunkSize * (ceil ? Math.ceil(rawVal) : Math.floor(rawVal));
})
);
/**
* Change one parameter to be an multiple of indicated block size. Used to
* regulate the URLs we send to the server so that some will be cached. Pos is
* the position in the combined inputs array, and the chunkSize is the size of
* the block to break the value into.
*
* Ex: Source Inputs [ x, y, z, 750 ],
* chunkParam(3, 200)
* Results in [ x, y, z, 600]
*
* @param {integer} pos Input array parameter number to chunk
* @param {integer} chunkSize Size of chunks
* @param {Boolean} ceil Math.ceil or Math.floor chunk value
*/
// prettier-ignore
export const chunkParam = (pos, chunkSize, ceil = false) => pipe(
map((inputs) => {
const chunkMe = inputs[pos];
const rawVal = 1.0 * chunkMe / chunkSize;
const chunkedVal = chunkSize * (ceil ? Math.ceil(rawVal) : Math.floor(rawVal));
const newInputs = inputs.slice();
newInputs[pos] = chunkedVal;
return newInputs;
})
);
@@ -5,6 +5,7 @@
*/
import { Content } from "./Content";
import { scrubModelProps } from "utils/safeAssign";
export class Dataset extends Content {
getUrl(urlType) {
@@ -40,4 +41,28 @@ export class Dataset extends Content {
const { display_apps = [], display_types = [] } = this;
return [...display_apps, ...display_types];
}
clone() {
const newProps = cleanDatasetProps(this);
return new Dataset(newProps);
}
patch(newProps) {
const cleanProps = cleanDatasetProps({ ...this, ...newProps });
return new Dataset(cleanProps);
}
equals(other) {
return Dataset.equals(this, other);
}
}
Dataset.equals = function (a, b) {
return JSON.stringify(a) == JSON.stringify(b);
};
const scrubber = scrubModelProps(Dataset);
export const cleanDatasetProps = (props = {}) => {
const cleanProps = JSON.parse(JSON.stringify(props));
return scrubber(cleanProps);
};
@@ -7,11 +7,11 @@
import { Content } from "./Content";
import { JobStateSummary } from "./JobStateSummary";
import { scrubModelProps } from "utils/safeAssign";
export class DatasetCollection extends Content {
loadProps(raw = {}) {
if (!raw.contents_url) {
// console.log("ouch", raw);
throw new Error("missing contents_url", raw);
}
super.loadProps(raw);
@@ -64,8 +64,28 @@ export class DatasetCollection extends Content {
get jobSummary() {
return new JobStateSummary(this);
}
clone() {
const newProps = cleanDscProps(this);
return new DatasetCollection(newProps);
}
patch(newProps) {
const cleanProps = cleanDscProps({ ...this, ...newProps });
return new DatasetCollection(cleanProps);
}
equals(other) {
return DatasetCollection.equals(this, other);
}
}
DatasetCollection.equals = function (a, b) {
return JSON.stringify(a) == JSON.stringify(b);
};
const scrubber = scrubModelProps(DatasetCollection);
export const cleanDscProps = (props = {}) => {
const cleanProps = JSON.parse(JSON.stringify(props));
return scrubber(cleanProps);
};
@@ -1,4 +1,5 @@
import { dateMixin, ModelBase } from "./ModelBase";
import { scrubModelProps } from "utils/safeAssign";
export class History extends dateMixin(ModelBase) {
// not deleted
@@ -36,8 +37,32 @@ export class History extends dateMixin(ModelBase) {
}
return status.join(", ");
}
get exportLink() {
return `histories/${this.id}/export`;
}
clone() {
const newProps = cleanHistoryProps(this);
return new History(newProps);
}
patch(newProps) {
const cleanProps = cleanHistoryProps({ ...this, ...newProps });
return new History(cleanProps);
}
equals(other) {
return History.equals(this, other);
}
}
History.equals = function (a, b) {
return JSON.stringify(a) == JSON.stringify(b);
};
const scrubber = scrubModelProps(History);
export const cleanHistoryProps = (props = {}) => {
const cleanProps = JSON.parse(JSON.stringify(props));
return scrubber(cleanProps);
};
@@ -0,0 +1,101 @@
import { History, cleanHistoryProps } from "./History";
describe("History model", () => {
const initialProps = {
id: "abc123",
name: "Foo",
tags: ["a", "b", "c"],
annotation: "I am a test history",
};
const h = new History(initialProps);
describe("clone", () => {
const c = h.clone();
it("must be able to clone itself for use in editors", () => {
// should be == but not ===
expect(h).not.toBe(c);
expect(c).toBeInstanceOf(History);
expect(History.equals(h, c)).toBeTruthy();
});
it("should have the same props before and after cloning", () => {
expect(h.id).toEqual(c.id);
expect(h.name).toEqual(c.name);
expect(h.tags).toEqual(c.tags);
expect(h.annotation).toEqual(c.annotation);
});
});
describe("cleanHistoryProps", () => {
it("should ignore computed prop names during cloning", () => {
const sloppyProps = {
...initialProps,
// these are all computed values in the history class which are defined by getters,
// if we feed thsm into the constructor they should be ignored
statusDescription: "123",
totalItems: Number.POSITIVE_INFINITY,
hidItems: "four",
active: "The Fonz",
};
const cleanProps = cleanHistoryProps(sloppyProps);
expect(cleanProps).toEqual(initialProps);
expect(cleanProps).not.toBe(initialProps);
expect(cleanProps).not.toHaveProperty("statusDescription");
expect(cleanProps).not.toHaveProperty("totalItems");
expect(cleanProps).not.toHaveProperty("hidItems");
expect(cleanProps).not.toHaveProperty("active");
const h = new History(cleanProps);
expect(h.statusDescription).not.toEqual(sloppyProps.statusDescription);
});
});
describe("patch", () => {
const patchVals = {
name: "i was patched",
annotation: "patched annotation",
tags: ["foo", "bar", "blech"],
};
const clone = h.patch(patchVals);
it("should return a new instance of History", () => {
expect(h).not.toBe(clone);
expect(clone).toBeInstanceOf(History);
expect(History.equals(h, clone)).toBeFalsy();
});
it("should patch in passed properties", () => {
expect(clone.name).toEqual(patchVals.name);
expect(clone.annotation).toEqual(patchVals.annotation);
expect(clone.tags).toEqual(patchVals.tags);
});
});
describe("equivalence comparison", () => {
it("should recognize different props", () => {
const hDifferent = new History({ id: "def345" });
expect(History.equals(h, hDifferent)).toBeFalsy();
});
it("equivalence should work fine with cloned histories", () => {
const clone = h.clone();
// different object
expect(h).not.toBe(clone);
// but equivalent because props the same
expect(History.equals(h, clone)).toBeTruthy();
});
it("should work with patch", () => {
const clone = h.patch({ id: "floobar" });
// different object
expect(h).not.toBe(clone);
// not equivalent because different val
expect(History.equals(h, clone)).toBeFalsy();
});
});
});
+3 -1
View File
@@ -8,7 +8,9 @@ import { bulkContentUpdate, getAllContentByFilter } from "./queries";
export const updateSelectedContent = (updates) => async (history, type_ids) => {
// single ajax call with wierd syntax because.... reasons
const changes = await bulkContentUpdate(history, type_ids, updates);
await bulkCacheContent(changes);
const cacheResult = await bulkCacheContent(changes, true);
console.log("cacheResult", changes, cacheResult);
return cacheResult;
};
export const hideSelectedContent = updateSelectedContent({
+14 -7
View File
@@ -8,6 +8,7 @@
import axios from "axios";
import moment from "moment";
import { prependPath } from "utils/redirect";
import { History } from "./History";
// #region setup & utils
@@ -87,9 +88,10 @@ const stdHistoryParams = {
* Return list of available histories
*/
export async function getHistoryList() {
const params = { view: "summary" };
const params = { view: "summary", keys: "size" };
const response = await api.get("/histories", { params });
return doResponse(response);
const rawList = doResponse(response);
return rawList.map((props) => new History(props));
}
/**
@@ -101,7 +103,8 @@ export async function getHistoryById(id, since) {
const sinceParam = since !== undefined ? moment.utc(since).toISOString() : null;
const url = sinceParam ? `${path}?q=update_time-gt&qv=${sinceParam}` : path;
const response = await api.get(url, { params: stdHistoryParams });
return doResponse(response);
const props = doResponse(response);
return new History(props);
}
/**
@@ -121,7 +124,8 @@ export async function createNewHistory(props = {}) {
if (!id) {
throw new Error("failed to create and select new history");
}
return doResponse(createResponse);
const newHistoryProps = doResponse(createResponse);
return new History(newHistoryProps);
}
/**
@@ -139,7 +143,8 @@ export async function cloneHistory(history, name, copyAll) {
current: true,
};
const response = await api.post(url, payload, { params: stdHistoryParams });
return doResponse(response);
const clonedProps = doResponse(response);
return new History(clonedProps);
}
/**
@@ -171,7 +176,8 @@ export async function undeleteHistoryById(id) {
export async function updateHistoryFields(id, payload) {
const url = `/histories/${id}`;
const response = await api.put(url, payload, { params: stdHistoryParams });
return doResponse(response);
const props = doResponse(response);
return new History(props);
}
/**
@@ -202,7 +208,8 @@ export async function getCurrentHistoryFromServer() {
const response = await api.get(url, {
baseURL: prependPath("/"), // old api doesn't use api path
});
return doResponse(response);
const props = doResponse(response);
return new History(props);
}
export async function setCurrentHistoryOnServer(history_id) {
@@ -1,10 +1,14 @@
import { DatasetCollection } from "../../model";
import { SearchParams, DatasetCollection } from "../../model";
import { ContentProvider, processContentStreams } from "../ContentProvider";
import { collectionPayload } from "./collectionPayload";
export default {
mixins: [ContentProvider],
props: {
params: { type: SearchParams, default: () => new SearchParams({ showHidden: false }) },
},
computed: {
dsc() {
if (this.parent instanceof DatasetCollection) {
@@ -1,5 +1,5 @@
import { of, Observable, Subject, partition, merge } from "rxjs";
import { tap, map, pluck, switchMap, publish, distinctUntilChanged, share } from "rxjs/operators";
import { tap, map, pluck, switchMap, publish, distinctUntilChanged, share, throttleTime } from "rxjs/operators";
import { chunk } from "utils/observable";
import { SearchParams } from "../../model/SearchParams";
import { loadDscContent } from "../../caching";
@@ -13,6 +13,8 @@ export const collectionPayload = (cfg = {}) => {
pageSize = SearchParams.pageSize,
debug = false,
loadingEvents$ = new Subject(),
debouncePeriod = 100,
chunkSize = pageSize
} = cfg;
const { totalElements: totalMatches, contents_url } = dsc;
@@ -34,7 +36,7 @@ export const collectionPayload = (cfg = {}) => {
// chunk the input to the server request so we don't request for every single mouse move
const chunked$ = elementIndex$.pipe(
chunk(pageSize, false),
chunk(chunkSize),
distinctUntilChanged(),
);
@@ -48,6 +50,7 @@ export const collectionPayload = (cfg = {}) => {
);
const serverLoad$ = pagination$.pipe(
throttleTime(debouncePeriod, undefined, { trailing: true }),
switchMap(pagination => of([contents_url, filters, pagination]).pipe(
tap(() => loadingEvents$.next(true)),
loadDscContent({ debug }),
@@ -14,7 +14,7 @@ import {
timeoutWith,
ignoreElements,
} from "rxjs/operators";
import { chunk, show, debounceBurst, shareButDie } from "utils/observable";
import { show, debounceBurst, shareButDie } from "utils/observable";
import { SearchParams } from "../../model/SearchParams";
import { processContentUpdate, newUpdateMap, buildContentResult, getKeyForUpdateMap } from "./aggregation";
import { SEEK } from "../../caching/enums";
@@ -41,7 +41,7 @@ export const aggregateCacheUpdates = (monitor, cfg = {}) => (src$) => {
// interval in which we create new monitors
// if we chunk the inputs then we won't make as many
// might be easier on memory
inputChunk = Math.floor(pageSize / 2),
// inputChunk = Math.floor(pageSize / 2),
// reduces duplicate updates to the skiplist
// which can be expensive
@@ -59,7 +59,7 @@ export const aggregateCacheUpdates = (monitor, cfg = {}) => (src$) => {
// avoids creating a new monitor for every single key emission
// chunk to ceiling if descending, floor if ascending
const monitorInputKey$ = targetKey$.pipe(
chunk(inputChunk, keyDirection == SEEK.DESC),
// chunk(inputChunk, keyDirection == SEEK.DESC, "monitorInputKey"),
distinctUntilChanged(),
show(debug, (key) => console.log("monitorInputKey", key)),
);
@@ -76,10 +76,10 @@ export const aggregateCacheUpdates = (monitor, cfg = {}) => (src$) => {
// when we're just going to change it again shortly
const groupedUpdates$ = cacheUpdates$.pipe(
groupBy(
update => update.key,
update => update,
update => update.key,
update => update,
updateByKey$ => updateByKey$.pipe(
timeoutWith(updateGroupLifetime, EMPTY),
timeoutWith(updateGroupLifetime, EMPTY),
ignoreElements()
)
),
@@ -104,7 +104,7 @@ export const aggregateCacheUpdates = (monitor, cfg = {}) => (src$) => {
withLatestFrom(targetKey$),
);
// query skiplist with scroll position (transformed into a key from the skiplist)
// query skiplist with scroll position (transformed into a key from the skiplist)
// to produce list of nearby content
const contentList$ = contentListInputs$.pipe(
map(buildContentResult({ pageSize, keyDirection, getKey })),
@@ -1,9 +1,9 @@
/**
* General plumbing for the 2 types of content providers. This funcction emits 3 observables:
* General plumbing for the 2 types of content providers. This function returns 3 observables:
* loading, scrolling, and payload. Payload's the most important and the operator that genrates the
* payload from the inputs is passed in as a parameter to this factory.
*/
import { debounceTime, distinctUntilChanged, share, startWith, switchMap } from "rxjs/operators";
import { distinctUntilChanged, share, startWith, switchMap } from "rxjs/operators";
import { activity, whenAny, show } from "utils/observable";
import { propMatch } from "./helpers";
import { SearchParams, ScrollPos } from "../../model";
@@ -23,7 +23,7 @@ import { Subject } from "rxjs";
*/
// prettier-ignore
export function processContentStreams(payloadOperator, sources = {}, settings = {}) {
const { debouncePeriod, debug = false } = settings;
const { debouncePeriod, debug = true } = settings;
// clean incoming source streams
const parent$ = sources.parent$.pipe(
@@ -35,7 +35,7 @@ export function processContentStreams(payloadOperator, sources = {}, settings =
show(debug, (params) => console.log('processContentStreams: params changed', params)),
);
const pos$ = sources.scrollPos$.pipe(
debounceTime(debouncePeriod),
// debounceTime(debouncePeriod),
show(debug, (pos) => console.log('processContentStreams: pos changed', pos)),
distinctUntilChanged(ScrollPos.equals),
);
@@ -48,7 +48,7 @@ export function processContentStreams(payloadOperator, sources = {}, settings =
activity(debouncePeriod),
startWith(true)
);
const resetPos$ = new Subject();
// The actual loader, when history or params change we poll against the api and
@@ -1,3 +1,5 @@
import { saveSet, loadSet } from "./setCache";
export default {
props: {
scopeKey: { type: String, required: true },
@@ -8,6 +10,11 @@ export default {
items: new Set(),
};
},
computed: {
expandedCount() {
return this.items.size;
},
},
methods: {
isExpanded(item) {
const key = this.getItemKey(item);
@@ -29,11 +36,23 @@ export default {
this.reset();
}
},
items(newSet) {
saveSet(this.key, newSet);
},
},
created() {
this.key = "expanded-history-items";
const cachedSet = loadSet(this.key);
if (cachedSet) {
this.items = cachedSet;
}
},
render() {
return this.$scopedSlots.default({
isExpanded: this.isExpanded,
setExpanded: this.setExpanded,
collapseAll: this.reset,
expandedCount: this.expandedCount,
});
},
};
@@ -34,10 +34,10 @@ import { defaultPayload } from "../ContentProvider";
*/
// prettier-ignore
export const contentPayload = (cfg = {}) => {
const {
parent: history,
const {
parent: history,
filters = new SearchParams(),
pageSize = SearchParams.pageSize,
pageSize = SearchParams.pageSize,
debouncePeriod = 250,
loadingTimeout = 2000,
disablePoll = false,
@@ -45,6 +45,7 @@ export const contentPayload = (cfg = {}) => {
// optional feedback indicators
loadingEvents$ = new Subject(),
resetPos$ = new Subject(),
chunkSize = pageSize
} = cfg;
// These running totals are shared between instances of content payload because a lot of the
@@ -78,14 +79,15 @@ export const contentPayload = (cfg = {}) => {
filter(hid => !isNaN(hid)),
share(),
);
// #endregion
// #region server loading
const serverHid$ = hid$.pipe(
chunk(pageSize, true),
chunk({ chunkSize, debug, label: "serverHid" }),
distinctUntilChanged(),
debounceTime(debouncePeriod),
show(debug, hid => console.log("serverHid", hid)),
share(),
);
@@ -109,7 +111,7 @@ export const contentPayload = (cfg = {}) => {
mergeAll(),
debounceTime(100),
);
const cacheMonitor$ = cacheHid$.pipe(
watchHistoryContents({ history, filters, pageSize, debouncePeriod, debug }),
show(debug, (result) => console.log("cacheMonitor.contents (hid)", result.contents.map(o => o.hid))),
@@ -133,7 +135,7 @@ export const contentPayload = (cfg = {}) => {
// #endregion
// #region Reposition View to accomodate recent updates
// When new updates to the history come in, we should shift the scrollPos so that we can see
// them. Without some kind of explicit move, the system will think they're just random
// updates that are happening outside the region of interest, and there would be no
@@ -151,7 +153,7 @@ export const contentPayload = (cfg = {}) => {
const fudge = 2;
const scrollNearLastTop = Math.abs(hid - lastResponse.maxContentHid) < fudge;
const scrollerAtTop = scrollerInDefault || scrollNearLastTop;
// console.group("should i reposition?");
// console.log("updatesAtTop", updatesAtTop);
// console.log("scrollerAtTop", scrollerAtTop);
@@ -190,7 +192,7 @@ export const contentPayload = (cfg = {}) => {
const sub = payload$.subscribe(obs);
// Tie stat feedback subscriptions to main payload sub
// Make sure not to combineLatest these subject accumulators or we'll
// Make sure not to combineLatest these subject accumulators or we'll
// end up with an infinite loop
sub.add(newCursorToHid$.subscribe(cursorToHid$));
sub.add(newHidToTopRows$.subscribe(hidToTopRows$));
@@ -43,6 +43,10 @@ export default {
"loadUserHistories",
"secureHistory",
]),
updateCurrentHistory(updates) {
this.updateHistory({ ...updates, id: this.currentHistory.id });
},
},
watch: {
// when user changes reload histories
@@ -81,7 +85,11 @@ export default {
// save new history params should be an object with an id property and any additional
// properties that are to be updated on the server. A full history object is not required
updateHistory: this.updateHistory,
updateCurrentHistory: () => this.updateHistory(this.currentHistory),
updateCurrentHistory: this.updateCurrentHistory,
// also conform to .sync event name format
"update:history": this.updateHistory,
"update:currentHistory": this.updateCurrentHistory,
// delete history then clear currentHistoryId
deleteHistory: (history) => this.deleteHistory({ history }),
@@ -0,0 +1,25 @@
// Caches expanded/selected Sets in sessionStorage
// Putting this in separate file for ease of mocking
export function saveSet(key, setObject) {
try {
const setToArray = Array.from(setObject);
const json = JSON.stringify(setToArray);
sessionStorage.setItem(key, json);
} catch (err) {
console.log("error saving stuff to cache", err);
sessionStorage.removeItem(key);
}
}
export function loadSet(key) {
try {
const arrJson = sessionStorage.getItem(key) || [];
const cachedArray = JSON.parse(arrJson);
return Array.isArray(cachedArray) ? new Set(cachedArray) : new Set();
} catch (err) {
console.log("erorr loading cached list", err);
sessionStorage.removeItem(key);
return null;
}
}
+51
View File
@@ -0,0 +1,51 @@
<template>
<b-button
class="galaxy-icon-button"
v-bind="$attrs"
v-on="$listeners"
:title="title"
:size="size"
:pressed="pressed"
:disabled="disabled"
:variant="localVariant"
>
<Icon :icon="icon" />
<span class="sr-only">{{ title | l }}</span>
</b-button>
</template>
<script>
export default {
props: {
icon: { type: [String, Array, Object], required: true },
title: { type: String, required: true },
size: { type: String, required: false, default: "sm" },
pressed: { type: Boolean, required: false, default: false },
disabled: { type: Boolean, required: false, default: false },
// overrides variant set by pressed/disabled
variant: { type: String, required: false, default: null },
},
computed: {
localVariant() {
if (this.variant) {
// override with prop
return this.variant;
}
if (this.disabled) {
return "secondary";
}
return this.pressed ? "info" : "secondary";
},
},
};
</script>
<style lang="scss">
// removes irritating bootstrap outline
.galaxy-icon-button.btn {
box-shadow: none !important;
&.disabled > svg {
opacity: 0.5;
}
}
</style>
@@ -59,7 +59,10 @@
<font-awesome-icon v-if="row.item.shared" v-b-tooltip.hover title="Shared" icon="share-alt" />
</template>
<template v-slot:cell(show_in_tool_panel)="row">
<b-form-checkbox v-model="row.item.show_in_tool_panel" @change="bookmarkWorkflow(row.item)" />
<b-form-checkbox
:checked="row.item.show_in_tool_panel"
@change="(checked) => bookmarkWorkflow(row.item, checked)"
/>
</template>
<template v-slot:cell(update_time)="data">
<UtcDate :date="data.value" mode="elapsed" />
@@ -192,14 +195,13 @@ export default {
executeWorkflow: function (workflow) {
window.location = `${this.root}workflows/run?id=${workflow.id}`;
},
bookmarkWorkflow: function (workflow) {
bookmarkWorkflow: function (workflow, checked) {
// This reloads the whole page, so that the workflow appears in the tool panel.
// Ideally we would notify only the tool panel of a change
const id = workflow.id;
const show_in_tool_panel = workflow.show_in_tool_panel;
const tags = workflow.tags;
const data = {
show_in_tool_panel: !show_in_tool_panel,
show_in_tool_panel: checked,
tags: tags,
};
this.services
+93
View File
@@ -0,0 +1,93 @@
import { library } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faArrowUp,
faBolt,
faBurn,
faChartArea,
faCheckSquare,
faClock,
faCodeBranch,
faColumns,
faCompress,
faCopy,
faDownload,
faEllipsisH,
faEllipsisV,
faExchangeAlt,
faEye,
faFileArchive,
faFileExport,
faFilter,
faFolder,
faInfoCircle,
faList,
faLink,
faLock,
faPause,
faPen,
faPlay,
faPlus,
faQuestion,
faShareAlt,
faStream,
faTags,
faTrash,
faTrashRestore,
faExclamationTriangle,
faUndo,
faUserLock,
faWrench,
} from "@fortawesome/free-solid-svg-icons";
library.add(
faArrowUp,
faBolt,
faBurn,
faChartArea,
faCheckSquare,
faClock,
faCodeBranch,
faColumns,
faCompress,
faCopy,
faDownload,
faEllipsisH,
faEllipsisV,
faExchangeAlt,
faEye,
faFileArchive,
faFileExport,
faFilter,
faFolder,
faInfoCircle,
faLink,
faList,
faLock,
faPause,
faPen,
faPlay,
faPlus,
faQuestion,
faShareAlt,
faStream,
faTags,
faTrash,
faTrashRestore,
faExclamationTriangle,
faUndo,
faUserLock,
faWrench
);
export const iconMixin = {
components: {
Icon: FontAwesomeIcon,
},
};
export const iconPlugin = {
install(Vue) {
Vue.mixin(iconMixin);
},
};
+1
View File
@@ -1,6 +1,7 @@
export { eventHubMixin, eventHubPlugin } from "./eventHub";
export { vueRxShortcutPlugin, vueRxShortcuts } from "./vueRxShortcuts";
export { localizationPlugin } from "./localization";
export { iconPlugin, iconMixin } from "./icons";
// I'm explicitly ommitting this because of its ugly dependencies, I want us to have to import the
// legacy nav features manually (when required) rather than it coming in every time somebody accesses
+4 -1
View File
@@ -6,7 +6,7 @@ import Vue from "vue";
import Vuex from "vuex";
import BootstrapVue from "bootstrap-vue";
import store from "../store";
import { eventHubPlugin, localizationPlugin, vueRxShortcutPlugin } from "components/plugins";
import { eventHubPlugin, localizationPlugin, vueRxShortcutPlugin, iconPlugin } from "components/plugins";
Vue.use(Vuex);
@@ -24,6 +24,9 @@ Vue.use(localizationPlugin);
// rxjs utilities
Vue.use(vueRxShortcutPlugin);
// font-awesome svg icon registration/loading
Vue.use(iconPlugin);
export const mountVueComponent = (ComponentDefinition) => {
const component = Vue.extend(ComponentDefinition);
return (propsData, el) => new component({ store, propsData, el });
+14 -4
View File
@@ -3,14 +3,24 @@ import { map } from "rxjs/operators";
/**
* Bust incoming numeric source values into blocks of designated size.
*
* @param {*} chunkSize
* @param {Number|Object} cfg object or numeric chunksize
* @param {*} ceil
*/
// prettier-ignore
export const chunk = (chunkSize, ceil = false) => {
export const chunk = (cfg) => {
const settings = Number.isInteger(cfg) ? { chunkSize: Math.round(cfg) } : cfg;
const { chunkSize = 0, ceil = false, debug = false, label } = settings;
if (chunkSize == 0) {
throw new Error("Please provide a chunk size");
}
return map((chunkMe) => {
const rawVal = chunkMe / chunkSize;
return chunkSize * (ceil ? Math.ceil(rawVal) : Math.floor(rawVal));
const rawVal = 1.0 * chunkMe / chunkSize;
const result = chunkSize * (ceil ? Math.ceil(rawVal) : Math.floor(rawVal));
if (debug) {
console.log(`chunk: ${label}`, chunkMe, result, settings);
}
return result;
})
};
+19
View File
@@ -9,3 +9,22 @@ export function safeAssign(target, source = {}) {
.filter((prop) => Object.prototype.hasOwnProperty.call(target, prop))
.forEach((prop) => (target[prop] = source[prop]));
}
/**
* Creates a reusable utility scrubber function that removes reserved initialization props from raw
* which are already defined by Klass. This lets us just stick one model instance into.
*
* Ex: const scrubber = scrubModelProps(History); *
* const newHistory = new History(scrubber(oldHistory));
*/
export function scrubModelProps(Klass) {
const restrictedKeys = new Set(Object.getOwnPropertyNames(Klass.prototype));
return (raw = {}) => {
const pairs = Object.entries(raw || {});
return pairs.reduce((clean, [propName, val]) => {
return restrictedKeys.has(propName) ? clean : { ...clean, [propName]: val };
}, {});
};
}
+3 -1
View File
@@ -7,6 +7,7 @@ import { createLocalVue, shallowMount } from "@vue/test-utils";
import { localizationPlugin } from "components/plugins/localization";
import { vueRxShortcutPlugin } from "components/plugins/vueRxShortcuts";
import { eventHubPlugin } from "components/plugins/eventHub";
import { iconPlugin } from "components/plugins/icons";
import BootstrapVue from "bootstrap-vue";
import Vuex from "vuex";
@@ -95,6 +96,7 @@ export function getLocalVue() {
localVue.use(localizationPlugin);
localVue.use(vueRxShortcutPlugin);
localVue.use(eventHubPlugin);
localVue.use(iconPlugin);
localVue.directive("b-tooltip", mockedDirective);
localVue.directive("b-popover", mockedDirective);
return localVue;
@@ -137,7 +139,7 @@ export const untilNthEmission = (src$, n = 1, safetyTimeout = 2000) => {
let result;
// prettier-ignore
src$.pipe(
take(n),
take(n),
takeUntil(timer(safetyTimeout))
).subscribe({
next: (val) => (result = val),
+37 -19
View File
@@ -1719,14 +1719,14 @@
"@nodelib/fs.scandir" "2.1.3"
fastq "^1.6.0"
"@nuxt/opencollective@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@nuxt/opencollective/-/opencollective-0.3.0.tgz#11d8944dcf2d526e31660bb69570be03f8fb72b7"
integrity sha512-Vf09BxCdj1iT2IRqVwX5snaY2WCTkvM0O4cWWSO1ThCFuc4if0Q/nNwAgCxRU0FeYHJ7DdyMUNSdswCLKlVqeg==
"@nuxt/opencollective@^0.3.2":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@nuxt/opencollective/-/opencollective-0.3.2.tgz#83cb70cdb2bac5fad6f8c93529e7b11187d49c02"
integrity sha512-XG7rUdXG9fcafu9KTDIYjJSkRO38EwjlKYIb5TQ/0WDbiTUTtUtgncMscKOYzfsY86kGs05pAuMOR+3Fi0aN3A==
dependencies:
chalk "^2.4.2"
consola "^2.10.1"
node-fetch "^2.6.0"
chalk "^4.1.0"
consola "^2.15.0"
node-fetch "^2.6.1"
"@sentry/browser@^5.20.1":
version "5.20.1"
@@ -3323,18 +3323,18 @@ boolbase@^1.0.0, boolbase@~1.0.0:
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
bootstrap-vue@^2.15.0:
version "2.15.0"
resolved "https://registry.yarnpkg.com/bootstrap-vue/-/bootstrap-vue-2.15.0.tgz#0dfc12c054496c0f10efed510da1def41697cf3c"
integrity sha512-ncxWkDG0mKFVot314wWKJELi+ESO7k6ngV//qvJFs9iVzlFI8Hx3rBVbpcPW2vrJ+0vitH8N2SOwn4fdQ3frMQ==
bootstrap-vue@^2.21.2:
version "2.21.2"
resolved "https://registry.yarnpkg.com/bootstrap-vue/-/bootstrap-vue-2.21.2.tgz#ec38f66c3a2205becccddb6158a991d96509ed0b"
integrity sha512-0Exe+4MZysqhZNXIKf4TzkvXaupxh9EHsoCRez0o5Dc0J7rlafayOEwql63qXv74CgZO8E4U8ugRNJko1vMvNw==
dependencies:
"@nuxt/opencollective" "^0.3.0"
bootstrap ">=4.5.0 <5.0.0"
"@nuxt/opencollective" "^0.3.2"
bootstrap ">=4.5.3 <5.0.0"
popper.js "^1.16.1"
portal-vue "^2.1.7"
vue-functional-data-merge "^3.1.0"
bootstrap@4.5.0, "bootstrap@>=4.5.0 <5.0.0":
bootstrap@4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.5.0.tgz#97d9dbcb5a8972f8722c9962483543b907d9b9ec"
integrity sha512-Z93QoXvodoVslA+PWNdk23Hze4RBYIkpb5h8I2HY2Tu2h7A0LpAgLcyrhrSUyo2/Oxm2l1fRZPs1e5hnxnliXA==
@@ -3344,6 +3344,11 @@ bootstrap@>=4.0.0-beta:
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.0.2.tgz#aff23d5e0e03c31255ad437530ee6556e78e728e"
integrity sha512-1Ge963tyEQWJJ+8qtXFU6wgmAVj9gweEjibUdbmcCEYsn38tVwRk8107rk2vzt6cfQcRr3SlZ8aQBqaD8aqf+Q==
"bootstrap@>=4.5.3 <5.0.0":
version "4.6.0"
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.6.0.tgz#97b9f29ac98f98dfa43bf7468262d84392552fd7"
integrity sha512-Io55IuQY3kydzHtbGvQya3H+KorS/M9rSNyfCGCg9WZ4pyT/lCxIlpJgG1GXW/PswzC84Tr2fBYi+7+jFVQQBw==
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -3826,6 +3831,14 @@ chalk@^4.0.0, chalk@~4.1.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
chalk@^4.1.0:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
dependencies:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
char-regex@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf"
@@ -4390,10 +4403,10 @@ connect@^3.7.0:
parseurl "~1.3.3"
utils-merge "1.0.1"
consola@^2.10.1:
version "2.10.1"
resolved "https://registry.yarnpkg.com/consola/-/consola-2.10.1.tgz#4693edba714677c878d520e4c7e4f69306b4b927"
integrity sha512-4sxpH6SGFYLADfUip4vuY65f/gEogrzJoniVhNUYkJHtng0l8ZjnDCqxxrSVRHOHwKxsy8Vm5ONZh1wOR3/l/w==
consola@^2.15.0:
version "2.15.3"
resolved "https://registry.yarnpkg.com/consola/-/consola-2.15.3.tgz#2e11f98d6a4be71ff72e0bdf07bd23e12cb61550"
integrity sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==
console-browserify@^1.1.0:
version "1.1.0"
@@ -10086,7 +10099,7 @@ node-fetch@^1.0.1:
encoding "^0.1.11"
is-stream "^1.0.1"
node-fetch@^2.2.0:
node-fetch@^2.2.0, node-fetch@^2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
@@ -11691,6 +11704,11 @@ prettier@^2.3.0:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.0.tgz#b6a5bf1284026ae640f17f7ff5658a7567fc0d18"
integrity sha512-kXtO4s0Lz/DW/IJ9QdWhAf7/NmPWQXkFr/r/WkR3vyI+0v8amTDxiaQSLzs8NBlytfLWX/7uQUMIW677yLKl4w==
pretty-bytes@^5.6.0:
version "5.6.0"
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
pretty-format@^25.2.1, pretty-format@^25.5.0:
version "25.5.0"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a"