Add form interacrtions to step [SCI-11341][SCI-11381]

This commit is contained in:
Anton 2024-12-30 10:06:12 +01:00 committed by Martin Artnik
parent 07b6a9e8db
commit f9d7066d55
24 changed files with 416 additions and 114 deletions

View file

@ -9,7 +9,8 @@ class FormFieldValuesController < ApplicationController
@form_field_value = @form_response.create_value!(
current_user,
@form_field,
form_field_value_params[:value]
form_field_value_params[:value],
not_applicable: form_field_value_params[:not_applicable]
)
render json: @form_field_value, serializer: FormFieldValueSerializer, user: current_user
@ -18,7 +19,7 @@ class FormFieldValuesController < ApplicationController
private
def form_field_value_params
params.require(:form_field_value).permit(:form_field_id, :value)
params.require(:form_field_value).permit(:form_field_id, :value, :not_applicable, value: [])
end
def load_form_response

View file

@ -1,68 +0,0 @@
# frozen_string_literal: true
class FormResponsesController < ApplicationController
before_action :load_form, only: :create
before_action :load_parent, only: :create
before_action :load_form_response, except: :create
def create
case @parent
when Step
render_403 and return unless can_create_protocol_form_responses?(@parent.protocol)
ActiveRecord::Base.transaction do
@form_response = FormResponse.create!(form: @form, created_by: current_user)
@parent.step_orderable_elements.create!(orderable: @form_response)
end
else
render_422
end
render json: @form_response, serializer: FormResponseSerializer, user: current_user
end
def submit
render_403 and return unless can_submit_form_response?(@form_response)
@form_response.submit!(current_user)
render json: @form_response, serializer: FormResponseSerializer, user: current_user
end
def reset
render_403 and return unless can_reset_form_response?(@form_response)
@form_response.reset!(current_user)
render json: @form_response, serializer: FormResponseSerializer, user: current_user
end
private
def form_response_params
params.require(:form_response).permit(:form_id, :parent_id, :parent_type)
end
def load_form
@form = Form.find_by(id: form_response_params[:form_id])
render_404 unless @form && can_read_form?(@form)
end
def load_parent
case form_response_params[:parent_type]
when 'Step'
@parent = Step.find_by(id: form_response_params[:parent_id])
else
return render_422
end
render_404 unless @parent
end
def load_form_response
@form_response = FormResponse.find_by(id: params[:id])
render_404 unless @form_response
end
end

View file

@ -0,0 +1,57 @@
# frozen_string_literal: true
module StepElements
class FormResponsesController < BaseController
before_action :load_form, only: :create
before_action :load_parent, only: :create
before_action :load_form_response, except: :create
def create
render_403 and return unless can_create_protocol_form_responses?(@parent.protocol)
ActiveRecord::Base.transaction do
@form_response = FormResponse.create!(form: @form, created_by: current_user)
@parent.step_orderable_elements.create!(orderable: @form_response)
end
render_step_orderable_element(@form_response)
end
def submit
render_403 and return unless can_submit_form_response?(@form_response)
@form_response.submit!(current_user)
render_step_orderable_element(@form_response)
end
def reset
render_403 and return unless can_reset_form_response?(@form_response)
new_form_response = @form_response.reset!(current_user)
render_step_orderable_element(@form_response)
end
private
def form_response_params
params.permit(:form_id, :step_id)
end
def load_form
@form = Form.find_by(id: form_response_params[:form_id])
render_404 unless @form && can_read_form?(@form)
end
def load_parent
@parent = Step.find_by(id: form_response_params[:step_id])
render_404 unless @parent
end
def load_form_response
@form_response = FormResponse.find_by(id: params[:id])
render_404 unless @form_response
end
end
end

View file

