Merge pull request #8093 from andrej-scinote/aj_SCI_11354

Fix reordering of the form fields [SCI-11354]
This commit is contained in:
andrej-scinote 2024-12-10 14:23:59 +01:00 committed by Anton
commit 705fc4da14
23 changed files with 611 additions and 16 deletions

View file

@ -39,7 +39,7 @@ class FormFieldsController < ApplicationController
if @form_field.discard
render json: {}
else
render json: { error: @storage_location.errors.full_messages }, status: :unprocessable_entity
render json: { error: @form_field.errors.full_messages }, status: :unprocessable_entity
end
end
end
@ -47,8 +47,8 @@ class FormFieldsController < ApplicationController
def reorder
ActiveRecord::Base.transaction do
params.permit(form_field_positions: %i(position id))[:form_field_positions].each do |data|
form_field = @form.form_fields.find(data['id'].to_i)
form_field.insert_at(data['position'].to_i)
form_field = @form.form_fields.find(data[:id])
form_field.insert_at(data[:position].to_i)
end
end

View file

@ -2,6 +2,7 @@
class FormsController < ApplicationController
before_action :load_form, only: %i(show update publish unpublish)
before_action :set_breadcrumbs_items, only: %i(index show)
def index
respond_to do |format|
@ -10,14 +11,15 @@ class FormsController < ApplicationController
forms = Lists::FormsService.new(current_user, current_team, params).call
render json: forms,
each_serializer: Lists::FormSerializer,
user: current_user
user: current_user,
meta: pagination_dict(forms)
end
end
end
def show
respond_to do |format|
format.json { render json: @form, serializer: Lists::FormSerializer, include: %i(form_fields), user: current_user }
format.json { render json: @form, serializer: FormSerializer, include: %i(form_fields), user: current_user }
format.html
end
end
@ -117,8 +119,36 @@ class FormsController < ApplicationController
end
end
def actions_toolbar
render json: {
actions:
Toolbars::FormsService.new(
current_user,
form_ids: JSON.parse(params[:items]).map { |i| i['id'] }
).actions
}
end
private
def set_breadcrumbs_items
@breadcrumbs_items = []
@breadcrumbs_items.push(
{ label: t('breadcrumbs.templates') }
)
@breadcrumbs_items.push(
{ label: t('breadcrumbs.forms'), url: forms_path }
)
if @form
@breadcrumbs_items.push(
{ label: @form.name }
)
end
end
def load_form
@form = Form.find_by(id: params[:id])

View file

