Merge pull request #6272 from aignatov-bio/ai-sci-9340-update-checklists-interactions

Refactor checklist [SCI-9340]
This commit is contained in:
aignatov-bio 2023-09-22 12:09:41 +02:00 committed by GitHub
commit a19a2d16e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 166 additions and 168 deletions

View file

@ -10,22 +10,28 @@ module StepElements
before_action :check_toggle_permissions, only: %i(toggle)
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))
def index
render json: @checklist.checklist_items, each_serializer: ChecklistItemSerializer, user: current_user
end
def create
checklist_item = @checklist.checklist_items.new(checklist_item_params.merge!(created_by: current_user))
new_items = []
ActiveRecord::Base.transaction do
checklist_item.save!
log_activity(
"#{@step.protocol.in_module? ? :task : :protocol}_step_checklist_item_added",
{
checklist_item: checklist_item.text,
checklist_name: @checklist.name
}
)
checklist_item_annotation(@step, checklist_item)
new_items = checklist_item.save_multiline!
new_items.each do |item|
log_activity(
"#{@step.protocol.in_module? ? :task : :protocol}_step_checklist_item_added",
{
checklist_item: item.text,
checklist_name: @checklist.name
}
)
checklist_item_annotation(@step, item)
end
end
render json: checklist_item, serializer: ChecklistItemSerializer, user: current_user
render json: new_items, each_serializer: ChecklistItemSerializer, user: current_user
rescue ActiveRecord::RecordInvalid
render json: { errors: checklist_item.errors }, status: :unprocessable_entity
end
@ -35,17 +41,20 @@ module StepElements
@checklist_item.assign_attributes(
checklist_item_params.except(:position, :id).merge(last_modified_by: current_user)
)
if @checklist_item.save!
log_activity(
"#{@step.protocol.in_module? ? :task : :protocol}_step_checklist_item_edited",
checklist_item: @checklist_item.text,
checklist_name: @checklist.name
)
checklist_item_annotation(@step, @checklist_item, old_text)
new_items = []
ActiveRecord::Base.transaction do
new_items = @checklist_item.save_multiline!
new_items.each_with_index do |item, i|
log_activity(
"#{@step.protocol.in_module? ? :task : :protocol}_step_checklist_item_#{i.zero? ? 'edited' : 'added'}",
checklist_item: item.text,
checklist_name: @checklist.name
)
checklist_item_annotation(@step, item, old_text)
end
end
render json: @checklist_item, serializer: ChecklistItemSerializer, user: current_user
render json: new_items, each_serializer: ChecklistItemSerializer, user: current_user
rescue ActiveRecord::RecordInvalid
render json: { errors: @checklist_item.errors }, status: :unprocessable_entity
end
@ -96,7 +105,6 @@ module StepElements
checklist_item = @checklist.checklist_items.find(checklist_item_params[:id])
ActiveRecord::Base.transaction do
checklist_item.insert_at(checklist_item_params[:position])
@checklist.touch
end
render json: params[:checklist_item_positions], status: :ok
rescue ActiveRecord::RecordInvalid
@ -118,7 +126,7 @@ module StepElements
end
def checklist_item_params
params.require(:attributes).permit(:text, :position, :id)
params.require(:attributes).permit(:text, :position, :id, :with_paragraphs)
end
def checklist_toggle_item_params

View file