@ -3,21 +3,23 @@
<div>
<div class="font-bold">
{{ field.attributes.name }}
<span v-if="unit">({{ unit }})</span>
<span v-if="field.attributes.required" class="text-sn-delete-red">*</span>
</div>
<div v-if="field.attributes.description">
{{ field.attributes.description }}
</div>
<div class="mt-2">
<component :is="field.attributes.data.type" :field="field" :marked_as_na="mark_as_na" />
<component :is="field.attributes.data.type" ref="formField" :disabled="disabled" :field="field" :marked_as_na="markAsNa" @save="saveValue" />
</div>
</div>
<div class="flex justify-end items-end">
<button class="btn btn-secondary mb-0.5"
:disabled="disabled"
v-if="field.attributes.allow_not_applicable"
:class="{'!bg-sn-super-light-blue !border-sn-blue': mark_as_na}"
@click="mark_as_na = !mark_as_na">
<div class="w-4 h-4 !border-sn-blue border rounded-sm flex items-center justify-center" :class="{'bg-sn-blue': mark_as_na}">
:class="{'!bg-sn-super-light-blue !border-sn-blue': markAsNa}"
@click="markAsNa = !markAsNa">
<div class="w-4 h-4 !border-sn-blue border rounded-sm flex items-center justify-center" :class="{'bg-sn-blue': markAsNa}">
<i class="sn-icon sn-icon-check text-white" style="font-size: 16px !important;"></i>
</div>
{{ i18n.t('forms.fields.mark_as_na') }}
@ -36,7 +38,12 @@ import MultipleChoiceField from './fields/multiple_choice.vue';
export default {
name: 'ViewField',
props: {
field: Object
field: Object,
disabled: Boolean,
formResponse: {
type: Object,
default: null
}
},
components: {
DatetimeField,
@ -47,8 +54,30 @@ export default {
},
data() {
return {
mark_as_na: false
markAsNa: this.field.field_value?.not_applicable || false
};
},
watch: {
markAsNa() {
this.saveValue(null);
}
},
computed: {
unit() {
return this.field.attributes.data.unit;
}
},
methods: {
saveValue(value) {
if (this.formResponse) {
this.$emit(
'save',
this.field.id,
value,
this.markAsNa
);
}
}
}
};
</script>

View file

@ -4,13 +4,17 @@
<DateTimePicker
@change="updateFromDate"
:mode="mode"
:defaultValue="fromValue"
:clearable="true"
:disabled="fieldDisabled"
:placeholder="i18n.t('forms.fields.from')"
:class="{'error': !validValue}"
/>
<DateTimePicker
@change="updateToDate"
:defaultValue="toValue"
:mode="mode"
:disabled="fieldDisabled"
:clearable="true"
:placeholder="i18n.t('forms.fields.to')"
:class="{'error': !validValue}"
@ -19,7 +23,9 @@
<DateTimePicker
v-else
@change="updateDate"
:defaultValue="value"
:mode="mode"
:disabled="fieldDisabled"
:clearable="true"
:placeholder="i18n.t(`forms.fields.add_${mode}`)"
/>
@ -43,6 +49,15 @@ export default {
toValue: null
};
},
created() {
if (this.field.field_value?.datetime) {
this.value = new Date(this.field.field_value.datetime);
this.fromValue = new Date(this.field.field_value.datetime);
}
if (this.field.field_value?.datetime_to) {
this.toValue = new Date(this.field.field_value.datetime_to);
}
},
computed: {
mode() {
return this.field.attributes.data.time ? 'datetime' : 'date';
@ -57,15 +72,31 @@ export default {
return this.value;
}
},
watch: {
marked_as_na() {
if (this.marked_as_na) {
this.value = null;
this.fromValue = null;
this.toValue = null;
}
}
},
methods: {
updateDate(date) {
this.value = date;
this.$emit('save', this.value);
},
updateFromDate(date) {
this.fromValue = date;
if (this.validValue) {
this.$emit('save', [this.fromValue, this.toValue]);
}
},
updateToDate(date) {
this.toValue = date;
if (this.validValue) {
this.$emit('save', [this.fromValue, this.toValue]);
}
}
}
};

View file

