scinote-web/app/javascript/vue/protocol/container.vue

523 lines
21 KiB
Vue

<template>
<div v-if="protocol.id" class="task-protocol">
<div ref="header" class="task-section-header ml-[-1rem] w-[calc(100%_+_2rem)] px-4 bg-sn-white sticky top-0 transition" v-if="!inRepository">
<div class="portocol-header-left-part grow" :class="{'overflow-hidden': headerSticked && moduleName}">
<template v-if="headerSticked && moduleName">
<i class="sn-icon sn-icon-navigator sci--layout--navigator-open cursor-pointer p-1.5 border rounded border-sn-light-grey mr-4"></i>
<div @click="scrollTop" class="task-section-title min-w-[5rem] cursor-pointer" :title="moduleName">
<h2 class="truncate leading-6">{{ moduleName }}</h2>
</div>
</template>
<template v-else>
<a class="task-section-caret" tabindex="0" role="button" data-toggle="collapse" href="#protocol-content" aria-expanded="true" aria-controls="protocol-content">
<i class="sn-icon sn-icon-right"></i>
<div class="task-section-title truncate">
<h2>{{ i18n.t('Protocol') }}</h2>
</div>
</a>
</template>
<div :class="{'hidden': headerSticked}">
<div class="my-module-protocol-status">
<!-- protocol status dropdown gets mounted here -->
</div>
</div>
</div>
<div class="actions-block">
<div class="protocol-buttons-group shrink-0 bg-sn-white">
<a v-if="urls.add_step_url"
class="btn btn-secondary icon-btn xl:!px-4"
:title="i18n.t('protocols.steps.new_step_title')"
@keyup.enter="addStep(steps.length)"
@click="addStep(steps.length)"
tabindex="0">
<span class="sn-icon sn-icon-new-task" aria-hidden="true"></span>
<span class="tw-hidden xl:inline">{{ i18n.t("protocols.steps.new_step") }}</span>
</a>
<template v-if="steps.length > 0">
<button :title="i18n.t('protocols.steps.collapse_label')" v-if="!stepCollapsed" class="btn btn-secondary icon-btn xl:!px-4" @click="collapseSteps" tabindex="0">
<i class="sn-icon sn-icon-collapse-all"></i>
<span class="tw-hidden xl:inline">{{ i18n.t("protocols.steps.collapse_label") }}</span>
</button>
<button v-else :title="i18n.t('protocols.steps.expand_label')" class="btn btn-secondary icon-btn xl:!px-4" @click="expandSteps" tabindex="0">
<i class="sn-icon sn-icon-expand-all"></i>
<span class="tw-hidden xl:inline">{{ i18n.t("protocols.steps.expand_label") }}</span>
</button>
</template>
<ProtocolOptions
v-if="protocol.attributes && protocol.attributes.urls"
:protocol="protocol"
@protocol:delete_steps="deleteSteps"
:canDeleteSteps="steps.length > 0 && urls.delete_steps_url !== null"
/>
<button class="btn btn-light icon-btn" data-toggle="modal" data-target="#print-protocol-modal" tabindex="0">
<span class="sn-icon sn-icon-printer" aria-hidden="true"></span>
</button>
<a v-if="steps.length > 0 && urls.reorder_steps_url"
class="btn btn-light icon-btn"
data-toggle="modal"
@click="startStepReorder"
@keyup.enter="startStepReorder"
:class="{'disabled': steps.length == 1}"
tabindex="0" >
<i class="sn-icon sn-icon-sort" aria-hidden="true"></i>
</a>
</div>
</div>
</div>
<div id="protocol-content" class="protocol-content collapse in" aria-expanded="true">
<div class="sci-divider" v-if="!inRepository"></div>
<div class="mb-4">
<div class="protocol-name mt-4" v-if="!inRepository">
<InlineEdit
v-if="urls.update_protocol_name_url"
:value="protocol.attributes.name"
:characterLimit="255"
:placeholder="i18n.t('my_modules.protocols.protocol_status_bar.enter_name')"
:allowBlank="!inRepository"
:attributeName="`${i18n.t('Protocol')} ${i18n.t('name')}`"
@update="updateName"
/>
<span v-else>
{{ protocol.attributes.name }}
</span>
</div>
<ProtocolMetadata v-if="protocol.attributes && protocol.attributes.in_repository" :protocol="protocol" @update="updateProtocol" @publish="startPublish"/>
<div :class="inRepository ? 'protocol-section protocol-information' : ''">
<div v-if="inRepository" id="protocol-description" class="protocol-section-header">
<div class="protocol-description-container">
<a class="protocol-section-caret"
role="button"
data-toggle="collapse"
href="#protocol-description-container"
aria-expanded="false"
aria-controls="protocol-description-container">
<i class="sn-icon sn-icon-right"></i>
<span id="protocolDescriptionLabel" class="protocol-section-title" data-e2e="e2e-TX-protocolTemplates-protocolDescription-title">
<h2>
{{ i18n.t("protocols.header.protocol_description") }}
</h2>
</span>
</a>
</div>
</div>
<div id="protocol-description-container"
class="text-base"
:class=" inRepository ? 'protocol-description collapse in' : ''"
data-e2e="e2e-IF-protocolTemplates-protocolDescription-content">
<div v-if="urls.update_protocol_description_url">
<Tinymce
:value="protocol.attributes.description"
:value_html="protocol.attributes.description_view"
:placeholder="i18n.t('my_modules.protocols.protocol_status_bar.empty_description_edit_label')"
:updateUrl="urls.update_protocol_description_url"
:objectType="'Protocol'"
:objectId="parseInt(protocol.id)"
:fieldName="'protocol[description]'"
:lastUpdated="protocol.attributes.updated_at"
:assignableMyModuleId="protocol.attributes.assignable_my_module_id"
:characterLimit="1000000"
@update="updateDescription"
/>
</div>
<div v-else-if="protocol.attributes.description_view" v-html="wrappedTables" class="view-text-element"></div>
<div v-else class="empty-protocol-description">
{{ i18n.t("protocols.no_text_placeholder") }}
</div>
</div>
</div>
</div>
<div :class="inRepository ? 'protocol-section protocol-steps-section protocol-information' : ''">
<div v-if="inRepository" id="protocol-steps" class="protocol-section-header">
<div class="protocol-steps-container">
<a class="protocol-section-caret" role="button" data-toggle="collapse" href="#protocol-steps-container" aria-expanded="false" aria-controls="protocol-steps-container">
<i class="sn-icon sn-icon-right"></i>
<span id="protocolStepsLabel" class="protocol-section-title" data-e2e="e2e-TX-protocol-templateSteps-title">
<h2>
{{ i18n.t("protocols.header.protocol_steps") }}
</h2>
</span>
</a>
</div>
</div>
<div class="sci-divider my-4" v-if="!inRepository"></div>
<div id="protocol-steps-container" :class=" inRepository ? 'protocol-steps collapse in' : ''">
<div v-if="urls.add_step_url && inRepository" class="py-5 flex flex-row gap-8 justify-between">
<a
class="btn btn-secondary"
:title="i18n.t('protocols.steps.new_step_title')"
data-e2e="e2e-BT-protocol-templateSteps-newStepTop"
@keyup.enter="addStep(steps.length)"
@click="addStep(steps.length)"
tabindex="0">
<span class="sn-icon sn-icon-new-task" aria-hidden="true"></span>
<span>{{ i18n.t("protocols.steps.new_step") }}</span>
</a>
<div v-if="steps.length > 0" class="flex justify-between items-center gap-4">
<button @click="collapseSteps" class="btn btn-secondary flex px-4" tabindex="0" data-e2e="e2e-BT-protocol-templateSteps-collapse">
<i class="sn-icon sn-icon-collapse-all"></i>
{{ i18n.t("protocols.steps.collapse_label") }}
</button>
<button @click="expandSteps" class="btn btn-secondary flex px-4" tabindex="0" data-e2e="e2e-BT-protocol-templateSteps-expand">
<i class="sn-icon sn-icon-expand-all"></i>
{{ i18n.t("protocols.steps.expand_label") }}
</button>
<a v-if="steps.length > 0 && urls.reorder_steps_url"
class="btn btn-light icon-btn"
data-toggle="modal"
data-e2e="e2e-BT-protocol-templateSteps-reorder"
@click="startStepReorder"
@keyup.enter="startStepReorder"
:class="{'disabled': steps.length == 1}"
tabindex="0" >
<i class="sn-icon sn-icon-sort" aria-hidden="true"></i>
</a>
</div>
</div>
<div class="protocol-steps pb-8">
<div v-for="(step, index) in steps" :key="step.id" class="step-block">
<div v-if="index > 0 && urls.add_step_url" class="insert-step" @click="addStep(index)" data-e2e="e2e-BT-protocol-templateSteps-insertStep">
<i class="sn-icon sn-icon-new-task"></i>
<span class="mr-3">{{ i18n.t("protocols.steps.add_step") }}</span>
</div>
<Step
ref="steps"
:step.sync="steps[index]"
@reorder="startStepReorder"
:inRepository="inRepository"
:stepToReload="stepToReload"
:activeDragStep="activeDragStep"
@step:delete="updateStepsPosition"
@step:update="updateStep"
@stepUpdated="refreshProtocolStatus"
@step:insert="updateStepsPosition"
@step:elements:loaded="stepToReload = null"
@step:move_element="reloadStep"
@step:attachemnts:loaded="stepToReload = null"
@step:move_attachment="reloadStep"
@step:drag_enter="dragEnter"
@step:collapsed="checkStepsState"
:reorderStepUrl="steps.length > 1 ? urls.reorder_steps_url : null"
:userSettingsUrl="userSettingsUrl"
:assignableMyModuleId="protocol.attributes.assignable_my_module_id"
/>
<div v-if="(index === steps.length - 1) && urls.add_step_url" class="insert-step" @click="addStep(index + 1)" data-e2e="e2e-BT-protocol-templateSteps-insertStep">
<i class="sn-icon sn-icon-new-task"></i>
<span class="mr-3">{{ i18n.t("protocols.steps.add_step") }}</span>
</div>
</div>
<div v-if="steps.length > 0 && urls.add_step_url && inRepository" class="py-5">
<a
class="btn btn-secondary"
:title="i18n.t('protocols.steps.new_step_title')"
data-e2e="e2e-BT-protocol-templateSteps-newStepBottom"
@keyup.enter="addStep(steps.length)"
@click="addStep(steps.length)"
tabindex="0">
<span class="sn-icon sn-icon-new-task" aria-hidden="true"></span>
<span>{{ i18n.t("protocols.steps.new_step") }}</span>
</a>
</div>
</div>
</div>
</div>
</div>
<ReorderableItemsModal v-if="reordering"
:title="i18n.t('protocols.reorder_steps.modal.title')"
:items="steps"
:includeNumbers="true"
@reorder="updateStepOrder"
@close="closeStepReorderModal"
/>
<PublishProtocol v-if="publishing"
:protocol="protocol"
@publish="publishProtocol"
@cancel="closePublishModal"
/>
<clipboardPasteModal v-if="showClipboardPasteModal"
:image="pasteImages"
:objects="steps"
:objectType="'step'"
:selectedObjectId="firstObjectInViewport()"
@files="uploadFilesToStep"
@cancel="showClipboardPasteModal = false"
/>
</div>
</template>
<script>
import InlineEdit from '../shared/inline_edit.vue';
import Step from './step';
import ProtocolMetadata from './protocolMetadata';
import ProtocolOptions from './protocolOptions';
import Tinymce from '../shared/tinymce.vue';
import ReorderableItemsModal from '../shared/reorderable_items_modal.vue';
import PublishProtocol from './modals/publish_protocol.vue';
import clipboardPasteModal from '../shared/content/attachments/clipboard_paste_modal.vue';
import AssetPasteMixin from '../shared/content/attachments/mixins/paste.js';
import axios from '../../packs/custom_axios';
import UtilsMixin from '../mixins/utils.js';
import stackableHeadersMixin from '../mixins/stackableHeadersMixin';
import moduleNameObserver from '../mixins/moduleNameObserver';
export default {
name: 'ProtocolContainer',
props: {
protocolUrl: {
type: String,
required: true
}
},
components: {
Step, InlineEdit, ProtocolOptions, Tinymce, ReorderableItemsModal, ProtocolMetadata, PublishProtocol, clipboardPasteModal
},
mixins: [UtilsMixin, stackableHeadersMixin, moduleNameObserver, AssetPasteMixin],
computed: {
wrappedTables() {
return window.wrapTables(this.protocol.attributes.description_view);
},
inRepository() {
return this.protocol.attributes.in_repository;
},
linked() {
return this.protocol.attributes.linked;
},
urls() {
return this.protocol.attributes.urls || {};
}
},
data() {
return {
protocol: {
attributes: {}
},
steps: [],
reordering: false,
publishing: false,
stepToReload: null,
activeDragStep: null,
userSettingsUrl: null,
stepCollapsed: false
};
},
mounted() {
this.userSettingsUrl = document.querySelector('meta[name="user-settings-url"]').getAttribute('content');
$.get(this.protocolUrl, (result) => {
this.protocol = result.data;
this.$nextTick(() => {
this.refreshProtocolStatus();
if (!this.inRepository) {
window.addEventListener('scroll', this.initStackableHeaders, false);
this.initStackableHeaders();
}
});
$.get(this.urls.steps_url, (result) => {
this.steps = result.data;
});
});
},
beforeUnmount() {
if (!this.inRepository) {
window.removeEventListener('scroll', this.initStackableHeaders, false);
}
},
methods: {
getHeader() {
return this.$refs.header;
},
reloadStep(step) {
this.stepToReload = step;
},
checkStepsState() {
this.stepCollapsed = this.$refs.steps.every((step) => step.isCollapsed);
},
collapseSteps() {
$('.step-container .collapse').collapse('hide');
this.updateStepStateSettings(true);
this.$refs.steps.forEach((step) => step.isCollapsed = true);
this.stepCollapsed = true;
},
expandSteps() {
$('.step-container .collapse').collapse('show');
this.updateStepStateSettings(false);
this.$refs.steps.forEach((step) => step.isCollapsed = false);
this.stepCollapsed = false;
},
updateStepStateSettings(newState) {
const updatedData = this.steps.reduce((acc, currentStep) => {
acc[currentStep.id] = newState;
return acc;
}, {});
this.steps = this.steps.map((step) => ({
...step,
attributes: {
...step.attributes,
collapsed: newState
}
}));
const settings = {
key: 'task_step_states',
data: updatedData
};
axios.put(this.userSettingsUrl, { settings: [settings] });
},
deleteSteps() {
$.post(this.urls.delete_steps_url, () => {
this.steps = [];
}).fail(() => {
HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger');
});
},
refreshProtocolStatus() {
if (this.inRepository) return;
// legacy method from app/assets/javascripts/my_modules/protocols.js
refreshProtocolStatusBar();
// Update protocol options drowpdown for linked tasks
this.refreshProtocolDropdownOptions();
},
refreshProtocolDropdownOptions() {
if (!this.linked && this.inRepository) return;
$.get(this.protocolUrl, (result) => {
this.protocol.attributes.urls = result.data.attributes.urls;
});
},
updateProtocol(attributes) {
this.protocol.attributes = attributes;
},
updateName(newName) {
this.protocol.attributes.name = newName;
$.ajax({
type: 'PATCH',
url: this.urls.update_protocol_name_url,
data: { protocol: { name: newName } },
success: () => {
this.refreshProtocolStatus();
}
});
},
updateDescription(protocol) {
this.protocol.attributes = protocol.attributes;
this.refreshProtocolStatus();
},
addStep(position) {
$.post(this.urls.add_step_url, { position }, (result) => {
result.data.newStep = true;
this.updateStepsPosition(result.data);
// scroll to bottom if step was appended at the end
if (position === this.steps.length - 1) {
this.$nextTick(() => this.scrollToBottom());
}
this.refreshProtocolStatus();
}).fail((data) => {
HelperModule.flashAlertMsg(data.responseJSON.error ? Object.values(data.responseJSON.error).join(', ') : I18n.t('errors.general'), 'danger');
});
},
updateStepsPosition(step, action = 'add') {
const { position } = step.attributes;
if (action === 'delete') {
this.steps.splice(position, 1);
}
const unordered_steps = this.steps.map((s) => {
if (s.attributes.position >= position) {
if (action === 'add') {
s.attributes.position += 1;
} else {
s.attributes.position -= 1;
}
}
return s;
});
if (action === 'add') {
unordered_steps.push(step);
}
this.reorderSteps(unordered_steps);
},
updateStep(attributes) {
this.steps[attributes.position].attributes = {
...this.steps[attributes.position].attributes,
...attributes
};
this.refreshProtocolStatus();
},
reorderSteps(steps) {
this.steps = steps.sort((a, b) => a.attributes.position - b.attributes.position);
this.refreshProtocolStatus();
},
updateStepOrder(orderedSteps) {
orderedSteps.forEach((step, position) => {
const index = this.steps.findIndex((e) => e.id === step.id);
this.steps[index].attributes.position = position;
});
const stepPositions = {
step_positions: this.steps.map(
(step) => [step.id, step.attributes.position]
)
};
$.ajax({
type: 'POST',
url: this.protocol.attributes.urls.reorder_steps_url,
data: JSON.stringify(stepPositions),
contentType: 'application/json',
dataType: 'json',
error: (() => HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger')),
success: (() => this.reorderSteps(this.steps))
});
},
startStepReorder() {
this.reordering = true;
},
closeStepReorderModal() {
this.reordering = false;
},
startPublish() {
$.ajax({
type: 'GET',
url: this.urls.version_comment_url,
contentType: 'application/json',
dataType: 'json',
success: (result) => {
this.protocol.attributes.version_comment = result.version_comment;
this.publishing = true;
}
});
},
closePublishModal() {
this.publishing = false;
},
scrollToBottom() {
window.scrollTo(0, document.body.scrollHeight);
},
publishProtocol(comment) {
this.protocol.attributes.version_comment = comment;
$.post(this.urls.publish_url, { version_comment: comment, view: 'show' });
},
scrollTop() {
window.scrollTo(0, 0);
setTimeout(() => {
$('.my_module-name .view-mode').trigger('click');
$('.my_module-name .input-field').focus();
}, 300);
},
dragEnter(id) {
this.activeDragStep = id;
},
uploadFilesToStep(file, stepId) {
this.$refs.steps.find((child) => child.step?.id == stepId).uploadFiles(file);
},
firstObjectInViewport() {
const step = $('.step-container:not(.locked)').toArray().find((element) => {
const { top, bottom } = element.getBoundingClientRect();
return bottom > 0 && top < window.innerHeight;
});
return step ? step.dataset.id : null;
}
}
};
</script>