@ -431,7 +431,7 @@
secondaryNavigation.style.zIndex= 251;
} else {
secondaryNavigation.style.boxShadow = 'none';
secondaryNavigation.style.zIndex= 0;
if (secondaryNavigationTop > 10) secondaryNavigation.style.zIndex= 0;
}
if (protocolHeaderTop - 5 < protocolHeaderHeight) { // When secondary navigation touch protocol header
@ -457,7 +457,7 @@
secondaryNavigation.style.top = newSecondaryTop + 'px'; // Secondary navigation starts slowly disappear
protocolHeader.style.top = newSecondaryTop + secondaryNavigationHeight - 1 + 'px'; // Protocol header starts getting offset to compensate secondary navigation position
// -1 to compensate small gap between protocol header and secondary navigation
if (newSecondaryTop <= 1) secondaryNavigation.style.zIndex= 0;
if (newSecondaryTop * -1 >= secondaryNavigationHeight) secondaryNavigation.style.zIndex= 0;
}
} else {
// Just reset secondary navigation and protocol header styles to initial state

View file

@ -43,7 +43,7 @@
>
<ChecklistItem
v-for="checklistItem in orderedChecklistItems"
:key="checklistItem.attributes.id"
:key="checklistItem.id"
:checklistItem="checklistItem"
:locked="locked"
:reorderChecklistItemUrl="element.attributes.orderable.urls.reorder_url"
@ -55,14 +55,13 @@
@toggle="saveItemChecked"
@removeItem="removeItem"
@component:delete="removeItem"
@multilinePaste="handleMultilinePaste"
/>
</Draggable>
<div v-if="element.attributes.orderable.urls.create_item_url"
class="flex items-center gap-1 text-sn-blue cursor-pointer mb-2 mt-1"
<div v-if="element.attributes.orderable.urls.create_item_url && !addingNewItem"
class="flex items-center gap-1 text-sn-blue cursor-pointer mb-2 mt-1 "
tabindex="0"
@keyup.enter="addItem"
@click="addItem">
@keyup.enter="addItem(orderedChecklistItems.length + 1)"
@click="addItem(orderedChecklistItems.length + 1)">
<i class="sn-icon sn-icon-new-task w-6 text-center inline-block"></i>
{{ i18n.t('protocols.steps.insert.checklist_item') }}
</div>
@ -117,36 +116,37 @@
data() {
return {
checklistItems: [],
linesToPaste: 0,
editingName: false,
reordering: false,
editingItem: false
editingItem: false,
}
},
created() {
this.initChecklistItems();
this.loadChecklistItems();
if (this.isNew) {
this.addItem();
this.addItem(orderedChecklistItems.length + 1);
}
},
watch: {
element() {
this.initChecklistItems();
this.loadChecklistItems();
}
},
computed: {
orderedChecklistItems() {
return this.checklistItems.map((item, index) => {
return { attributes: {...item.attributes, position: index } }
});
},
pastingMultiline() {
return this.linesToPaste > 0;
return this.checklistItems.sort((a, b) => a.attributes.position - b.attributes.position || b.id - a.id)
.map((item, index) => {
item.attributes.position = index + 1;
return item;
});
},
locked() {
return this.reordering || this.editingName || !this.element.attributes.orderable.urls.update_url
},
addingNewItem() {
return this.checklistItems.find((item) => item.attributes.isNew);
},
actionMenu() {
let menu = [];
if (this.element.attributes.orderable.urls.update_url) {
@ -177,57 +177,46 @@
}
},
methods: {
initChecklistItems() {
this.checklistItems = this.element.attributes.orderable.checklist_items.map((item, index) => {
return { attributes: {...item, position: index } }
loadChecklistItems(insertAfter) {
$.get(this.element.attributes.orderable.urls.checklist_items_url, (result) => {
this.checklistItems = result.data;
if (insertAfter != null) {
this.addItem(insertAfter);
}
});
},
updateName(name) {
this.element.attributes.orderable.name = name;
this.editingName = false;
this.update(false);
},
update(skipRequest = true) {
this.element.attributes.orderable.checklist_items =
this.checklistItems.map((i) => i.attributes);
this.$emit('update', this.element, skipRequest);
},
postItem(item, callback) {
console.log(this.element.attributes.orderable.urls.create_item_url)
postItem(item) {
item.attributes.position = item.attributes.position - 1;
$.post(this.element.attributes.orderable.urls.create_item_url, item).done((result) => {
this.checklistItems.splice(
result.data.attributes.position,
1,
{ attributes: { ...result.data.attributes, id: result.data.id } }
);
if(callback) callback();
this.loadChecklistItems(result.data[result.data.length - 1].attributes.position)
}).fail((e) => {
HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger');
});
this.update();
// Fake element during loading
item.id = 'new' + Math.floor(Math.random() * 1000000000);
this.checklistItems.push(item);
},
saveItem(item) {
if (item.attributes.id) {
saveItem(item, key) {
if (item.id > 0) {
let insertAfter = key === 'Enter' ? item.attributes.position : null;
$.ajax({
url: item.attributes.urls.update_url,
type: 'PATCH',
data: item,
success: (result) => {
let updatedItem = this.checklistItems[item.attributes.position]
updatedItem.attributes = result.data.attributes
updatedItem.attributes.id = item.attributes.id
this.$set(this.checklistItems, item.attributes.position, updatedItem)
success: () => {
this.loadChecklistItems(insertAfter)
},
error: (xhr) => setFlashErrors(xhr.responseJSON.errors)
});
} else {
// create item, then append next one
this.postItem(item, this.addItem);
this.postItem(item, key);
}
this.update(true);
},
saveItemChecked(item) {
$.ajax({
@ -235,29 +224,28 @@
type: 'PATCH',
data: { attributes: { checked: item.attributes.checked } },
success: (result) => {
let updatedItem = this.checklistItems[item.attributes.position]
updatedItem.attributes = result.data.attributes
updatedItem.attributes.id = item.attributes.id
this.$set(this.checklistItems, item.attributes.position, updatedItem)
this.checklistItems.find(
(i) => i.id === item.id
).attributes.checked = result.data.attributes.checked;
},
error: () => HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger')
});
},
addItem() {
addItem(insertAfter) {
this.checklistItems.push(
{
attributes: {
text: '',
checked: false,
position: this.checklistItems.length,
position: insertAfter,
isNew: true
}
}
);
this.checklistItems = this.orderedChecklistItems;
},
removeItem(position) {
this.checklistItems.splice(position, 1);
this.update();
this.checklistItems = this.orderedChecklistItems.filter((item) => item.attributes.position !== position);
},
startReorder() {
this.reordering = true;
@ -266,10 +254,12 @@
this.reordering = false;
if(
Number.isInteger(event.newIndex)
&& Number.isInteger(event.newIndex)
&& Number.isInteger(event.oldIndex)
&& event.newIndex !== event.oldIndex
){
const { id, position } = this.orderedChecklistItems[event.newIndex]?.attributes
let position = this.orderedChecklistItems[event.newIndex]?.attributes.position;
let id = this.checklistItems[event.oldIndex]?.id;
this.checklistItems[event.oldIndex].attributes.position = position + (event.newIndex > event.oldIndex ? 1 : -1);
this.saveItemOrder(id, position);
}
},
@ -281,31 +271,9 @@
contentType: "application/json",
dataType: "json",
error: (xhr) => this.setFlashErrors(xhr.responseJSON.errors),
success: () => this.update()
success: () => this.loadChecklistItems()
});
},
handleMultilinePaste(data) {
this.linesToPaste = data.length;
let nextPosition = this.checklistItems.length - 1;
// 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);
},
setFlashErrors(errors) {
for(const key in errors){
HelperModule.flashAlertMsg(

View file

@ -2,13 +2,13 @@
<div class="content__checklist-item">
<div class="checklist-item-header flex rounded pl-10 ml-[-2.325rem] items-center relative w-full group/checklist-item-header" :class="{ 'locked': locked || editingText, 'editing-name': editingText }">
<div v-if="reorderChecklistItemUrl"
class="absolute h-6 cursor-grab justify-center left-0 top-1 px-2 tw-hidden text-sn-grey element-grip step-element-grip--draggable"
class="absolute h-6 cursor-grab justify-center left-0 top-0.5 px-2 tw-hidden text-sn-grey element-grip step-element-grip--draggable"
:class="{ 'group-hover/checklist-item-header:flex': (!locked && !editingText && draggable) }"
>
<i class="sn-icon sn-icon-drag"></i>
</div>
<div class="flex items-start gap-2 grow pt-[1px]" :class="{ 'done': checklistItem.attributes.checked }">
<div v-if="!inRepository" class="sci-checkbox-container my-1.5 border-y border-transparent border-solid w-6" :class="{ 'disabled': !toggleUrl }">
<div class="flex items-start gap-2 grow" :class="{ 'done': checklistItem.attributes.checked }">
<div v-if="!inRepository" class="sci-checkbox-container my-1.5 border-0 border-y border-transparent border-solid" :class="{ 'disabled': !toggleUrl }">
<input ref="checkbox"
type="checkbox"
class="sci-checkbox"
@ -18,7 +18,11 @@
</span>
</div>
<div v-else class="w-6"></div>
<div class="pr-10 grow relative flex items-start max-w-[90ch]" :class="{ 'pointer-events-none': !checklistItem.attributes.isNew && !updateUrl }">
<div class="pr-24 relative flex items-start max-w-[90ch]"
:class="{
'pointer-events-none': !checklistItem.attributes.isNew && !updateUrl,
'flex-grow': editingText,
}">
<InlineEdit
:value="checklistItem.attributes.text"
:sa_value="checklistItem.attributes.sa_text"
@ -28,18 +32,16 @@
:singleLine="false"
:autofocus="editingText"
:attributeName="`${i18n.t('ChecklistItem')} ${i18n.t('name')}`"
:multilinePaste="true"
:editOnload="checklistItem.attributes.isNew"
:smartAnnotation="true"
:saveOnEnter="false"
:allowNewLine="true"
@editingEnabled="enableTextEdit"
@editingDisabled="disableTextEdit"
@update="updateText"
@delete="removeItem()"
@multilinePaste="(data) => { $emit('multilinePaste', data) && removeItem() }"
@keypress="keyPressHandler"
@blur="editingText = false"
/>
<span v-if="!checklistItem.attributes.urls || deleteUrl" class="absolute right-0 leading-6 tw-hidden group-hover/checklist-item-header:inline-block !text-sn-blue cursor-pointer" @click="showDeleteModal" tabindex="0">
<span v-if="!editingText && (!checklistItem.attributes.urls || deleteUrl)" class="absolute right-0 top-0.5 leading-6 tw-hidden group-hover/checklist-item-header:inline-block !text-sn-blue cursor-pointer" @click="showDeleteModal" tabindex="0">
<i class="sn-icon sn-icon-delete"></i>
</span>
</div>
@ -81,7 +83,8 @@
},
data() {
return {
editingText: false
editingText: false,
deleting: false
}
},
computed: {
@ -116,6 +119,8 @@
},
disableTextEdit() {
if (this.checklistItem.attributes.isNew) {
if (this.deleting) return
this.removeItem();
this.$emit('editEnd');
this.editingText = false;
@ -129,25 +134,30 @@
this.checklistItem.attributes.checked = this.$refs.checkbox.checked;
this.$emit('toggle', this.checklistItem);
},
updateText(text) {
updateText(text, withKey) {
if (text.length === 0) {
this.disableTextEdit();
this.removeItem();
} else {
this.checklistItem.attributes.text = text;
this.update();
this.update(withKey);
}
},
removeItem() {
this.deleting = true;
if (this.deleteUrl) {
this.deleteElement();
} else {
this.$emit('removeItem', this.checklistItem.attributes.position);
}
},
update() {
this.$emit('update', this.checklistItem);
}
update(withKey) {
this.$emit('update', this.checklistItem, withKey);
},
keyPressHandler(e) {
if (e.key === 'Enter' && e.shiftKey) {
this.checklistItem.attributes.with_paragraphs = true;
}
},
}
}
</script>

View file

@ -12,13 +12,12 @@
}"
v-model="newValue"
@keydown="handleKeypress"
@paste="handlePaste"
@blur="handleBlur"
@keyup.escape="cancelEdit"
@focus="setCaretAtEnd"/>
<textarea v-else
ref="input"
class="overflow-hidden leading-5 inline-block outline-none px-0 py-1 border-0 border-solid border-y w-full border-t-transparent mb-[1px]"
class="overflow-hidden leading-5 inline-block outline-none px-0 py-1 border-0 border-solid border-y w-full border-t-transparent mb-0.5"
:class="{
'inline-edit-placeholder text-sn-grey caret-black': isBlank,
'border-sn-delete-red': error,
@ -27,7 +26,6 @@
:placeholder="placeholder"
v-model="newValue"
@keydown="handleKeypress"
@paste="handlePaste"
@blur="handleBlur"
@keyup.escape="cancelEdit"
@focus="setCaretAtEnd"/>
@ -36,7 +34,7 @@
v-else
ref="view"
class="grid sci-cursor-edit leading-5 border-0 py-1 outline-none inline-block border-solid border-y border-transparent"
:class="{ 'text-sn-grey font-normal': isBlank }"
:class="{ 'text-sn-grey font-normal': isBlank, 'whitespace-pre': !singleLine }"
@click="enableEdit($event)"
>
<span :class="{'truncate': singleLine}" v-if="smartAnnotation" v-html="sa_value || placeholder" ></span>
@ -151,6 +149,7 @@
},
handleBlur() {
if ($('.atwho-view:visible').length) return;
this.$emit('blur');
if (this.allowBlank || !this.isBlank) {
this.$nextTick(this.update);
} else {
@ -198,37 +197,6 @@
this.newValue = this.value || '';
this.$emit('editingDisabled');
},
handlePaste(e) {
e.preventDefault();
this.dirty = true;
const clipboardData = (e.clipboardData || window.clipboardData).getData("text");
let lines = clipboardData.split(/[\n\r]/).filter((l) => l).map((l) => l.trim());
const selection = window.getSelection();
if (!selection.rangeCount) return;
selection.deleteFromDocument();
let textNode = document.createTextNode(lines[0]);
selection.getRangeAt(0).insertNode(textNode);
let range = document.createRange();
range.setStart(textNode, textNode.length);
range.setEnd(textNode, textNode.length);
this.$nextTick(() => {
this.newValue = e.target.textContent;
selection.removeAllRanges();
selection.addRange(range);
// Handle multi-line paste
if (this.multilinePaste && lines.length > 1) {
this.$emit('multilinePaste', lines);
this.update();
}
});
},
handleInput(e) {
this.dirty = true;
@ -242,15 +210,16 @@
handleKeypress(e) {
if (e.key == 'Escape') {
this.cancelEdit();
} else if (e.key == 'Enter' && this.saveOnEnter) {
} else if (e.key == 'Enter' && this.saveOnEnter && e.shiftKey == false) {
e.preventDefault()
this.update();
this.update(e.key);
} else {
if (!this.error) this.$emit('error-cleared');
this.dirty = true;
}
this.$emit('keypress', e)
},
update() {
update(withKey = null) {
this.refreshTexareaHeight();
if (!this.dirty && !this.isBlank) {
@ -264,11 +233,13 @@
this.editing = false;
this.$emit('editingDisabled');
this.$emit('update', this.newValue);
this.$emit('update', this.newValue, withKey);
},
refreshTexareaHeight() {
if (this.editing && !this.singleLine) {
this.$nextTick(() => {
if (!this.$refs.input) return;
this.$refs.input.style.height = '0px';
this.$refs.input.style.height = this.$refs.input.scrollHeight + 'px';
});

View file

@ -1,4 +1,7 @@
class ChecklistItem < ApplicationRecord
attr_accessor :with_paragraphs
auto_strip_attributes :text, nullify: false
validates :text,
presence: true,
@ -24,6 +27,45 @@ class ChecklistItem < ApplicationRecord
after_save :touch_checklist
after_touch :touch_checklist
def save_multiline!
if with_paragraphs
if new_record?
original_position = position
self.position = nil
save!
insert_at(original_position + 1)
else
save!
end
return [self]
end
items = []
if new_record?
start_position = position
text.split("\n").compact.each do |line|
new_item = checklist.checklist_items.create!(text: line)
new_item.insert_at(start_position + 1)
start_position = new_item.position
items.push(new_item)
end
else
item = self
text.split("\n").compact.each_with_index do |line, index|
if index.zero?
update!(text: line)
items.push(self)
else
new_item = checklist.checklist_items.create!(text: line)
new_item.insert_at(item.position + 1)
item = new_item
items.push(new_item)
end
end
end
items
end
private
def touch_checklist

View file

@ -6,7 +6,11 @@ class ChecklistItemSerializer < ActiveModel::Serializer
include ApplicationHelper
include ActionView::Helpers::TextHelper
attributes :id, :text, :checked, :position, :urls, :sa_text
attributes :id, :text, :checked, :position, :urls, :sa_text, :with_paragraphs
def with_paragraphs
object.text.include?("\n")
end
def sa_text
@user = scope[:user] || @instance_options[:user]

View file

@ -16,12 +16,6 @@ class ChecklistSerializer < ActiveModel::Serializer
:step
end
def checklist_items
object.checklist_items.map do |item|
ChecklistItemSerializer.new(item, scope: { user: scope[:user] || @instance_options[:user] }).as_json
end
end
def sa_name
@user = scope[:user] || @instance_options[:user]
custom_auto_link(object.name,
@ -34,6 +28,7 @@ class ChecklistSerializer < ActiveModel::Serializer
return {} if object.destroyed? || !can_manage_step?(scope[:user] || @instance_options[:user], object.step)
{
checklist_items_url: step_checklist_checklist_items_path(object.step, object),
duplicate_url: duplicate_step_checklist_path(object.step, object),
delete_url: step_checklist_path(object.step, object),
update_url: step_checklist_path(object.step, object),

View file

@ -582,7 +582,7 @@ Rails.application.routes.draw do
post :move
post :duplicate
end
resources :checklist_items, controller: 'step_elements/checklist_items', only: %i(create update destroy) do
resources :checklist_items, controller: 'step_elements/checklist_items', only: %i(index create update destroy) do
patch :toggle, on: :member
post :reorder, on: :collection
end