@ -1,6 +1,12 @@
export default {
props: {
field: Object,
marked_as_na: Boolean
marked_as_na: Boolean,
disabled: Boolean
},
computed: {
fieldDisabled() {
return this.marked_as_na || this.disabled;
}
}
};

View file

@ -1,8 +1,10 @@
<template>
<div>
<SelectDropdown
:disabled="marked_as_na"
:disabled="fieldDisabled"
:options="options"
:value="value"
@change="saveValue"
:multiple="true"
:withCheckboxes="true"
:clearable="true"
@ -21,12 +23,20 @@ export default {
SelectDropdown
},
computed: {
value() {
return (this.field.field_value?.selection || '[]');
},
options() {
if (!this.field.attributes.data.options) {
return [];
}
return this.field.attributes.data.options.map((option) => ([option, option]));
}
},
methods: {
saveValue(value) {
this.$emit('save', value);
}
}
};
</script>

View file

@ -1,6 +1,6 @@
<template>
<div class="sci-input-container-v2 mb-2" :class="{'error': !isValidValue}" :data-error="errorMessage">
<input type="number" v-model="value" class="sci-input" :disabled="marked_as_na" :placeholder="i18n.t('forms.fields.add_number')"></input>
<input type="number" v-model="value" class="sci-input" :disabled="fieldDisabled" @change="saveValue" :placeholder="i18n.t('forms.fields.add_number')"></input>
</div>
</template>
@ -12,7 +12,7 @@ export default {
mixins: [fieldMixin],
data() {
return {
value: ''
value: this.field.field_value?.value
};
},
computed: {
@ -43,6 +43,13 @@ export default {
return '';
}
},
methods: {
saveValue() {
if (this.isValidValue) {
this.$emit('save', this.value);
}
}
}
};
</script>

View file

@ -1,8 +1,10 @@
<template>
<div>
<SelectDropdown
:disabled="marked_as_na"
:disabled="fieldDisabled"
:options="options"
:value="value"
@change="saveValue"
:clearable="true"
/>
</div>
@ -19,12 +21,20 @@ export default {
SelectDropdown
},
computed: {
value() {
return this.field.field_value?.value;
},
options() {
if (!this.field.attributes.data.options) {
return [];
}
return this.field.attributes.data.options.map((option) => ([option, option]));
}
},
methods: {
saveValue(value) {
this.$emit('save', value);
}
}
};
</script>

View file

@ -1,6 +1,6 @@
<template>
<div class="sci-input-container-v2 h-24">
<textarea class="sci-input" :disabled="marked_as_na" :placeholder="i18n.t('forms.fields.add_text')"></textarea>
<textarea class="sci-input" :value="value" :disabled="fieldDisabled" @change="saveValue" :placeholder="i18n.t('forms.fields.add_text')"></textarea>
</div>
</template>
@ -9,6 +9,16 @@ import fieldMixin from './field_mixin';
export default {
name: 'TextField',
mixins: [fieldMixin]
mixins: [fieldMixin],
methods: {
saveValue(event) {
this.$emit('save', event.target.value);
}
},
computed: {
value() {
return this.field.field_value?.value;
}
}
};
</script>

View file