@ -35,6 +35,10 @@ module LeftMenuBarHelper
icon: 'sn-icon-protocols-templates',
active: protocols_are_selected? || label_templates_are_selected?,
submenu: [{
url: forms_path,
name: t('left_menu_bar.forms'),
active: forms_are_selected?
}, {
url: protocols_path,
name: t('left_menu_bar.protocol'),
active: protocols_are_selected?
@ -79,6 +83,10 @@ module LeftMenuBarHelper
controller_name == 'protocols'
end
def forms_are_selected?
controller_name == 'forms'
end
def label_templates_are_selected?
controller_name == 'label_templates'
end

View file

@ -0,0 +1,10 @@
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import FormShow from '../../vue/forms/show.vue';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
const app = createApp();
app.component('FormShow', FormShow);
app.config.globalProperties.i18n = window.I18n;
app.use(PerfectScrollbar);
mountWithTurbolinks(app, '#formShow');

View file

@ -0,0 +1,10 @@
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import FormsTable from '../../vue/forms/table.vue';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
const app = createApp();
app.component('FormsTable', FormsTable);
app.config.globalProperties.i18n = window.I18n;
app.use(PerfectScrollbar);
mountWithTurbolinks(app, '#formsTable');

View file

@ -0,0 +1,95 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex items-center">
<h3>{{ i18n.t(`forms.show.blocks.${editField.attributes.type}`) }}</h3>
<div class="ml-auto flex items-center gap-3">
<div class="flex items-center gap-2">
<span class="sci-toggle-checkbox-container">
<input type="checkbox"
class="sci-toggle-checkbox"
@change="updateField"
v-model="editField.attributes.required" />
<span class="sci-toggle-checkbox-label"></span>
</span>
<span>{{ i18n.t('forms.show.required_label') }}</span>
</div>
<GeneralDropdown position="right">
<template v-slot:field>
<button class="btn btn-secondary icon-btn">
<i class="sn-icon sn-icon-more-hori"></i>
</button>
</template>
<template v-slot:flyout>
<div @click="deleteField" class="py-2.5 px-3 hover:bg-sn-super-light-grey cursor-pointer text-sn-delete-red">
{{ i18n.t('forms.show.delete') }}
</div>
</template>
</GeneralDropdown>
</div>
</div>
<hr class="my-4 w-full">
<div>
<label class="sci-label">{{ i18n.t('forms.show.title_label') }}</label>
<div class="sci-input-container-v2" :class="{ 'error': editField.attributes.name.length == 0 }" >
<input type="text" class="sci-input" v-model="editField.attributes.name" @change="updateField" :placeholder="i18n.t('forms.show.title_placeholder')" />
</div>
</div>
<div>
<label class="sci-label">{{ i18n.t('forms.show.description_label') }}</label>
<div class="sci-input-container-v2" >
<input type="text" class="sci-input" v-model="editField.attributes.description" @change="updateField" :placeholder="i18n.t('forms.show.description_placeholder')" />
</div>
</div>
<hr class="my-4 w-full">
<div class="bg-sn-super-light-grey rounded p-4">
<div class="flex items-center gap-4">
<h5>{{ i18n.t('forms.show.mark_as_na') }}</h5>
<span class="sci-toggle-checkbox-container">
<input type="checkbox"
class="sci-toggle-checkbox"
@change="updateField"
v-model="editField.attributes.allow_not_applicable" />
<span class="sci-toggle-checkbox-label"></span>
</span>
</div>
<div>{{ i18n.t('forms.show.mark_as_na_explanation') }}</div>
</div>
</div>
</template>
<script>
import GeneralDropdown from '../shared/general_dropdown.vue';
export default {
name: 'EditField',
props: {
field: Object
},
components: {
GeneralDropdown
},
data() {
return {
editField: { ...this.field }
};
},
created() {
},
computed: {
validField() {
return this.editField.attributes.name.length > 0;
}
},
methods: {
updateField() {
if (!this.validField) {
return;
}
this.$emit('update', this.editField);
},
deleteField() {
this.$emit('delete', this.editField);
}
}
};
</script>

View file

@ -0,0 +1,16 @@
<template>
<a :href="params.data.urls.show" :title="params.data.name">
{{ params.data.name }}
</a>
</template>
<script>
export default {
props: {
params: {
type: Object,
required: true
}
}
};
</script>

View file

@ -0,0 +1,169 @@
<template>
<div v-if="form" class="content-pane flexible with-grey-background">
<div class="content-header flex items-center mb-4">
<div class="title-row">
<h1>
<InlineEdit
v-if="canManage"
:value="form.attributes.name"
:characterLimit="255"
:characterMinLimit="2"
:allowBlank="false"
@editingEnabled="editingName = true"
@editingDisabled="editingName = false"
@update="updateName"
/>
<template v-else>
{{ form.attributes.name }}
</template>
</h1>
</div>
<div class="flex items-center gap-4 ml-auto">
<button class="btn btn-secondary">
<i class="sn-icon sn-icon-visibility-show"></i>
{{ i18n.t('forms.show.test_form') }}
</button>
<button class="btn btn-primary">
{{ i18n.t('forms.show.publish') }}
</button>
</div>
</div>
<div class="content-body">
<div class="bg-white rounded-xl grid grid-cols-[360px_auto] min-h-[calc(100vh_-_200px)]">
<div class="p-6 border-transparent border-r-sn-sleepy-grey border-solid border-r">
<h3 class="mb-3">{{ i18n.t('forms.show.build_form') }}</h3>
<div class="mb-3 flex flex-col gap-3">
<div v-for="(field) in fields"
@click="activeField = field"
:key="field.id"
class="font-bold p-3 rounded-lg border-sn-grey-100 cursor-pointer border"
:class="{ '!border-sn-blue bg-sn-super-light-blue': activeField.id === field.id }"
>
{{ field.attributes.name }}
</div>
</div>
<GeneralDropdown>
<template v-slot:field>
<button class="btn btn-secondary w-full">
<i class="sn-icon sn-icon-new-task"></i>
{{ i18n.t('forms.show.add_block') }}
</button>
</template>
<template v-slot:flyout>
<div v-for="e in newFields" :key="e.type" @click="addField(e.type)" class="py-2.5 px-3 hover:bg-sn-super-light-grey cursor-pointer">
{{ e.name }}
</div>
</template>
</GeneralDropdown>
</div>
<div class="p-6">
<EditField
:key="activeField.id"
v-if="activeField.id"
:field="activeField"
@update="updateField"
@delete="deleteField"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import InlineEdit from '../shared/inline_edit.vue';
import axios from '../../packs/custom_axios.js';
import GeneralDropdown from '../shared/general_dropdown.vue';
import EditField from './edit_field.vue';
export default {
name: 'ShowForm',
props: {
formUrl: String
},
components: {
InlineEdit,
GeneralDropdown,
EditField
},
computed: {
canManage() {
return true;
},
newFields() {
return [
{ name: this.i18n.t('forms.show.blocks.text'), type: 'text' },
{ name: this.i18n.t('forms.show.blocks.number'), type: 'number' },
{ name: this.i18n.t('forms.show.blocks.single_choice'), type: 'single_choice' },
{ name: this.i18n.t('forms.show.blocks.multiple_choice'), type: 'multiple_choice' },
{ name: this.i18n.t('forms.show.blocks.datetime'), type: 'datetime' }
];
}
},
created() {
this.loadForm();
},
data() {
return {
form: null,
fields: [],
activeField: {}
};
},
methods: {
loadForm() {
axios.get(this.formUrl).then((response) => {
this.form = response.data.data;
this.fields = response.data.included || [];
if (this.fields.length > 0) {
[this.activeField] = this.fields;
}
});
},
addField(type) {
axios.post(this.form.attributes.urls.create_field, {
form_field: {
name: this.i18n.t(`forms.show.blocks.${type}`),
data: {
type
}
}
}).then((response) => {
this.fields.push(response.data.data);
if (this.fields.length === 1) {
[this.activeField] = this.fields;
}
});
},
updateName(name) {
axios.put(this.formUrl, {
form: {
name
}
}).then(() => {
this.form.attributes.name = name;
});
},
updateField(field) {
const index = this.fields.findIndex((f) => f.id === field.id);
axios.put(field.attributes.urls.show, {
form_field: field.attributes
}).then((response) => {
this.fields.splice(index, 1, response.data.data);
});
},
deleteField(field) {
const index = this.fields.findIndex((f) => f.id === field.id);
axios.delete(field.attributes.urls.show).then(() => {
this.fields.splice(index, 1);
if (this.fields.length > 0) {
[this.activeField] = this.fields;
} else {
this.activeField = {};
}
});
}
}
};
</script>

View file

@ -0,0 +1,113 @@
<template>
<div class="h-full">
<DataTable :columnDefs="columnDefs"
:tableId="'FormsTable'"
:dataUrl="dataSource"
:reloadingTable="reloadingTable"
:toolbarActions="toolbarActions"
:actionsUrl="actionsUrl"
@tableReloaded="reloadingTable = false"
@create="createForm"
/>
</div>
</template>
<script>
/* global HelperModule */
import axios from '../../packs/custom_axios.js';
import DataTable from '../shared/datatable/table.vue';
import DeleteModal from '../shared/confirmation_modal.vue';
import NameRenderer from './renderers/name.vue';
export default {
name: 'FormsTable',
components: {
DataTable,
DeleteModal,
NameRenderer
},
props: {
dataSource: {
type: String,
required: true
},
actionsUrl: {
type: String,
required: true
},
createUrl: {
type: String
}
},
data() {
return {
reloadingTable: false,
columnDefs: [
{
field: 'name',
headerName: this.i18n.t('forms.index.table.name'),
cellRenderer: 'NameRenderer',
sortable: true
}, {
field: 'code',
headerName: this.i18n.t('forms.index.table.code'),
sortable: true
}, {
field: 'versions',
headerName: this.i18n.t('forms.index.table.versions'),
sortable: true
}, {
field: 'used_in_protocols',
headerName: this.i18n.t('forms.index.table.used_in_protocols'),
sortable: true
}, {
field: 'access',
headerName: this.i18n.t('forms.index.table.access'),
sortable: true
}, {
field: 'published_by',
headerName: this.i18n.t('forms.index.table.published_by'),
sortable: true
}, {
field: 'published_on',
headerName: this.i18n.t('forms.index.table.published_on'),
sortable: true
}, {
field: 'updated_at',
headerName: this.i18n.t('forms.index.table.updated_on'),
sortable: true
}
]
};
},
computed: {
toolbarActions() {
const left = [];
if (this.createUrl) {
left.push({
name: 'create',
icon: 'sn-icon sn-icon-new-task',
label: this.i18n.t('forms.index.toolbar.new'),
type: 'emit',
path: this.createUrl,
buttonStyle: 'btn btn-primary'
});
}
return {
left,
right: []
};
}
},
methods: {
createForm(action) {
axios.post(action.path).then((response) => {
window.location.href = response.data.data.attributes.urls.show;
});
}
}
};
</script>

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Form < ApplicationRecord
ID_PREFIX = 'FR'
include PrefixedIdModel
include ArchivableModel
belongs_to :team

View file

@ -3,6 +3,8 @@
class FormField < ApplicationRecord
include Discard::Model
default_scope -> { kept }
belongs_to :form
belongs_to :created_by, class_name: 'User'
belongs_to :last_modified_by, class_name: 'User'
@ -11,5 +13,8 @@ class FormField < ApplicationRecord
validates :description, length: { maximum: Constants::NAME_MAX_LENGTH }
validates :position, presence: true, uniqueness: { scope: :form }
acts_as_list scope: :form, top_of_list: 0, sequential_updates: true
acts_as_list scope: [:form, discarded_at: nil], top_of_list: 0, sequential_updates: true
private
end

View file

@ -1,9 +1,19 @@
# frozen_string_literal: true
class FormFieldSerializer < ActiveModel::Serializer
attributes :id, :name, :description, :updated_at, :type, :required, :allow_not_applicable, :uid, :position
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
attributes :id, :name, :description, :updated_at, :type, :required,
:allow_not_applicable, :uid, :position, :urls
def type
object.data[:type]
object.data['type']
end
def urls
{
show: form_form_field_path(object.form, object)
}
end
end

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
class FormSerializer < ActiveModel::Serializer
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
attributes :id, :name, :published_on, :published_by, :updated_at, :urls
has_many :form_fields,
key: :form_fields,
serializer: FormFieldSerializer,
order: :position
def published_by
object.published_by&.full_name
end
def published_on
I18n.l(object.published_on, format: :full) if object.published_on
end
def updated_at
I18n.l(object.updated_at, format: :full) if object.updated_at
end
def urls
{
show: form_path(object),
create_field: form_form_fields_path(object)
}
end
end

View file

@ -2,9 +2,10 @@
module Lists
class FormSerializer < ActiveModel::Serializer
attributes :id, :name, :description, :published_on, :published_by, :updated_at
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
has_many :form_fields, key: :form_fields, serializer: FormFieldSerializer
attributes :id, :name, :published_on, :published_by, :updated_at, :urls, :code
def published_by
object.published_by&.full_name
@ -17,5 +18,11 @@ module Lists
def updated_at
I18n.l(object.updated_at, format: :full) if object.updated_at
end
def urls
{
show: form_path(object)
}
end
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
module Toolbars
class FormsService
attr_reader :current_user
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
def initialize(current_user, form_ids: [])
@current_user = current_user
@forms = Form.where(id: forms_ids)
@single = @forms.length == 1
end
def actions
return [] if @forms.none?
[].compact
end
private
end
end

View file

@ -0,0 +1,18 @@
<div class="content-pane flexible">
<div class="content-header sticky-header">
<div class="title-row">
<h1>
<%= t('forms.index.head_title') %>
</h1>
</div>
</div>
<div id="formsTable" class="fixed-content-body"
>
<forms-table
actions-url="<%= actions_toolbar_forms_url %>"
data-source="<%= forms_path(format: :json) %>"
create-url="<%= forms_path %>"
/>
</div>
</div>
<%= javascript_include_tag 'vue_form_table' %>

View file

@ -0,0 +1,7 @@
<div id="formShow" >
<form-show
form-url="<%= form_path(@form) %>"
/>
</div>
<%= javascript_include_tag 'vue_form_show' %>

View file

@ -678,6 +678,7 @@ class Extends
search/index
storage_locations/index
storage_locations/show
forms/show
)
DEFAULT_USER_NOTIFICATION_SETTINGS = {
@ -706,6 +707,8 @@ class Extends
ProjectList_archived_state
ProtocolTemplates_active_state
ProtocolTemplates_archived_state
FormsTable_active_state
FormsTable_archived_state
ReportTemplates_active_state
ReportTemplates_archived_state
Repositories_active_state

View file

@ -350,6 +350,7 @@ en:
projects: "Projects"
repositories: "Inventories"
templates: "Templates"
forms: "Forms"
protocol: "Protocol"
label: "Label"
items: "Items"
@ -1053,7 +1054,6 @@ en:
select_user_role: "Please select a user role."
add_user_generic_error: "An error occurred. "
can_add_user_to_project: "Can not add user to the project."
forms:
default_name: "Untitled form"
restored:
@ -1062,7 +1062,38 @@ en:
archived:
success_flash: "<strong>%{number}</strong> form(s) successfully archived."
error_flash: "Failed to archive form(s)."
index:
head_title: "Forms"
toolbar:
new: 'New form'
table:
name: 'Form name'
code: 'ID'
versions: 'Versions'
used_in_protocols: 'Used in protocols'
access: 'Access'
published_by: 'Published by'
published_on: 'Published on'
updated_on: 'Updated on'
show:
build_form: 'Build your form'
add_block: 'Add a block'
title_label: Title (required)
title_placeholder: 'Add a title'
description_label: Description
description_placeholder: 'Add a description'
required_label: 'Required'
mark_as_na: 'Mark as N/A'
mark_as_na_explanation: 'Allow user to mark the field as Not-applicable.'
delete: 'Delete'
test_form: 'Test form'
publish: 'Publish'
blocks:
text: 'Text'
number: 'Number'
single_choice: 'Single choice'
multiple_choice: 'Multiple choice'
datetime: 'Date & Time'
label_templates:
types:
fluics_label_template: 'Fluics'
@ -4483,6 +4514,7 @@ en:
locations: "Locations"
label_printer: "Label printer"
fluics_printer: "Fluics printer"
forms: "Forms"
Add: "Add"
Asset: "File"

View file

@ -857,6 +857,7 @@ Rails.application.routes.draw do
end
collection do
get :actions_toolbar
post :archive
post :restore
end

View file

@ -68,7 +68,9 @@ const entryList = {
vue_legacy_repository_menu_dropdown: './app/javascript/packs/vue/legacy/repository_menu_dropdown.js',
vue_dashboard_new_task: './app/javascript/packs/vue/dashboard_new_task.js',
vue_storage_locations_table: './app/javascript/packs/vue/storage_locations_table.js',
vue_storage_locations_container: './app/javascript/packs/vue/storage_locations_container.js'
vue_storage_locations_container: './app/javascript/packs/vue/storage_locations_container.js',
vue_form_show: './app/javascript/packs/vue/forms_show.js',
vue_form_table: './app/javascript/packs/vue/forms_table.js'
};
// Engine pack loading based on https://github.com/rails/webpacker/issues/348#issuecomment-635480949

View file

@ -33,8 +33,6 @@ class CreateForms < ActiveRecord::Migration[7.0]
t.string :uid
t.datetime :discarded_at, index: true
t.index %i(form_id position), unique: true
t.timestamps
end
end

View file

@ -47,7 +47,7 @@ describe FormFieldsController, type: :controller do
end
end
describe 'PUT create' do
describe 'PUT update' do
let(:action) { put :update, params: params, format: :json }
let(:params) do
{