Implement checklists [SCI-6789] (#4089)

This commit is contained in:
artoscinote 2022-05-11 15:51:26 +02:00 committed by GitHub
parent 880f23c227
commit b486f3fd31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 419 additions and 28 deletions

View file

@ -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 {

View 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;
}
}
}

View file

@ -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;
}

View file

@ -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

View file

@ -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);
}
}
}

View file

@ -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>

View file

@ -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
}
}
}

View file

@ -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

View 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

View file

@ -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

View file

@ -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"

View file

@ -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'