mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2025-02-27 01:05:21 +08:00
Implement checklists [SCI-6789] (#4089)
This commit is contained in:
parent
880f23c227
commit
b486f3fd31
12 changed files with 419 additions and 28 deletions
|
@ -1,5 +1,9 @@
|
|||
.sci-inline-edit {
|
||||
display: flex;
|
||||
|
||||
&.editing {
|
||||
margin-top: -0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.sci-inline-edit__content {
|
||||
|
@ -7,7 +11,6 @@
|
|||
|
||||
span {
|
||||
cursor: pointer;
|
||||
white-space: pre;
|
||||
|
||||
&.blank {
|
||||
color: $color-silver-chalice;
|
||||
|
@ -19,10 +22,10 @@
|
|||
border-color: $brand-focus;
|
||||
border-radius: 4px;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
min-height: 36px;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
padding: 0 16px;
|
||||
padding: 0.5em 1em;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
|
|
41
app/assets/stylesheets/steps/checklist.scss
Normal file
41
app/assets/stylesheets/steps/checklist.scss
Normal file
|
@ -0,0 +1,41 @@
|
|||
.step-checklist-container {
|
||||
margin-left: -1.5em;
|
||||
|
||||
.step-element-name {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
|
||||
.sci-checkbox-container {
|
||||
margin-right: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.step-checklist-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.done .step-checklist-text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
&:hover.done .step-checklist-text {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.step-checklist-add-item {
|
||||
margin-left: 9px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.step-checklist-items {
|
||||
.sci-inline-edit {
|
||||
font-weight: normal;
|
||||
|
||||
textarea {
|
||||
padding-left: 4px;
|
||||
margin-left: -5px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -91,11 +91,12 @@
|
|||
.step-element-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
padding: 0 0 0 8px;
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
|
||||
&.editing-name {
|
||||
padding: 0;
|
||||
|
||||
.step-element-controls {
|
||||
display: none;
|
||||
}
|
||||
|
@ -111,10 +112,16 @@
|
|||
}
|
||||
|
||||
.step-element-controls {
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0) 0%, $color-concrete 15%, $color-concrete 100%);
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
|
||||
.btn {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module StepComponents
|
||||
class ChecklistItemsController < ApplicationController
|
||||
include ApplicationHelper
|
||||
|
||||
before_action :load_vars
|
||||
before_action :load_checklist, only: %i(update destroy)
|
||||
before_action :check_manage_permissions, only: %i(create update destroy)
|
||||
|
||||
def create
|
||||
checklist_item = @checklist.checklist_items.build(checklist_item_params.merge!(created_by: current_user))
|
||||
checklist_item.save!
|
||||
render json: checklist_item, serializer: ChecklistItemSerializer
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
render json: checklist_item, serializer: ChecklistItemSerializer, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def update
|
||||
@checklist_item.assign_attributes(checklist_item_params)
|
||||
|
||||
if @checklist_item.save! && @checklist_item.saved_change_to_attribute?(:checked)
|
||||
completed_items = @checklist_item.checklist.checklist_items.where(checked: true).count
|
||||
all_items = @checklist_item.checklist.checklist_items.count
|
||||
text_activity = smart_annotation_parser(@checklist_item.text).gsub(/\s+/, ' ')
|
||||
type_of = if @checklist_item.saved_change_to_attribute(:checked).last
|
||||
:check_step_checklist_item
|
||||
else
|
||||
:uncheck_step_checklist_item
|
||||
end
|
||||
log_activity(type_of,
|
||||
my_module: @step.protocol.my_module.id,
|
||||
step: @step.id,
|
||||
step_position: { id: @step.id, value_for: 'position_plus_one' },
|
||||
checkbox: text_activity,
|
||||
num_completed: completed_items.to_s,
|
||||
num_all: all_items.to_s)
|
||||
end
|
||||
|
||||
render json: @checklist_item, serializer: ChecklistItemSerializer
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
render json: @checklist_item, serializer: ChecklistItemSerializer, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @checklist_item.destroy
|
||||
render json: @checklist_item, serializer: ChecklistItemSerializer
|
||||
else
|
||||
render json: @checklist, serializer: ChecklistItemSerializer, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_manage_permissions
|
||||
render_403 unless can_manage_step?(@step)
|
||||
end
|
||||
|
||||
def checklist_item_params
|
||||
params.require(:attributes).permit(:checked, :text, :position)
|
||||
end
|
||||
|
||||
def load_vars
|
||||
@step = Step.find_by(id: params[:step_id])
|
||||
return render_404 unless @step
|
||||
|
||||
@checklist = @step.checklists.find_by(id: params[:checklist_id])
|
||||
return render_404 unless @checklist
|
||||
end
|
||||
|
||||
def load_checklist
|
||||
@checklist_item = @checklist.checklist_items.find_by(id: params[:id])
|
||||
return render_404 unless @checklist_item
|
||||
end
|
||||
|
||||
def log_activity(type_of, message_items = {})
|
||||
default_items = { step: @step.id, step_position: { id: @step.id, value_for: 'position_plus_one' } }
|
||||
message_items = default_items.merge(message_items)
|
||||
|
||||
Activities::CreateActivityService.call(activity_type: type_of,
|
||||
owner: current_user,
|
||||
subject: @step.protocol,
|
||||
team: @step.protocol.team,
|
||||
project: @step.protocol.my_module.experiment.project,
|
||||
message_items: message_items)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -26,6 +26,21 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!pastingMultiline" class="step-checklist-items">
|
||||
<ChecklistItem
|
||||
v-for="checklistItem in checklistItems"
|
||||
:key="checklistItem.attributes.id"
|
||||
:checklistItem="checklistItem"
|
||||
@update="saveItem"
|
||||
@removeItem="removeItem"
|
||||
@component:delete="removeItem"
|
||||
@multilinePaste="handleMultilinePaste"
|
||||
/>
|
||||
<div class="btn btn-light step-checklist-add-item" @click="addItem">
|
||||
<i class="fas fa-plus"></i>
|
||||
{{ i18n.t('protocols.steps.insert.checklist_item') }}
|
||||
</div>
|
||||
</div>
|
||||
<deleteComponentModal v-if="confirmingDelete" @confirm="deleteComponent" @cancel="closeDeleteModal"/>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -34,10 +49,11 @@
|
|||
import DeleteMixin from 'vue/protocol/mixins/components/delete.js'
|
||||
import deleteComponentModal from 'vue/protocol/modals/delete_component.vue'
|
||||
import InlineEdit from 'vue/shared/inline_edit.vue'
|
||||
import ChecklistItem from 'vue/protocol/step_components/checklistItem.vue'
|
||||
|
||||
export default {
|
||||
name: 'Checklist',
|
||||
components: { deleteComponentModal, InlineEdit },
|
||||
components: { deleteComponentModal, InlineEdit, ChecklistItem },
|
||||
mixins: [DeleteMixin],
|
||||
props: {
|
||||
element: {
|
||||
|
@ -47,7 +63,18 @@
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
editingName: false
|
||||
editingName: false,
|
||||
linesToPaste: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
checklistItems() {
|
||||
return this.element.attributes.orderable.checklist_items.map((item, index) => {
|
||||
return { attributes: {...item, position: index + 1 } }
|
||||
});
|
||||
},
|
||||
pastingMultiline() {
|
||||
return this.linesToPaste > 0;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -63,6 +90,68 @@
|
|||
},
|
||||
update() {
|
||||
this.$emit('update', this.element)
|
||||
},
|
||||
postItem(item, callback) {
|
||||
$.post(this.element.attributes.orderable.urls.create_item_url, item).success((result) => {
|
||||
this.element.attributes.orderable.checklist_items.splice(
|
||||
result.data.attributes.position - 1,
|
||||
1,
|
||||
{ id: result.data.id, ...result.data.attributes }
|
||||
);
|
||||
|
||||
callback();
|
||||
}).error(() => {
|
||||
HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger');
|
||||
});
|
||||
},
|
||||
saveItem(item) {
|
||||
if (item.attributes.id) {
|
||||
this.element.attributes.orderable.checklist_items.splice(
|
||||
item.attributes.position - 1, 1, item.attributes
|
||||
);
|
||||
$.ajax({
|
||||
url: item.attributes.urls.update_url,
|
||||
type: 'PATCH',
|
||||
data: item,
|
||||
error: () => HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger')
|
||||
});
|
||||
} else {
|
||||
this.postItem(item, this.addItem);
|
||||
}
|
||||
},
|
||||
addItem() {
|
||||
this.element.attributes.orderable.checklist_items.push(
|
||||
{
|
||||
text: '',
|
||||
checked: false,
|
||||
position: this.element.attributes.orderable.checklist_items.length + 1
|
||||
}
|
||||
);
|
||||
},
|
||||
removeItem(item) {
|
||||
this.element.attributes.orderable.checklist_items.splice(item.attributes.position - 1, 1);
|
||||
},
|
||||
handleMultilinePaste(data) {
|
||||
this.linesToPaste = data.length;
|
||||
let nextPosition = this.element.attributes.orderable.checklist_items.length;
|
||||
|
||||
// we need to post items to API in the right order, to avoid positions breaking
|
||||
let synchronousPost = (index) => {
|
||||
if(index === data.length) return;
|
||||
|
||||
let item = {
|
||||
attributes: {
|
||||
text: data[index],
|
||||
checked: false,
|
||||
position: nextPosition + index
|
||||
}
|
||||
};
|
||||
|
||||
this.linesToPaste -= 1;
|
||||
this.postItem(item, () => synchronousPost(index + 1));
|
||||
};
|
||||
|
||||
synchronousPost(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<div class="step-checklist-item">
|
||||
<div class="step-element-header" :class="{ 'editing-name': editingText }">
|
||||
<div class="step-element-grip">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</div>
|
||||
<div class="step-element-name" :class="{ 'done': checklistItem.attributes.checked }">
|
||||
<div class="sci-checkbox-container">
|
||||
<input ref="checkbox" type="checkbox" class="sci-checkbox" :checked="checklistItem.attributes.checked" @change="toggleChecked" />
|
||||
<span class="sci-checkbox-label">
|
||||
</span>
|
||||
</div>
|
||||
<div class="step-checklist-text">
|
||||
<InlineEdit
|
||||
:value="checklistItem.attributes.text"
|
||||
:characterLimit="10000"
|
||||
:placeholder="''"
|
||||
:allowBlank="true"
|
||||
:autofocus="editingText"
|
||||
:attributeName="`${i18n.t('ChecklistItem')} ${i18n.t('name')}`"
|
||||
:multilinePaste="true"
|
||||
@editingEnabled="enableTextEdit"
|
||||
@editingDisabled="disableTextEdit"
|
||||
@update="updateText"
|
||||
@delete="checklistItem.attributes.id ? deleteComponent() : removeItem()"
|
||||
@multilinePaste="(data) => { $emit('multilinePaste', data) && removeItem() }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-element-controls">
|
||||
<button class="btn icon-btn btn-light" @click="enableTextEdit">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
<button class="btn icon-btn btn-light" @click="showDeleteModal">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<deleteComponentModal v-if="confirmingDelete" @confirm="deleteComponent" @cancel="closeDeleteModal"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DeleteMixin from 'vue/protocol/mixins/components/delete.js'
|
||||
import deleteComponentModal from 'vue/protocol/modals/delete_component.vue'
|
||||
import InlineEdit from 'vue/shared/inline_edit.vue'
|
||||
|
||||
export default {
|
||||
name: 'Checklist',
|
||||
components: { deleteComponentModal, InlineEdit },
|
||||
mixins: [DeleteMixin],
|
||||
props: {
|
||||
checklistItem: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editingText: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
element() { // remap and alias to work with delete mixin
|
||||
return { attributes: { orderable: this.checklistItem.attributes } }
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
enableTextEdit() {
|
||||
this.editingText = true;
|
||||
},
|
||||
disableTextEdit() {
|
||||
this.editingText = false;
|
||||
},
|
||||
toggleChecked() {
|
||||
this.checklistItem.attributes.checked = this.$refs.checkbox.checked;
|
||||
this.update();
|
||||
},
|
||||
updateText(text) {
|
||||
if (text.length === 0) {
|
||||
this.disableTextEdit();
|
||||
this.deleteComponent();
|
||||
} else {
|
||||
this.checklistItem.attributes.text = text;
|
||||
this.update();
|
||||
}
|
||||
},
|
||||
removeItem() {
|
||||
this.$emit('removeItem', this.checklistItem);
|
||||
},
|
||||
update() {
|
||||
this.$emit('update', this.checklistItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -1,19 +1,29 @@
|
|||
<template>
|
||||
<div class="sci-inline-edit">
|
||||
<div class="sci-inline-edit" :class="{ 'editing': editing }">
|
||||
<div class="sci-inline-edit__content">
|
||||
<textarea ref="input" rows="1" v-if="editing" :class="{ 'error': error }" :placeholder="placeholder" v-model="newValue" @input="handleInput" @keydown="handleKeypress" @blur="update">
|
||||
</textarea>
|
||||
<textarea
|
||||
ref="input"
|
||||
rows="1"
|
||||
v-if="editing"
|
||||
:class="{ 'error': error }"
|
||||
:placeholder="placeholder"
|
||||
v-model="newValue"
|
||||
@input="handleInput"
|
||||
@keydown="handleKeypress"
|
||||
@paste="handlePaste"
|
||||
@blur="handleBlur"
|
||||
></textarea>
|
||||
<span v-else @click="enableEdit" :class="{ 'blank': isBlank }">{{ value || placeholder }}</span>
|
||||
<div v-if="editing && error" class="sci-inline-edit__error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="editing" class="sci-inline-edit__controls">
|
||||
<div class="sci-inline-edit__control sci-inline-edit__save">
|
||||
<i @click="update" class="fas fa-check"></i>
|
||||
<div class="sci-inline-edit__control sci-inline-edit__save" @click="update">
|
||||
<i class="fas fa-check"></i>
|
||||
</div>
|
||||
<div class="sci-inline-edit__control sci-inline-edit__cancel">
|
||||
<i @click="cancelEdit" class="fas fa-times"></i>
|
||||
<div class="sci-inline-edit__control sci-inline-edit__cancel" @click="cancelEdit">
|
||||
<i class="fas fa-times"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -28,7 +38,8 @@
|
|||
attributeName: { type: String, required: true },
|
||||
characterLimit: { type: Number },
|
||||
placeholder: { type: String },
|
||||
autofocus: { type: Boolean, default: false }
|
||||
autofocus: { type: Boolean, default: false },
|
||||
multilinePaste: { type: Boolean, default: false }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -68,12 +79,21 @@
|
|||
},
|
||||
methods: {
|
||||
handleAutofocus() {
|
||||
if (this.autofocus) {
|
||||
this.editing = true;
|
||||
if (this.autofocus || !this.placeholder && this.isBlank) {
|
||||
this.enableEdit();
|
||||
setTimeout(this.focus, 50);
|
||||
}
|
||||
},
|
||||
handleBlur() {
|
||||
if (!this.isBlank) {
|
||||
this.$nextTick(this.update);
|
||||
} else {
|
||||
this.$emit('delete');
|
||||
}
|
||||
},
|
||||
focus() {
|
||||
if (!this.$refs.input) return;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.input.focus();
|
||||
this.resize();
|
||||
|
@ -89,8 +109,18 @@
|
|||
this.newValue = this.value || '';
|
||||
this.$emit('editingDisabled');
|
||||
},
|
||||
handlePaste(e) {
|
||||
if (!this.multilinePaste) return;
|
||||
e.clipboardData.items[0].getAsString((data) => {
|
||||
let lines = data.split(/[\n\r]/);
|
||||
if (lines.length > 1) {
|
||||
this.newValue = lines[0];
|
||||
this.$emit('multilinePaste', lines);
|
||||
}
|
||||
})
|
||||
},
|
||||
handleInput() {
|
||||
this.newValue = this.newValue.trim();
|
||||
this.newValue = this.newValue.replace(/^[\n\r]+|[\n\r]+$/g, '');
|
||||
this.$nextTick(this.resize);
|
||||
},
|
||||
handleKeypress(e) {
|
||||
|
@ -108,12 +138,15 @@
|
|||
this.$refs.input.style.height = (this.$refs.input.scrollHeight) + "px";
|
||||
},
|
||||
update() {
|
||||
if(this.isBlank) return;
|
||||
if(!this.editing) return;
|
||||
setTimeout(() => {
|
||||
if(!this.allowBlank && this.isBlank) return;
|
||||
if(!this.editing) return;
|
||||
|
||||
this.editing = false;
|
||||
this.$emit('editingDisabled');
|
||||
this.$emit('update', this.newValue);
|
||||
this.newValue = this.newValue.trim();
|
||||
this.editing = false;
|
||||
this.$emit('editingDisabled');
|
||||
this.$emit('update', this.newValue);
|
||||
}, 100) // due to clicking 'x' also triggering a blur event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,4 +17,16 @@ class ChecklistItem < ApplicationRecord
|
|||
foreign_key: 'last_modified_by_id',
|
||||
class_name: 'User',
|
||||
optional: true
|
||||
|
||||
after_destroy :update_positions
|
||||
|
||||
private
|
||||
|
||||
def update_positions
|
||||
transaction do
|
||||
checklist.checklist_items.order(position: :asc).each_with_index do |checklist_item, i|
|
||||
checklist_item.update!(position: i + 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
16
app/serializers/checklist_item_serializer.rb
Normal file
16
app/serializers/checklist_item_serializer.rb
Normal file
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ChecklistItemSerializer < ActiveModel::Serializer
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
attributes :id, :text, :checked, :position, :urls
|
||||
|
||||
def urls
|
||||
return {} if object.destroyed? || !object.persisted?
|
||||
|
||||
{
|
||||
update_url: step_checklist_checklist_item_path(object.checklist.step, object.checklist, object),
|
||||
delete_url: step_checklist_checklist_item_path(object.checklist.step, object.checklist, object)
|
||||
}
|
||||
end
|
||||
end
|
|
@ -3,14 +3,16 @@
|
|||
class ChecklistSerializer < ActiveModel::Serializer
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
attributes :name, :urls
|
||||
attributes :id, :name, :urls
|
||||
has_many :checklist_items, serializer: ChecklistItemSerializer
|
||||
|
||||
def urls
|
||||
return {} if object.destroyed?
|
||||
|
||||
{
|
||||
delete_url: step_checklist_path(object.step, object),
|
||||
update_url: step_checklist_path(object.step, object)
|
||||
update_url: step_checklist_path(object.step, object),
|
||||
create_item_url: step_checklist_checklist_items_path(object.step, object)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2520,6 +2520,7 @@ en:
|
|||
table: 'Add table'
|
||||
text: 'Add text'
|
||||
checklist: 'Add checklist'
|
||||
checklist_item: 'Add a new checklist item...'
|
||||
table:
|
||||
default_name: 'Table %{position}'
|
||||
edit_message: 'Use right click for table options'
|
||||
|
@ -3059,6 +3060,7 @@ en:
|
|||
Protocol: "Protocol"
|
||||
Protocols: "Protocols"
|
||||
Checklist: "Checklist"
|
||||
ChecklistItem: "Checklist item"
|
||||
Checklists: "Checklists"
|
||||
Table: "Table"
|
||||
Tables: "Tables"
|
||||
|
|
|
@ -449,9 +449,11 @@ Rails.application.routes.draw do
|
|||
path: '/comments',
|
||||
only: %i(create index update destroy)
|
||||
|
||||
resources :tables, controller: 'step_components/tables', only: %i(create destroy update)
|
||||
resources :texts, controller: 'step_components/texts', only: %i(create destroy update)
|
||||
resources :checklists, controller: 'step_components/checklists', only: %i(create destroy update)
|
||||
resources :tables, controller: 'step_components/tables', only: %i(create destroy)
|
||||
resources :texts, controller: 'step_components/texts', only: %i(create destroy)
|
||||
resources :checklists, controller: 'step_components/checklists', only: %i(create destroy update) do
|
||||
resources :checklist_items, controller: 'step_components/checklist_items', only: %i(create update destroy)
|
||||
end
|
||||
member do
|
||||
get 'elements'
|
||||
post 'checklistitem_state'
|
||||
|
|
Loading…
Reference in a new issue