diff --git a/app/assets/stylesheets/shared/inline_edit.scss b/app/assets/stylesheets/shared/inline_edit.scss index d946a6a3b..336ec8a92 100644 --- a/app/assets/stylesheets/shared/inline_edit.scss +++ b/app/assets/stylesheets/shared/inline_edit.scss @@ -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 { diff --git a/app/assets/stylesheets/steps/checklist.scss b/app/assets/stylesheets/steps/checklist.scss new file mode 100644 index 000000000..2e2448cc1 --- /dev/null +++ b/app/assets/stylesheets/steps/checklist.scss @@ -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; + } + } +} diff --git a/app/assets/stylesheets/steps/step.scss b/app/assets/stylesheets/steps/step.scss index 8d7446cca..2b9998c27 100644 --- a/app/assets/stylesheets/steps/step.scss +++ b/app/assets/stylesheets/steps/step.scss @@ -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; } diff --git a/app/controllers/step_components/checklist_items_controller.rb b/app/controllers/step_components/checklist_items_controller.rb new file mode 100644 index 000000000..45028f984 --- /dev/null +++ b/app/controllers/step_components/checklist_items_controller.rb @@ -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 diff --git a/app/javascript/vue/protocol/step_components/checklist.vue b/app/javascript/vue/protocol/step_components/checklist.vue index 375fe9977..cf27761a5 100644 --- a/app/javascript/vue/protocol/step_components/checklist.vue +++ b/app/javascript/vue/protocol/step_components/checklist.vue @@ -26,6 +26,21 @@ +
+ +
+ + {{ i18n.t('protocols.steps.insert.checklist_item') }} +
+
@@ -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); } } } diff --git a/app/javascript/vue/protocol/step_components/checklistItem.vue b/app/javascript/vue/protocol/step_components/checklistItem.vue new file mode 100644 index 000000000..c199b20c1 --- /dev/null +++ b/app/javascript/vue/protocol/step_components/checklistItem.vue @@ -0,0 +1,96 @@ + + + diff --git a/app/javascript/vue/shared/inline_edit.vue b/app/javascript/vue/shared/inline_edit.vue index 23a55c52f..9ca52f9d7 100644 --- a/app/javascript/vue/shared/inline_edit.vue +++ b/app/javascript/vue/shared/inline_edit.vue @@ -1,19 +1,29 @@