@ -168,7 +168,11 @@
@reorder="updateElementOrder"
@close="closeReorderModal"
/>
<SelectFormModal v-if="openFormSelectModal" @close="openFormSelectModal = false" />
<SelectFormModal
v-if="openFormSelectModal"
@close="openFormSelectModal = false"
@submit="createElement('form_response', null, $event); openFormSelectModal = false"
/>
</div>
</template>
@ -183,6 +187,7 @@
import StepTable from '../shared/content/table.vue'
import StepText from '../shared/content/text.vue'
import Checklist from '../shared/content/checklist.vue'
import FormResponse from '../shared/content/form_response.vue'
import deleteStepModal from './modals/delete_step.vue'
import Attachments from '../shared/content/attachments.vue'
import ReorderableItemsModal from '../shared/reorderable_items_modal.vue'
@ -285,7 +290,8 @@
ReorderableItemsModal,
MenuDropdown,
ContentToolbar,
SelectFormModal
SelectFormModal,
FormResponse
},
created() {
this.loadAttachments();
@ -624,10 +630,10 @@
}
});
},
createElement(elementType, tableDimensions = null) {
createElement(elementType, tableDimensions = null, formId = null) {
let plateTemplate = tableDimensions != null;
tableDimensions ||= [5, 5];
$.post(this.urls[`create_${elementType}_url`], { tableDimensions: tableDimensions, plateTemplate: plateTemplate }, (result) => {
$.post(this.urls[`create_${elementType}_url`], { tableDimensions: tableDimensions, plateTemplate: plateTemplate, form_id: formId }, (result) => {
result.data.isNew = true;
this.elements.push(result.data)

View file

@ -0,0 +1,165 @@
<template>
<div class="content__form-container pr-8" :data-e2e="`e2e-CO-${dataE2e}-stepForm${element.id}`">
<div class="sci-divider my-6" v-if="!inRepository"></div>
<div class="flex items-center gap-4">
<MenuDropdown
class="ml-auto"
:listItems="this.actionMenu"
:btnClasses="'btn btn-light icon-btn btn-sm'"
:position="'right'"
:btnIcon="'sn-icon sn-icon-more-hori'"
:dataE2e="`e2e-DD-${dataE2e}-stepText${element.id}-options`"
@move="showMoveModal"
@delete="showDeleteModal"
></MenuDropdown>
</div>
<div class="max-w-[800px] w-full rounded bg-sn-super-light-grey p-6 flex flex-col gap-4">
<div class="flex items-center">
<h3 class="my-1">{{ form.name }}</h3>
<div v-if="this.formResponse.status == 'submitted'" class="ml-auto text-right text-xs text-sn-grey-700">
{{ i18n.t('forms.response.submitted_on') }} {{ this.formResponse.submitted_at }}<br>
{{ i18n.t('forms.response.by') }} {{ this.formResponse.submitted_by_full_name }}
</div>
</div>
<Field v-for="field in formFields" :disabled="formDisabled" ref="formFields" :key="field.id" :field="field" :formResponse="formResponse" @save="saveValue" />
<div>
<button v-if="this.formResponse.urls.submit" class="btn btn-primary" :disabled="!validResponse" @click="submitForm">
{{ i18n.t('forms.response.submit') }}
</button>
<button v-else-if="this.formResponse.urls.reset" class="btn btn-secondary" @click="resetForm">
{{ i18n.t('general.edit')}}
</button>
</div>
</div>
<deleteElementModal v-if="confirmingDelete" @confirm="deleteElement($event)" @cancel="closeDeleteModal"/>
<moveElementModal v-if="movingElement"
:parent_type="element.attributes.orderable.parent_type"
:targets_url="''"
@confirm="moveElement($event)" @cancel="closeMoveModal"/>
</div>
</template>
<script>
/* global I18n */
import DeleteMixin from './mixins/delete.js';
import MoveMixin from './mixins/move.js';
import deleteElementModal from './modal/delete.vue';
import moveElementModal from './modal/move.vue';
import MenuDropdown from '../menu_dropdown.vue';
import Field from '../../forms/field.vue';
import axios from '../../../packs/custom_axios.js';
export default {
name: 'TextContent',
components: {
deleteElementModal,
moveElementModal,
MenuDropdown,
Field
},
mixins: [DeleteMixin, MoveMixin],
props: {
element: {
type: Object,
required: true
},
inRepository: {
type: Boolean,
required: true
},
reorderElementUrl: {
type: String
},
dataE2e: {
type: String,
default: ''
}
},
data() {
return {
form: this.element.attributes.orderable.form,
formResponse: this.element.attributes.orderable,
formFieldValues: this.element.attributes.orderable.form_field_values
};
},
mounted() {
},
computed: {
formDisabled() {
return !this.formResponse.urls.submit;
},
validResponse() {
return this.formFields.every((field) => {
if (field.attributes.required) {
return field.field_value?.value
|| field.field_value?.selection
|| field.field_value?.datetime
|| field.field_value?.datetime_to
|| field.field_value?.not_applicable;
}
return true;
});
},
formFields() {
return this.element.attributes.orderable.form_fields.map((field) => ({
id: field.id,
attributes: field,
field_value: this.formFieldValues.find((value) => value.form_field_id === field.id)
}));
},
actionMenu() {
const menu = [];
if (this.element.attributes.orderable.urls.move_targets_url) {
menu.push({
text: I18n.t('general.move'),
emit: 'move',
data_e2e: `e2e-BT-${this.dataE2e}-stepText${this.element.id}-options-move`
});
}
if (this.element.attributes.orderable.urls.delete_url) {
menu.push({
text: I18n.t('general.delete'),
emit: 'delete',
data_e2e: `e2e-BT-${this.dataE2e}-stepText${this.element.id}-options-delete`
});
}
return menu;
}
},
methods: {
saveValue(formFieldId, value, markAsNa) {
axios.post(this.formResponse.urls.add_value, {
form_field_value: {
form_field_id: formFieldId,
value,
not_applicable: markAsNa
}
}).then((response) => {
if (this.formFieldValues.find((formFieldValue) => formFieldValue.form_field_id === formFieldId)) {
this.formFieldValues = this.formFieldValues.map((formFieldValue) => {
if (formFieldValue.form_field_id === formFieldId) {
return response.data.data.attributes;
}
return formFieldValue;
});
} else {
this.formFieldValues.push(response.data.data.attributes);
}
});
},
submitForm() {
axios.post(this.formResponse.urls.submit).then((response) => {
const { attributes } = response.data.data;
this.formResponse = attributes.orderable;
});
},
resetForm() {
axios.post(this.formResponse.urls.reset).then((response) => {
const { attributes } = response.data.data;
this.formResponse = attributes.orderable;
});
}
}
};
</script>

View file

@ -29,7 +29,7 @@
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="close">{{ i18n.t('general.cancel') }}</button>
<button v-if="anyForms" :disabled="!form" @submit="$emit('submit', form)" class="btn btn-primary">
<button v-if="anyForms" :disabled="!form" @click="$emit('submit', form)" class="btn btn-primary">
{{ i18n.t('protocols.steps.modals.form_modal.add_form') }}
</button>
<a v-else :href="formsPageUrl" class="btn btn-primary">

View file

@ -14,6 +14,7 @@
:format="format"
:month-change-on-scroll="false"
:six-weeks="true"
:disabled="disabled"
:auto-apply="true"
:partial-flow="true"
:markers="markers"

View file

@ -25,7 +25,7 @@ class FormResponse < ApplicationRecord
step_orderable_element&.step
end
def create_value!(created_by, form_field, value)
def create_value!(created_by, form_field, value, not_applicable: false)
ActiveRecord::Base.transaction(requires_new: true) do
form_field_values.where(form_field: form_field).find_each do |form_field_value|
form_field_value.update!(latest: false)
@ -38,7 +38,8 @@ class FormResponse < ApplicationRecord
created_by: created_by,
submitted_by: created_by,
submitted_at: DateTime.current,
value: value
value: value,
not_applicable: not_applicable
)
end
end

View file

@ -15,6 +15,7 @@ Canaid::Permissions.register_for(FormResponse) do
case parent
when Step
next false unless parent.protocol.my_module # protocol template forms can't be submitted
next false unless form_response.pending?
parent.protocol.my_module.permission_granted?(user, FormResponsePermissions::SUBMIT)
end
@ -25,6 +26,7 @@ Canaid::Permissions.register_for(FormResponse) do
case parent
when Step
next false unless parent.protocol.my_module # protocol template forms can't be reset
next false unless form_response.submitted?
parent.protocol.my_module.permission_granted?(user, FormResponsePermissions::SUBMIT)
end

View file

@ -3,7 +3,8 @@
class FormFieldValueSerializer < ActiveModel::Serializer
include Canaid::Helpers::PermissionsHelper
attributes :form_field_id, :type, :value, :submitted_at, :submitted_by_full_name, :unit
attributes :form_field_id, :type, :value, :submitted_at, :submitted_by_full_name,
:unit, :not_applicable, :selection, :datetime, :datetime_to
def submitted_by_full_name
object.submitted_by.full_name

View file

@ -1,15 +0,0 @@
# frozen_string_literal: true
class FormResponseSerializer < ActiveModel::Serializer
include Canaid::Helpers::PermissionsHelper
attributes :id, :created_at, :form_id
has_many :form_field_values do
object.form_field_values.latest
end
def submitted_by_full_name
object.submitted_by.full_name
end
end

View file

@ -23,15 +23,16 @@ class FormSerializer < ActiveModel::Serializer
end
def urls
user = scope[:user] || @instance_options[:user]
list = {
show: form_path(object),
create_field: form_form_fields_path(object),
reorder_fields: reorder_form_form_fields_path(object)
}
list[:publish] = publish_form_path(object) if can_publish_form?(current_user, object)
list[:publish] = publish_form_path(object) if can_publish_form?(user, object)
list[:unpublish] = unpublish_form_path(object) if can_unpublish_form?(current_user, object)
list[:unpublish] = unpublish_form_path(object) if can_unpublish_form?(user, object)
list
end

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
class StepFormResponseSerializer < ActiveModel::Serializer
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
attributes :id, :created_at, :form_id, :urls, :submitted_by_full_name, :status, :submitted_at
has_one :form, serializer: FormSerializer
has_many :form_fields, serializer: FormFieldSerializer do
object.form.form_fields
end
has_many :form_field_values do
object.form_field_values.latest
end
def submitted_by_full_name
object.submitted_by&.full_name
end
def submitted_at
I18n.l(object.submitted_at, format: :full) if object.submitted_at
end
def urls
user = scope[:user] || @instance_options[:user]
list = {
add_value: form_response_form_field_values_path(object)
}
list[:submit] = submit_step_form_response_path(object.step ,object) if can_submit_form_response?(user, object)
list[:reset] = reset_step_form_response_path(object.step ,object) if can_reset_form_response?(user, object)
list
end
end

View file

@ -11,6 +11,8 @@ class StepOrderableElementSerializer < ActiveModel::Serializer
TableSerializer.new(object.orderable.table, scope: { user: @instance_options[:user] }).as_json
when 'StepText'
StepTextSerializer.new(object.orderable, scope: { user: @instance_options[:user] }).as_json
when 'FormResponse'
StepFormResponseSerializer.new(object.orderable, scope: { user: @instance_options[:user] }).as_json
end
end
end

View file

@ -97,6 +97,7 @@ class StepSerializer < ActiveModel::Serializer
create_table_url: step_tables_path(object),
create_text_url: step_texts_path(object),
create_checklist_url: step_checklists_path(object),
create_form_response_url: step_form_responses_path(object),
update_asset_view_mode_url: update_asset_view_mode_step_path(object),
update_view_state_url: update_view_state_step_path(object),
direct_upload_url: rails_direct_uploads_url,

View file

@ -1133,6 +1133,10 @@ en:
SingleChoiceField: 'Single choice'
MultipleChoiceField: 'Multiple choice'
DatetimeField: 'Date & Time'
response:
submit: 'Submit form'
submitted_on: 'Submitted on'
by: 'by'
label_templates:
types:
fluics_label_template: 'Fluics'

View file

@ -613,6 +613,12 @@ Rails.application.routes.draw do
post :reorder, on: :collection
end
end
resources :form_responses, controller: 'step_elements/form_responses', only: %i(create) do
member do
post :submit
post :reset
end
end
member do
get 'elements'
get 'attachments'
@ -876,12 +882,7 @@ Rails.application.routes.draw do
end
end
resources :form_responses, only: %i(create) do
member do
post :submit
post :reset
end
resources :form_responses, only: [] do
resources :form_field_values, only: %i(create)
end