diff --git a/Procfile.webpack b/Procfile.webpack
new file mode 100644
index 000000000..6b8c62be8
--- /dev/null
+++ b/Procfile.webpack
@@ -0,0 +1,3 @@
+web: bundle exec rake cf:run_migrations db:migrate && bin/rails server -p $PORT
+worker: bundle exec sidekiq -L ./log/worker.log -C ./config/sidekiq.yml
+webpack: bin/webpack-dev-server
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/comments.js.coffee b/app/assets/javascripts/admin/comments.js.coffee
index da4d82250..cab48ec08 100644
--- a/app/assets/javascripts/admin/comments.js.coffee
+++ b/app/assets/javascripts/admin/comments.js.coffee
@@ -1,5 +1,5 @@
ready = ->
- $('body').on 'submit', '.new_comment', (e) ->
+ $(document).on 'submit', '.new_comment', (e) ->
that = $(this)
e.preventDefault()
$.ajax
@@ -30,8 +30,9 @@ ready = ->
else
signatureWrapper.html("")
+ window.fire(that[0], 'ajax:x:success', data)
- $('body').on 'submit', '.destroy-comment', (e) ->
+ $(document).on 'submit', '.destroy-comment', (e) ->
e.preventDefault()
$.ajax
url: $(this).attr('action')
diff --git a/app/assets/javascripts/admin/financial_data.js.coffee b/app/assets/javascripts/admin/financial_data.js.coffee
index c548a5db0..96df61541 100644
--- a/app/assets/javascripts/admin/financial_data.js.coffee
+++ b/app/assets/javascripts/admin/financial_data.js.coffee
@@ -6,14 +6,13 @@ jQuery ->
overallBenchmarksTable = ($ "#overall-financial-benchmarks")
financialTable = ($ "#financial-table")
- ($ "input", form).on "change keyup keydown paste", ->
- timer ||= setTimeout(saveFinancials, 500 )
+ $("input", form).on "change keyup keydown paste", ->
+ timer ||= setTimeout(saveFinancials, 500)
($ "button", form).on "click", (event) ->
do event.preventDefault
$(this).closest(".form-group").removeClass("form-edit")
-
updateExportsGrowth = (exports) ->
exportsGrowth = ($ 'tr.exports-growth td.value', benchmarksTable)
values = exports.map (i, td) ->
@@ -91,13 +90,13 @@ jQuery ->
url = form.attr('action')
formData = form.serialize()
updateBenchmarks()
-
- jqXHR = $.ajax({
+ $.ajax
url: url
data: formData
type: 'POST'
- dataType: 'js'
- })
+ dataType: 'script'
+ success: (_data) ->
+ window.fire(form[0], 'ajax:x:success', null)
($ 'td.value', financialTable).each (i, td) ->
input = ($ 'input', ($ td))
diff --git a/app/assets/javascripts/admin/focusable.js.coffee b/app/assets/javascripts/admin/focusable.js.coffee
new file mode 100644
index 000000000..4d5f3c536
--- /dev/null
+++ b/app/assets/javascripts/admin/focusable.js.coffee
@@ -0,0 +1,25 @@
+FOCUSABLE_ELEMENTS = 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [href], [tabindex]:not([tabindex="-1"]):not([disabled]), details:not([disabled]), summary:not(:disabled)'
+FOCUSABLE_FORM_ELEMENTS = 'input:not([disabled]), select:not([disabled]), textarea:not([disabled])'
+
+window.FOCUSABLE_ELEMENTS = FOCUSABLE_ELEMENTS
+window.FOCUSABLE_FORM_ELEMENTS = FOCUSABLE_FORM_ELEMENTS
+
+visible = (el) ->
+ !el.hidden and (!el.type or el.type != 'hidden') and (el.offsetWidth > 0 or el.offsetHeight > 0)
+
+focusable = (el) ->
+ el.tabIndex >= 0 and !el.disabled and visible(el)
+
+window.focusElement = (el, elements = FOCUSABLE_ELEMENTS) ->
+ autofocusElement = Array.from(el.querySelectorAll(elements)).filter(focusable)[0]
+ if autofocusElement
+ autofocusElement.focus()
+ return
+
+window.autofocusElement = (el) ->
+ autofocusElement = Array.from(el.querySelectorAll('[autofocus]')).filter(focusable)[0]
+ if !autofocusElement
+ autofocusElement = el
+ el.setAttribute('tabindex', '-1')
+ autofocusElement.focus()
+ return
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/form_answers.js.coffee b/app/assets/javascripts/admin/form_answers.js.coffee
index fd7492260..c9ff0ff43 100644
--- a/app/assets/javascripts/admin/form_answers.js.coffee
+++ b/app/assets/javascripts/admin/form_answers.js.coffee
@@ -314,9 +314,12 @@ ready = ->
if $('.form_answer_attachment').length == 0
sidebarSection.find(".document-list .p-empty").removeClass("visuallyhidden")
- $(document).on "click", ".form-edit-link", (e) ->
+ $(document).on 'click', '.form-edit-link', (e) ->
e.preventDefault()
- $(this).closest(".form-group").addClass("form-edit")
+ element = this.closest('.form-group')
+ if (element)
+ element.classList.add('form-edit')
+
$(".submit-assessment").on "ajax:error", (e, data, status, xhr) ->
errors = data.responseJSON
$(this).addClass("field-with-errors")
@@ -431,8 +434,8 @@ editFormAnswerAutoUpdate = ->
$(".sic-code .form-save-link").on "click", (e) ->
e.preventDefault()
e.stopPropagation()
- that = $("#form_answer_sic_code")
- form = $(".edit_form_answer")
+ input = $("#form_answer_sic_code")
+ form = $(e.target).closest('form')
$.ajax
action: form.attr("action")
data: form.serialize()
@@ -440,15 +443,19 @@ editFormAnswerAutoUpdate = ->
dataType: "json"
success: (result) ->
- formGroup = that.parents(".form-group")
+ formGroup = input.parents(".form-group")
formGroup.removeClass("form-edit")
- formGroup.find(".form-value p").text(that.find("option:selected").text())
+ console.log(formGroup, input)
+ formGroup.find(".form-value p").text(input.val())
sicCodes = result["form_answer"]["sic_codes"]
counter = 1
for row in $(".sector-average-growth td")
$(row).text(sicCodes[counter.toString()])
counter += 1
$(".avg-growth-legend").text(result["form_answer"]["legend"])
+
+ window.fire(form[0], 'ajax:x:success', null)
+
bindRags =(klass) ->
$(document).on "click", "#{klass} .btn-rag .dropdown-menu a", (e) ->
e.preventDefault()
diff --git a/app/assets/javascripts/admin/settings.js.coffee b/app/assets/javascripts/admin/settings.js.coffee
index 0c878afbf..2b927673f 100644
--- a/app/assets/javascripts/admin/settings.js.coffee
+++ b/app/assets/javascripts/admin/settings.js.coffee
@@ -117,20 +117,20 @@ jQuery ->
settingsWrapper.on "click", ".btn-cancel", (e) ->
e.preventDefault()
- form_well = ($ e.currentTarget).closest('.well')
+ well = ($ e.currentTarget).closest('.well')
- if form_well.hasClass("deadline-form")
+ if well.hasClass("deadline-form")
wrapper = ($ e.currentTarget).closest('.deadline')
($ ".form-value", wrapper).removeClass("hidden")
($ ".deadline-form", wrapper).addClass("hidden")
($ ".edit-deadline", wrapper).removeClass("hidden")
- else if form_well.hasClass("notification-edit-form")
+ else if well.hasClass("notification-edit-form")
wrapper = ($ e.currentTarget).closest('li')
($ ".form-value", wrapper).removeClass("hidden")
($ ".notification-edit-form", wrapper).addClass("hidden")
($ ".actions", wrapper).removeClass("hidden")
- else if form_well.hasClass("notification-new-form")
+ else if well.hasClass("notification-new-form")
wrapper = ($ e.currentTarget).closest('.panel-section')
($ ".notification-form", wrapper).addClass("hidden")
diff --git a/app/assets/javascripts/application-admin.js.coffee b/app/assets/javascripts/application-admin.js.coffee
index 6011afb7b..673022e3f 100644
--- a/app/assets/javascripts/application-admin.js.coffee
+++ b/app/assets/javascripts/application-admin.js.coffee
@@ -26,7 +26,18 @@
#= require clean-paste
$(document).ready(() ->
- $("html").removeClass("no-js").addClass("js")
- ($ ".timepicker").timePicker()
- ($ ".datepicker").datepicker({dateFormat: "dd/mm/yy"})
+ $('html').removeClass('no-js').addClass('js')
+ ($ '.timepicker').timePicker()
+ ($ '.datepicker').datepicker({dateFormat: 'dd/mm/yy'})
)
+
+$(document).on 'ajax:success', 'form', (event, data, _status, _xhr) ->
+ fire(this, 'ajax:x:success', data)
+
+$(document).on 'ajax:error', 'form', (event, data, _status, _xhr) ->
+ fire(this, 'ajax:x:error', data)
+
+window.fire = (obj, name, data) ->
+ event = new CustomEvent(name, detail: data, bubbles: true, cancelable: true)
+ obj.dispatchEvent(event)
+ !event.defaultPrevented
diff --git a/app/assets/javascripts/frontend/form-validation.js.coffee b/app/assets/javascripts/frontend/form-validation.js.coffee
index 973b5ccb8..9695846bd 100644
--- a/app/assets/javascripts/frontend/form-validation.js.coffee
+++ b/app/assets/javascripts/frontend/form-validation.js.coffee
@@ -145,11 +145,49 @@ window.FormValidation =
for subquestion in subquestions
if not @validateSingleQuestion($(subquestion))
@logThis(question, "validateRequiredQuestion", "This field is required")
- @addErrorMessage($(subquestion), "This field is required")
+ @addSubfieldError(question, subquestion)
else
if not @validateSingleQuestion(question)
@logThis(question, "validateRequiredQuestion", "This field is required")
- @addErrorMessage(question, "This field is required")
+ @addQuestionError(question)
+
+ addSubfieldError: (question, subquestion) ->
+ questionRef = question.attr("data-question_ref")
+ input = $(subquestion).find('input,textarea,select').filter(':visible')
+ label = $("label[for='#{input.attr('id')}']").text()
+ incompleteMessage = "Question #{questionRef} is incomplete. It is required and must be filled in."
+
+ if question.hasClass('date-DDMMYYYY')
+ @addErrorMessage($(subquestion), "#{incompleteMessage} Use the format DD/MM/YYYY.")
+ else if question.hasClass('date-MMYYYY')
+ @addErrorMessage($(subquestion), "#{incompleteMessage} Use the format MM/YYYY.")
+ else if question.hasClass('date-YYYY')
+ @addErrorMessage($(subquestion), "#{incompleteMessage} Use the format YYYY.")
+ else if input.hasClass("autocomplete__input")
+ @addErrorMessage($(subquestion), "Question #{questionRef} is incomplete. #{label} is required and an option must be selected from the following dropdown list.")
+ else
+ if question.find(".js-financial-year-latest").length
+ #avoid duplicate errors for financial year questions
+ return
+ else
+ @addErrorMessage($(subquestion), "Question #{questionRef} is incomplete. #{label} is required and must be filled in.")
+
+ addQuestionError: (question) ->
+ questionRef = question.attr("data-question_ref")
+ incompleteMessage = "Question #{questionRef} is incomplete. It is required"
+ if @isOptionsQuestion(question)
+ @addErrorMessage(question, "#{incompleteMessage} and an option must be chosen from the following list.")
+ else if @isSelectQuestion(question)
+ @addErrorMessage(question, "#{incompleteMessage} and an option must be selected from the following dropdown list.")
+ else if @isTextishQuestion(question) && !question.hasClass("question-year")
+ @addErrorMessage(question, "#{incompleteMessage} and must be filled in.")
+ else if question.hasClass("question-year")
+ @addErrorMessage(question, "#{incompleteMessage} and must be filled in. Use the format YYYY.")
+ else if @isCheckboxQuestion(question)
+ if question.find("input[type='checkbox']").length > 1
+ @addErrorMessage(question, "#{incompleteMessage} and at least one option must be chosen from the following list.")
+ else
+ @addErrorMessage(question, "#{incompleteMessage} and confirmation must be given by ticking the checkbox.")
validateMatchQuestion: (question) ->
q = question.find(".match")
@@ -161,6 +199,7 @@ window.FormValidation =
validateMaxDate: (question) ->
val = question.find("input[type='number']").val()
+ questionRef = question.attr("data-question_ref")
questionYear = question.find(".js-date-input-year").val()
questionMonth = question.find(".js-date-input-month").val()
@@ -175,7 +214,7 @@ window.FormValidation =
if not @toDate(questionDate).isValid()
@logThis(question, "validateMaxDate", "Not a valid date")
- @addErrorMessage(question, "Not a valid date")
+ @addErrorMessage(question, "Question #{questionRef} is incomplete. The date entered is not valid. Use the format DD/MM/YYYY.")
return
if diff > 0
@@ -291,6 +330,7 @@ window.FormValidation =
@addErrorMessage(question, "Not a valid number")
validateEmployeeMin: (question) ->
+ questionRef = question.attr("data-question_ref")
for subquestion in question.find("input")
shownQuestion = true
for conditional in $(subquestion).parents('.js-conditional-question')
@@ -299,9 +339,10 @@ window.FormValidation =
if shownQuestion
subq = $(subquestion)
+ label = $("label[for='#{subq.attr('id')}']").text()
if not subq.val() and question.hasClass("question-required")
@logThis(question, "validateEmployeeMin", "This field is required")
- @appendMessage(subq.closest(".span-financial"), "This field is required")
+ @appendMessage(subq.closest(".span-financial"), "Question #{questionRef} is incomplete. #{label} is required and must be filled in. Enter '0' if none.")
@addErrorClass(question)
continue
else if not subq.val()
@@ -374,6 +415,7 @@ window.FormValidation =
validateCurrentAwards: (question) ->
$(".govuk-error-message", question).empty()
+ questionRef = question.attr("data-question_ref")
for subquestion in question.find(".list-add li")
errors = false
@@ -382,7 +424,7 @@ window.FormValidation =
if !$(input).val()
fieldName = $(input).data("dependable-option-siffix")
fieldName = fieldName[0].toUpperCase() + fieldName.slice(1)
- fieldError = "#{fieldName} can't be blank. "
+ fieldError = "Question #{questionRef} is incomplete. #{fieldName} is required and an option must be selected from the following list. "
@logThis(question, "validateCurrentAwards", fieldError)
@appendMessage($(input).closest('.govuk-form-group'), fieldError)
errors = true
@@ -402,6 +444,7 @@ window.FormValidation =
subq = $(subquestion)
qParent = subq.closest(".js-fy-entries")
errContainer = subq.closest(".span-financial")
+ questionRef = question.attr("data-question_ref")
shownQuestion = true
for conditional in $(subquestion).parents('.js-conditional-question')
@@ -413,7 +456,8 @@ window.FormValidation =
if not subq.val() and question.hasClass("question-required")
@logThis(question, "validateMoneyByYears", "This field is required")
- @appendMessage(errContainer, "This field is required")
+ label = $("label[for='#{subq.attr('id')}']").text()
+ @appendMessage(errContainer, "Question #{questionRef} is incomplete. #{label} is required and must be filled in. Enter '0' if none.")
@addErrorClass(question)
continue
else if not subq.val()
@@ -448,41 +492,54 @@ window.FormValidation =
if !conditional
return
# end of conditional validation
+ questionRef = question.attr("data-question_ref")
for subquestionBlock in question.find(".by-years-wrapper.show-question .govuk-date-input")
subq = $(subquestionBlock)
qParent = subq.closest(".js-fy-entries")
+ label = qParent.find(".js-year-default").text()
errorsContainer = qParent.find(".govuk-error-message").html()
day = subq.find("input.js-fy-day").val()
month = subq.find("input.js-fy-month").val()
year = subq.find("input.js-fy-year").val()
- if (not day or not month or not year)
- if question.hasClass("question-required") && errorsContainer.length < 1
+ if question.hasClass("question-required") && errorsContainer.length < 1
+ if (not day and not month and not year)
@logThis(question, "validateDateByYears", "This field is required")
- @appendMessage(qParent, "This field is required")
+ @appendMessage(qParent, "Question #{questionRef} is incomplete. #{label} is required and must be filled in. Use the format DD/MM/YYYY.")
@addErrorClass(question)
- else
- complexDateString = day + "/" + month + "/" + year
- date = @toDate(complexDateString)
+ else if (not day)
+ @appendMessage(qParent, "Question #{questionRef} is incomplete. #{label} day is required and must be filled in. Use the format DD.")
+ else if (not month)
+ @appendMessage(qParent, "Question #{questionRef} is incomplete. #{label} month is required and must be filled in. Use the format MM.")
+ else if (not year)
+ @appendMessage(qParent, "Question #{questionRef} is incomplete. #{label} year is required and must be filled in. Use the format YYYY.")
+ else
+ complexDateString = day + "/" + month + "/" + year
+ date = @toDate(complexDateString)
- if not date.isValid()
- @logThis(question, "validateDateByYears", "Not a valid date")
- @appendMessage(qParent, "Not a valid date")
- @addErrorClass(question)
+ if not date.isValid()
+ @logThis(question, "validateDateByYears", "Not a valid date")
+ @appendMessage(qParent, "Not a valid date")
+ @addErrorClass(question)
validateInnovationFinancialDate: (question) ->
-
val = question.find("input[type='number']").val()
-
+ questionRef = question.attr("data-question_ref")
questionDay = parseInt(question.find(".innovation-day").val())
questionMonth = parseInt(question.find(".innovation-month").val())
questionDate = "#{questionDay}/#{questionMonth}/#{moment().format('Y')}"
- if not @toDate(questionDate).isValid()
+ if (not questionDay and not questionMonth)
+ @addErrorMessage(question, "Question #{questionRef} is incomplete. Year-end is required and must be filled in. Use the format DD/MM.")
+ else if not questionDay
+ @addErrorMessage(question, "Question #{questionRef} is incomplete. Day is required and must be filled in. Use the format DD/MM.")
+ else if not questionMonth
+ @addErrorMessage(question, "Question #{questionRef} is incomplete. Month is required and must be filled in. Use the format DD/MM.")
+ else if not @toDate(questionDate).isValid()
@logThis(question, "validateMaxDate", "Not a valid date")
- @addErrorMessage(question, "Not a valid date")
+ @addErrorMessage(question, "Question #{questionRef} is incomplete. It is required and must be filled in. Use the format DD/MM.")
return
validateDiffBetweenDates: (question) ->
diff --git a/app/assets/javascripts/frontend/js_detector.js.coffee b/app/assets/javascripts/frontend/js_detector.js.coffee
new file mode 100644
index 000000000..b7334d196
--- /dev/null
+++ b/app/assets/javascripts/frontend/js_detector.js.coffee
@@ -0,0 +1,3 @@
+jQuery ->
+ # if the user has javascript enabled, remove non js step value
+ $("#non_js_step_title").val("")
diff --git a/app/assets/javascripts/frontend/password-strength-indicator.js b/app/assets/javascripts/frontend/password-strength-indicator.js
index 71f7b0791..6448b0dfd 100644
--- a/app/assets/javascripts/frontend/password-strength-indicator.js
+++ b/app/assets/javascripts/frontend/password-strength-indicator.js
@@ -138,6 +138,7 @@ $(function() {
if (someProblem) {
$('#password-guidance').removeClass("govuk-!-display-none");
+ $('#password-result-span').addClass("hide")
} else {
$('#password-guidance').addClass("govuk-!-display-none");
}
@@ -147,8 +148,10 @@ $(function() {
if ($passwordField.val().length == 0) {
$passwordField.attr('aria-invalid', "true");
+ $('#password-result-span').addClass("hide")
} else if ($.inArray('good-password', guidance) >= 0) {
$passwordField.attr('aria-invalid', "false");
+ $('#password-result-span').removeClass("hide")
} else {
$passwordField.attr('aria-invalid', "true");
}
@@ -160,13 +163,16 @@ $(function() {
if ($passwordConfirmationField.val().length == 0) {
$passwordConfirmationField.attr('aria-invalid', "true");
+ $('#password-confirmation-result-span').addClass("hide")
} else if ($.inArray('confirmation-not-matching', guidance) >= 0) {
$passwordConfirmationField.attr('aria-invalid', "true");
indicator.parent().removeClass('confirmation-matching');
+ $('#password-confirmation-result-span').addClass("hide")
} else if ($.inArray('confirmation-matching', guidance) >= 0) {
$passwordConfirmationField.attr('aria-invalid', "false");
if($.inArray('good-password', guidance) >= 0) {
indicator.parent().addClass('confirmation-matching');
+ $('#password-confirmation-result-span').removeClass("hide")
}
}
}
diff --git a/app/assets/stylesheets/admin/_variables.scss b/app/assets/stylesheets/admin/_variables.scss
index bc468ae15..740857467 100644
--- a/app/assets/stylesheets/admin/_variables.scss
+++ b/app/assets/stylesheets/admin/_variables.scss
@@ -31,3 +31,4 @@ $header-hover: #444;
$blue-link-colour: #3264A3;
$govuk-light-blue: #5694ca;
$govuk-highlight-yellow: #fd0;
+$govuk-warning-red: #d4351c;
diff --git a/app/assets/stylesheets/admin/base.scss b/app/assets/stylesheets/admin/base.scss
index 0c1f0c9f3..4060c2682 100644
--- a/app/assets/stylesheets/admin/base.scss
+++ b/app/assets/stylesheets/admin/base.scss
@@ -28,9 +28,9 @@ input[type=text], input[type=tel],
input[type=email], input[type=password],
input[type=checkbox], select {
&:focus {
- outline: 3px solid #ffdd00;
- outline-offset: 0;
- box-shadow: inset 0 0 0 2px;
+ outline: 3px solid #ffdd00 !important;
+ outline-offset: 0 !important;
+ box-shadow: inset 0 0 0 2px !important;
}
}
diff --git a/app/assets/stylesheets/admin/page-applications.scss b/app/assets/stylesheets/admin/page-applications.scss
index d2a114b6e..9598f9530 100644
--- a/app/assets/stylesheets/admin/page-applications.scss
+++ b/app/assets/stylesheets/admin/page-applications.scss
@@ -599,6 +599,12 @@
left: 0;
}
+ &:hover {
+ color: #fff;
+ background-color: $govuk-light-blue;
+ border-color: #204d74
+ }
+
.show-bulk-assign & {
.js & {
display: none;
diff --git a/app/assets/stylesheets/frontend/forms.scss b/app/assets/stylesheets/frontend/forms.scss
index fc7729529..2b27d1d30 100644
--- a/app/assets/stylesheets/frontend/forms.scss
+++ b/app/assets/stylesheets/frontend/forms.scss
@@ -1110,3 +1110,8 @@ fieldset.account-contact-preferences-other-ops {
.js-fy-entries {
margin-bottom: 30px
}
+
+.cke_wordcount span {
+ font-size: 1.1875rem !important;
+ color: $grey-1 !important;
+}
diff --git a/app/controllers/admin/admins_controller.rb b/app/controllers/admin/admins_controller.rb
index e5e008e53..65753238b 100644
--- a/app/controllers/admin/admins_controller.rb
+++ b/app/controllers/admin/admins_controller.rb
@@ -19,6 +19,9 @@ def create
authorize @resource, :create?
@resource.save
+
+ render_flash_message_for(@resource)
+
location = @resource.persisted? ? admin_admins_path : nil
respond_with :admin, @resource, location: location
end
@@ -32,6 +35,8 @@ def update
@resource.update_without_password(resource_params)
end
+ render_flash_message_for(@resource)
+
respond_with :admin, @resource, location: admin_admins_path
end
@@ -39,6 +44,8 @@ def destroy
authorize @resource, :destroy?
@resource.soft_delete!
+ render_flash_message_for(@resource)
+
respond_with :admin, @resource, location: admin_admins_path
end
diff --git a/app/controllers/admin/assessors_controller.rb b/app/controllers/admin/assessors_controller.rb
index 6074ee624..f41faa68b 100644
--- a/app/controllers/admin/assessors_controller.rb
+++ b/app/controllers/admin/assessors_controller.rb
@@ -20,6 +20,9 @@ def create
@resource.save
location = @resource.persisted? ? admin_assessors_path : nil
+
+ render_flash_message_for(@resource)
+
respond_with :admin, @resource, location: location
end
@@ -32,6 +35,8 @@ def update
@resource.update_without_password(resource_params)
end
+ render_flash_message_for(@resource)
+
respond_with :admin, @resource, location: admin_assessors_path
end
@@ -39,6 +44,8 @@ def destroy
authorize @resource, :destroy?
@resource.soft_delete!
+ render_flash_message_for(@resource)
+
respond_with :admin, @resource, location: admin_assessors_path
end
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
index 0fa7161e9..8ea7ef5bc 100644
--- a/app/controllers/admin/base_controller.rb
+++ b/app/controllers/admin/base_controller.rb
@@ -20,6 +20,14 @@ def current_subject
current_admin
end
+ def render_flash_message_for(resource, message: nil)
+ if resource.errors.any?
+ flash[:error] = message || "An unknown error has occurred, please try again."
+ else
+ flash[:notice] = message || "Success!"
+ end
+ end
+
private
def user_not_authorized
diff --git a/app/controllers/admin/comments_controller.rb b/app/controllers/admin/comments_controller.rb
index 1cf05c751..938f5c908 100644
--- a/app/controllers/admin/comments_controller.rb
+++ b/app/controllers/admin/comments_controller.rb
@@ -27,6 +27,7 @@ def create
head :ok
end
else
+ render_flash_message_for(@comment)
redirect_to admin_form_answer_path(form_answer)
end
end
@@ -38,7 +39,10 @@ def update
log_event if resource.update(update_params)
respond_to do |format|
- format.html { redirect_to([namespace_name, form_answer]) }
+ format.html do
+ render_flash_message_for(resource)
+ redirect_to([namespace_name, form_answer])
+ end
format.js { head :ok }
end
end
@@ -49,8 +53,11 @@ def destroy
log_event if resource.destroy
respond_to do |format|
- format.json{ render(json: :ok)}
- format.html{ redirect_to admin_form_answer_path(form_answer)}
+ format.json { render(json: :ok)}
+ format.html do
+ render_flash_message_for(resource)
+ redirect_to(admin_form_answer_path(form_answer))
+ end
end
end
diff --git a/app/controllers/admin/custom_emails_controller.rb b/app/controllers/admin/custom_emails_controller.rb
index bbe767e96..438461a96 100644
--- a/app/controllers/admin/custom_emails_controller.rb
+++ b/app/controllers/admin/custom_emails_controller.rb
@@ -15,8 +15,10 @@ def create
@form = CustomEmailForm.new(custom_email_form_attributes)
if @form.valid?
CustomEmailWorker.perform_async(custom_email_form_attributes)
+ render_flash_message_for(@form)
redirect_to admin_custom_email_path, notice: "Email was successfully scheduled"
else
+ render_flash_message_for(@form)
render :show
end
end
diff --git a/app/controllers/admin/judges_controller.rb b/app/controllers/admin/judges_controller.rb
index 147578b2d..5ee3060f8 100644
--- a/app/controllers/admin/judges_controller.rb
+++ b/app/controllers/admin/judges_controller.rb
@@ -20,6 +20,9 @@ def create
@resource.save
location = @resource.persisted? ? admin_judges_path : nil
+
+ render_flash_message_for(@resource)
+
respond_with :admin, @resource, location: location
end
@@ -32,6 +35,8 @@ def update
@resource.update_without_password(resource_params)
end
+ render_flash_message_for(@resource)
+
respond_with :admin, @resource, location: admin_judges_path
end
@@ -39,6 +44,8 @@ def destroy
authorize @resource, :destroy?
@resource.soft_delete!
+ render_flash_message_for(@resource)
+
respond_with :admin, @resource, location: admin_judges_path
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index b5c22f5af..89015ebc0 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -27,6 +27,9 @@ def create
@resource.save
location = @resource.persisted? ? admin_users_path : nil
+
+ render_flash_message_for(@resource)
+
respond_with :admin, @resource, location: location
end
@@ -39,6 +42,8 @@ def update
@resource.update_without_password(resource_params)
end
+ render_flash_message_for(@resource)
+
respond_with :admin, @resource, location: admin_users_path
end
diff --git a/app/controllers/concerns/admin_shortlisted_docs_context.rb b/app/controllers/concerns/admin_shortlisted_docs_context.rb
index dd01eafc7..bc8a520f8 100644
--- a/app/controllers/concerns/admin_shortlisted_docs_context.rb
+++ b/app/controllers/concerns/admin_shortlisted_docs_context.rb
@@ -18,6 +18,7 @@ def create
respond_to do |format|
format.html do
+ render_flash_message_for(attachment)
redirect_to [namespace_name, form_answer]
end
@@ -30,7 +31,8 @@ def create
else
respond_to do |format|
format.html do
- redirect_to [namespace_name, form_answer], alert: attachment.errors.full_messages.join(", ")
+ render_flash_message_for(attachment)
+ redirect_to [namespace_name, form_answer]
end
format.js do
@@ -55,6 +57,7 @@ def destroy
if request.xhr? || request.format.js?
head :ok
else
+ render_flash_message_for(resource)
redirect_to [namespace_name, form_answer]
end
end
diff --git a/app/controllers/concerns/admin_shortlisted_docs_submission_context.rb b/app/controllers/concerns/admin_shortlisted_docs_submission_context.rb
index 70e2b1da5..e3ddf4706 100644
--- a/app/controllers/concerns/admin_shortlisted_docs_submission_context.rb
+++ b/app/controllers/concerns/admin_shortlisted_docs_submission_context.rb
@@ -6,6 +6,8 @@ def create
respond_to do |format|
format.html do
+ render_flash_message_for(resource)
+
redirect_to [namespace_name, form_answer], alert: render_errors
end
diff --git a/app/controllers/concerns/draft_notes_mixin.rb b/app/controllers/concerns/draft_notes_mixin.rb
index 3a9ac2604..ff7fcebe1 100644
--- a/app/controllers/concerns/draft_notes_mixin.rb
+++ b/app/controllers/concerns/draft_notes_mixin.rb
@@ -9,6 +9,7 @@ def create
respond_to do |format|
format.html do
+ render_flash_message_for(resource)
redirect_to [namespace_name, form_answer]
end
@@ -28,6 +29,7 @@ def update
respond_to do |format|
format.html do
+ render_flash_message_for(resource)
redirect_to [namespace_name, form_answer]
end
diff --git a/app/controllers/form_controller.rb b/app/controllers/form_controller.rb
index a99fdf178..38637f991 100644
--- a/app/controllers/form_controller.rb
+++ b/app/controllers/form_controller.rb
@@ -110,7 +110,11 @@ def edit_form
end
def save
- @form_answer.document = prepare_doc if params[:form].present?
+ if params[:form].present?
+ @form_answer.document = prepare_doc
+ @form_answer.current_non_js_step = params[:form][:current_non_js_step].presence
+ end
+
@form = @form_answer.award_form.decorate(answers: HashWithIndifferentAccess.new(@form_answer.document))
redirected = params[:next_action] == "redirect"
diff --git a/app/forms/award_years/v2024/innovation/innovation_step1.rb b/app/forms/award_years/v2024/innovation/innovation_step1.rb
index fb9c20f01..6e426508d 100644
--- a/app/forms/award_years/v2024/innovation/innovation_step1.rb
+++ b/app/forms/award_years/v2024/innovation/innovation_step1.rb
@@ -12,14 +12,15 @@ def innovation_step1
text "I confirm that I have the consent of the head of my organisation to fill in and submit this entry form."
end
- head_of_business :head_of_business, "Details of the head of your organisation" do
+ sub_fields :head_of_business, "Details of the head of your organisation" do
+ required
sub_ref "A 1.2"
classes "sub-question"
sub_fields([
{ title: "Title" },
{ first_name: "First name" },
{ last_name: "Last name" },
- { honours: "Personal Honours (optional)" },
+ { honours: "Personal Honours (optional)", hint: "For example, Lieutenant (LVO), Member of the Most Excellent Order of the British Empire (MBE), Air Force Cross (AFC). Please do not include qualifications such as a master's degree or doctorate." },
{ job_title: "Job title or role in the organisation" },
{ email: "Email address" }
])
diff --git a/app/forms/award_years/v2024/innovation/innovation_step2.rb b/app/forms/award_years/v2024/innovation/innovation_step2.rb
index 18399588d..4405f6a87 100644
--- a/app/forms/award_years/v2024/innovation/innovation_step2.rb
+++ b/app/forms/award_years/v2024/innovation/innovation_step2.rb
@@ -115,7 +115,7 @@ def innovation_step2
end
date :started_trading, "Date started trading." do
- classes "js-started-trading"
+ classes "js-started-trading date-DDMMYYYY"
required
ref "B 5"
context -> do
@@ -156,7 +156,8 @@ def innovation_step2
style "small"
end
- press_contact_details :press_contact_details, "Contact details for press enquiries." do
+ sub_fields :press_contact_details, "Contact details for press enquiries." do
+ required
ref "B 7"
context %(
@@ -278,6 +279,7 @@ def innovation_step2
queen_award_applications :applied_for_queen_awards_details, " List the Queen's/King's awards you have applied for in the last 10 years." do
classes "sub-question question-current-awards"
sub_ref "B 12.1"
+ required
conditional :applied_for_queen_awards, :yes
diff --git a/app/forms/award_years/v2024/innovation/innovation_step3.rb b/app/forms/award_years/v2024/innovation/innovation_step3.rb
index 0329a8b67..e5de6a908 100644
--- a/app/forms/award_years/v2024/innovation/innovation_step3.rb
+++ b/app/forms/award_years/v2024/innovation/innovation_step3.rb
@@ -284,7 +284,7 @@ def innovation_step3
date :innovation_was_launched_in_the_market, "Select the date when your innovation was launched in the market." do
sub_section :innovation_timeline_header
- classes "sub-question"
+ classes "sub-question date-DDMMYYYY"
sub_ref "C 2.2"
required
context -> do
diff --git a/app/forms/award_years/v2024/innovation/innovation_step4.rb b/app/forms/award_years/v2024/innovation/innovation_step4.rb
index 3245beda7..f3086bafb 100644
--- a/app/forms/award_years/v2024/innovation/innovation_step4.rb
+++ b/app/forms/award_years/v2024/innovation/innovation_step4.rb
@@ -481,6 +481,7 @@ def innovation_step4
end
options :product_estimated_figures, "Are any of the figures used on this page estimates?" do
+ required
ref "D 9"
yes_no
context %(
diff --git a/app/forms/award_years/v2024/international_trade/international_trade_step1.rb b/app/forms/award_years/v2024/international_trade/international_trade_step1.rb
index df378d884..1eec16e3c 100644
--- a/app/forms/award_years/v2024/international_trade/international_trade_step1.rb
+++ b/app/forms/award_years/v2024/international_trade/international_trade_step1.rb
@@ -14,14 +14,15 @@ def trade_step1
)
end
- head_of_business :head_of_business, "Details of the head of your organisation" do
+ sub_fields :head_of_business, "Details of the head of your organisation" do
sub_ref "A 1.2"
+ required
classes "sub-question"
sub_fields([
{ title: "Title" },
{ first_name: "First name" },
{ last_name: "Last name" },
- { honours: "Personal Honours (optional)" },
+ { honours: "Personal Honours (optional)", hint: "For example, Lieutenant (LVO), Member of the Most Excellent Order of the British Empire (MBE), Air Force Cross (AFC). Please do not include qualifications such as a master's degree or doctorate." },
{ job_title: "Job title or role in the organisation" },
{ email: "Email address" }
])
diff --git a/app/forms/award_years/v2024/international_trade/international_trade_step2.rb b/app/forms/award_years/v2024/international_trade/international_trade_step2.rb
index 3a436b64b..c6945269f 100644
--- a/app/forms/award_years/v2024/international_trade/international_trade_step2.rb
+++ b/app/forms/award_years/v2024/international_trade/international_trade_step2.rb
@@ -118,7 +118,7 @@ def trade_step2
end
date :started_trading, "Date started trading." do
- classes "js-started-trading"
+ classes "js-started-trading date-DDMMYYYY"
required
ref "B 5"
context %(
@@ -156,8 +156,9 @@ def trade_step2
style "small"
end
- press_contact_details :press_contact_details, "Contact details for press enquiries." do
+ sub_fields :press_contact_details, "Contact details for press enquiries." do
ref "B 7"
+ required
context %(
If your application is successful, you may get contacted by the press.
diff --git a/app/forms/award_years/v2024/social_mobility/social_mobility_step1.rb b/app/forms/award_years/v2024/social_mobility/social_mobility_step1.rb
index f1d2059cd..835cb1834 100644
--- a/app/forms/award_years/v2024/social_mobility/social_mobility_step1.rb
+++ b/app/forms/award_years/v2024/social_mobility/social_mobility_step1.rb
@@ -12,14 +12,15 @@ def mobility_step1
text "I confirm that I have the consent of the head of my organisation to fill in and submit this entry form."
end
- head_of_business :head_of_business, "Details of the head of your organisation" do
+ sub_fields :head_of_business, "Details of the head of your organisation" do
sub_ref "A 1.2"
+ required
classes "sub-question"
sub_fields([
{ title: "Title" },
{ first_name: "First name" },
{ last_name: "Last name" },
- { honours: "Personal Honours (optional)" },
+ { honours: "Personal Honours (optional)", hint: "For example, Lieutenant (LVO), Member of the Most Excellent Order of the British Empire (MBE), Air Force Cross (AFC). Please do not include qualifications such as a master's degree or doctorate." },
{ job_title: "Job title or role in the organisation" },
{ email: "Email address" }
])
diff --git a/app/forms/award_years/v2024/social_mobility/social_mobility_step2.rb b/app/forms/award_years/v2024/social_mobility/social_mobility_step2.rb
index db722a4e0..0bd7cc83f 100644
--- a/app/forms/award_years/v2024/social_mobility/social_mobility_step2.rb
+++ b/app/forms/award_years/v2024/social_mobility/social_mobility_step2.rb
@@ -116,7 +116,7 @@ def mobility_step2
end
date :started_trading, "Date started trading." do
- classes "js-started-trading"
+ classes "js-started-trading date-DDMMYYYY"
required
ref "B 5"
context %(
@@ -154,8 +154,9 @@ def mobility_step2
style "small"
end
- press_contact_details :press_contact_details, "Contact details for press enquiries." do
+ sub_fields :press_contact_details, "Contact details for press enquiries." do
ref "B 7"
+ required
context %(
If your application is successful, you may get contacted by the press.
diff --git a/app/forms/award_years/v2024/sustainable_development/sustainable_development_step1.rb b/app/forms/award_years/v2024/sustainable_development/sustainable_development_step1.rb
index 513904668..71da8439a 100644
--- a/app/forms/award_years/v2024/sustainable_development/sustainable_development_step1.rb
+++ b/app/forms/award_years/v2024/sustainable_development/sustainable_development_step1.rb
@@ -13,14 +13,15 @@ def development_step1
text "I confirm that I have the consent of the head of my organisation to fill in and submit this entry form."
end
- head_of_business :head_of_business, "Details of the head of your organisation" do
+ sub_fields :head_of_business, "Details of the head of your organisation" do
sub_ref "A 1.2"
+ required
classes "sub-question"
sub_fields([
{ title: "Title" },
{ first_name: "First name" },
{ last_name: "Last name" },
- { honours: "Personal Honours (optional)" },
+ { honours: "Personal Honours (optional)", hint: "For example, Lieutenant (LVO), Member of the Most Excellent Order of the British Empire (MBE), Air Force Cross (AFC). Please do not include qualifications such as a master's degree or doctorate." },
{ job_title: "Job title or role in the organisation" },
{ email: "Email address" }
])
diff --git a/app/forms/award_years/v2024/sustainable_development/sustainable_development_step2.rb b/app/forms/award_years/v2024/sustainable_development/sustainable_development_step2.rb
index 95f31abe6..41d85e431 100644
--- a/app/forms/award_years/v2024/sustainable_development/sustainable_development_step2.rb
+++ b/app/forms/award_years/v2024/sustainable_development/sustainable_development_step2.rb
@@ -116,7 +116,7 @@ def development_step2
end
date :started_trading, "Date started trading." do
- classes "js-started-trading"
+ classes "js-started-trading date-DDMMYYYY"
required
ref "B 5"
context %(
@@ -154,8 +154,9 @@ def development_step2
style "small"
end
- press_contact_details :press_contact_details, "Contact details for press enquiries." do
+ sub_fields :press_contact_details, "Contact details for press enquiries." do
ref "B 7"
+ required
context %(
If your application is successful, you may get contacted by the press.
diff --git a/app/forms/qae_form_builder.rb b/app/forms/qae_form_builder.rb
index 3bfdb4b01..3c5474fc9 100644
--- a/app/forms/qae_form_builder.rb
+++ b/app/forms/qae_form_builder.rb
@@ -28,6 +28,7 @@
require "qae_form_builder/position_details_question"
require "qae_form_builder/previous_name_question"
require "qae_form_builder/country_question"
+require "qae_form_builder/sub_fields_question"
require "qae_form_builder/address_question"
require "qae_form_builder/head_of_business_question"
require "qae_form_builder/press_contact_details_question"
diff --git a/app/forms/qae_form_builder/address_question.rb b/app/forms/qae_form_builder/address_question.rb
index 71162b0b3..cab52cc48 100644
--- a/app/forms/qae_form_builder/address_question.rb
+++ b/app/forms/qae_form_builder/address_question.rb
@@ -1,27 +1,9 @@
class QAEFormBuilder
- class AddressQuestionValidator < QuestionValidator
+ class AddressQuestionValidator < SubFieldsQuestionValidator
NO_VALIDATION_SUB_FIELDS = [:street, :county]
- def errors
- result = super
-
- if question.required?
- question.required_sub_fields.each do |sub_field|
- suffix = sub_field.keys[0]
- if !question.input_value(suffix: suffix).present? && NO_VALIDATION_SUB_FIELDS.exclude?(suffix)
- result[question.hash_key(suffix: suffix)] ||= ""
- result[question.hash_key(suffix: suffix)] << " Can't be blank."
- end
- end
- end
-
- # need to add govuk-form-group--errors class
- result[question.hash_key] ||= "" if result.any?
-
- result
- end
end
- class AddressQuestionDecorator < QuestionDecorator
+ class AddressQuestionDecorator < SubFieldsQuestionDecorator
include RegionHelper
def required_sub_fields
@@ -50,15 +32,11 @@ def rendering_sub_fields
end
end
- class AddressQuestionBuilder < QuestionBuilder
+ class AddressQuestionBuilder < SubFieldsQuestionBuilder
def countries(countries)
@q.countries = countries
end
- def sub_fields(fields)
- @q.sub_fields = fields
- end
-
def region_context(region_context)
@q.region_context = region_context
end
@@ -68,7 +46,7 @@ def county_context(county_context)
end
end
- class AddressQuestion < Question
+ class AddressQuestion < SubFieldsQuestion
attr_accessor :countries, :sub_fields, :region_context, :county_context
end
end
diff --git a/app/forms/qae_form_builder/checkbox_seria_question.rb b/app/forms/qae_form_builder/checkbox_seria_question.rb
index 05ce55ca1..c43a43856 100644
--- a/app/forms/qae_form_builder/checkbox_seria_question.rb
+++ b/app/forms/qae_form_builder/checkbox_seria_question.rb
@@ -1,7 +1,15 @@
class QAEFormBuilder
class CheckboxSeriaQuestionValidator < QuestionValidator
def errors
- result = super
+ result = {}
+
+ return {} if skip_base_validation?
+
+ if question.required?
+ if !question.input_value.present?
+ result[question.hash_key] = "Question #{question.ref || question.sub_ref} is incomplete. It is required and at least one option must be chosen from the following list."
+ end
+ end
if question.input_value && question.selection_limit
if question.input_value.size > question.selection_limit
diff --git a/app/forms/qae_form_builder/confirm_question.rb b/app/forms/qae_form_builder/confirm_question.rb
index 7f87e11f0..1417657ca 100644
--- a/app/forms/qae_form_builder/confirm_question.rb
+++ b/app/forms/qae_form_builder/confirm_question.rb
@@ -1,5 +1,18 @@
class QAEFormBuilder
class ConfirmQuestionValidator < QuestionValidator
+ def errors
+ result = {}
+
+ return {} if skip_base_validation?
+
+ if question.required?
+ if !question.input_value.present?
+ result[question.hash_key] = "Question #{question.ref || question.sub_ref} is incomplete. It is required and confirmation must be given by ticking the checkbox."
+ end
+ end
+
+ result
+ end
end
class ConfirmQuestionBuilder < QuestionBuilder
diff --git a/app/forms/qae_form_builder/date_question.rb b/app/forms/qae_form_builder/date_question.rb
index f32058be0..b0a56ca7b 100644
--- a/app/forms/qae_form_builder/date_question.rb
+++ b/app/forms/qae_form_builder/date_question.rb
@@ -16,17 +16,17 @@ def errors
if !date
if question.required?
result[question.hash_key] ||= ""
- result[question.hash_key] << " Invalid date."
+ result[question.hash_key] << "Question #{question.ref || question.sub_ref} is incomplete. It requires a date in the format DD/MM/YYYY."
end
else
if date_min && date < date_min
result[question.hash_key] ||= ""
- result[question.hash_key] << " Date should be greater than #{date_min.strftime('%d/%m/%Y')}."
+ result[question.hash_key] << "Question #{question.ref || question.sub_ref} is incomplete. Date should be after #{date_min.strftime('%d/%m/%Y')}."
end
if date_max && date > date_max
result[question.hash_key] ||= ""
- result[question.hash_key] << " Date should be less than #{date_max.strftime('%d/%m/%Y')}."
+ result[question.hash_key] << "Question #{question.ref || question.sub_ref} is incomplete. Date should be before #{date_max.strftime('%d/%m/%Y')}."
end
end
diff --git a/app/forms/qae_form_builder/innovation_financial_year_date_question.rb b/app/forms/qae_form_builder/innovation_financial_year_date_question.rb
index 7c8ba036c..698f370cd 100644
--- a/app/forms/qae_form_builder/innovation_financial_year_date_question.rb
+++ b/app/forms/qae_form_builder/innovation_financial_year_date_question.rb
@@ -14,7 +14,7 @@ def errors
if !date && question.required?
result[question.hash_key] ||= ""
- result[question.hash_key] << " Invalid date."
+ result[question.hash_key] << "#{question.ref || question.sub_ref} is incomplete. Year-end is required. Use the format MM/YYYY"
end
result
diff --git a/app/forms/qae_form_builder/multi_question_validator.rb b/app/forms/qae_form_builder/multi_question_validator.rb
index f0e59d9fd..695da6125 100644
--- a/app/forms/qae_form_builder/multi_question_validator.rb
+++ b/app/forms/qae_form_builder/multi_question_validator.rb
@@ -7,7 +7,7 @@ def errors
if !entity[attr].present?
result[question.key] ||= {}
result[question.key][index] ||= ""
- result[question.key][index] << " #{attr.humanize.capitalize} can't be blank."
+ result[question.key][index] << "Question #{question.ref || question.sub_ref} is incomplete. #{attr.humanize} can't be blank."
end
end
end
diff --git a/app/forms/qae_form_builder/options_question.rb b/app/forms/qae_form_builder/options_question.rb
index d3f435492..7ad8b4634 100644
--- a/app/forms/qae_form_builder/options_question.rb
+++ b/app/forms/qae_form_builder/options_question.rb
@@ -1,5 +1,18 @@
class QAEFormBuilder
class OptionsQuestionValidator < QuestionValidator
+ def errors
+ result = {}
+
+ return {} if skip_base_validation?
+
+ if question.required?
+ if !question.input_value.present?
+ result[question.hash_key] = "Question #{question.ref || question.sub_ref} is incomplete. It is required and an option must be chosen from the following list."
+ end
+ end
+
+ result
+ end
end
QuestionAnswerOption = Struct.new(:value, :text)
diff --git a/app/forms/qae_form_builder/question.rb b/app/forms/qae_form_builder/question.rb
index d28342944..0bff6308d 100644
--- a/app/forms/qae_form_builder/question.rb
+++ b/app/forms/qae_form_builder/question.rb
@@ -3,6 +3,7 @@ class QuestionValidator
# multifield questions that can not be simply validated
SKIP_PRESENCE_VALIDATION_QUESTIONS = [
"DateQuestion",
+ "SubFieldsQuestion",
"AddressQuestion",
"InnovationFinancialYearDateQuestion",
"ByYearsQuestion",
@@ -32,7 +33,7 @@ def errors
if question.required?
if !question.input_value.present?
- result[question.hash_key] = "Can't be blank."
+ result[question.hash_key] = "Question #{question.ref || question.sub_ref} is incomplete. It is required and and must be filled in."
end
end
@@ -127,6 +128,7 @@ def label_as_legend?
"by_years_label_question",
"matrix_question",
"press_contact_details_question",
+ "sub_fields_question",
"upload_question"
]
diff --git a/app/forms/qae_form_builder/sic_code_dropdown_question.rb b/app/forms/qae_form_builder/sic_code_dropdown_question.rb
index e6824aab4..e054e0e1a 100644
--- a/app/forms/qae_form_builder/sic_code_dropdown_question.rb
+++ b/app/forms/qae_form_builder/sic_code_dropdown_question.rb
@@ -1,5 +1,17 @@
class QAEFormBuilder
class SicCodeDropdownQuestionValidator < QuestionValidator
+ def errors
+ result = super
+
+ if question.required?
+ if !question.input_value.present?
+ result[question.hash_key] ||= ""
+ result[question.hash_key] = "Question #{question.ref || question.sub_ref} is incomplete. It is required and and an option must be selected from the following dropdown list."
+ end
+ end
+
+ result
+ end
end
class SicCodeDropdownQuestionBuilder < DropdownQuestionBuilder
diff --git a/app/forms/qae_form_builder/sub_fields_question.rb b/app/forms/qae_form_builder/sub_fields_question.rb
new file mode 100644
index 000000000..3b1c6b7f8
--- /dev/null
+++ b/app/forms/qae_form_builder/sub_fields_question.rb
@@ -0,0 +1,46 @@
+class QAEFormBuilder
+ class SubFieldsQuestionValidator < QuestionValidator
+ NO_VALIDATION_SUB_FIELDS = [:honours]
+ def errors
+ result = super
+
+ if question.required?
+ question.required_sub_fields.each do |sub_field|
+ suffix = sub_field.keys[0]
+ if !question.input_value(suffix: suffix).present? && NO_VALIDATION_SUB_FIELDS.exclude?(suffix)
+ result[question.hash_key(suffix: suffix)] ||= ""
+ result[question.hash_key(suffix: suffix)] << "Question #{question.ref || question.sub_ref} is incomplete. #{suffix.to_s.humanize} is required and and must be filled in."
+ end
+ end
+ end
+
+ # need to add govuk-form-group--errors class
+ result[question.hash_key] ||= "" if result.any?
+
+ result
+ end
+ end
+
+ class SubFieldsQuestionDecorator < QuestionDecorator
+ def required_sub_fields
+ sub_fields
+ end
+
+ def rendering_sub_fields
+ required_sub_fields.map do |f|
+ {key: f.keys.first, title: f.values.first, hint: f.try(:[], :hint)}
+ end
+ end
+ end
+
+ class SubFieldsQuestionBuilder < QuestionBuilder
+ def sub_fields fields
+ @q.sub_fields = fields
+ end
+ end
+
+ class SubFieldsQuestion < Question
+ attr_accessor :sub_fields
+ end
+
+end
diff --git a/app/forms/qae_form_builder/year_question.rb b/app/forms/qae_form_builder/year_question.rb
index b19177716..4b3ad4c10 100644
--- a/app/forms/qae_form_builder/year_question.rb
+++ b/app/forms/qae_form_builder/year_question.rb
@@ -3,6 +3,13 @@ class YearQuestionValidator < QuestionValidator
def errors
result = super
+ if question.required?
+ if !question.input_value.present?
+ result[question.hash_key] ||= ""
+ result[question.hash_key] = "Question #{question.ref || question.sub_ref} is incomplete. It is required and and must be filled in. Use the format YYYY."
+ end
+ end
+
year = question.input_value.to_i
if year < question.min || year > question.max
diff --git a/app/javascript/admin.js b/app/javascript/admin.js
new file mode 100644
index 000000000..2d08e9bc9
--- /dev/null
+++ b/app/javascript/admin.js
@@ -0,0 +1,6 @@
+import { Application } from '@hotwired/stimulus';
+import { definitionsFromContext } from '@hotwired/stimulus-webpack-helpers';
+
+const application = Application.start();
+const context = require.context('controllers', true, /_controller\.js$/);
+application.load(definitionsFromContext(context));
diff --git a/app/javascript/application_controller.js b/app/javascript/application_controller.js
new file mode 100644
index 000000000..a42e264de
--- /dev/null
+++ b/app/javascript/application_controller.js
@@ -0,0 +1,35 @@
+import { Controller } from '@hotwired/stimulus';
+
+export default class extends Controller {
+ get classList() {
+ return this.element.classList;
+ }
+
+ get csrfToken() {
+ return this.metaValue('csrf-token');
+ }
+
+ dispatch(eventName, { target = this.element, detail = {}, bubbles = true, cancelable = true } = {}) {
+ const type = `${this.identifier}:${eventName}`;
+ const event = new CustomEvent(type, { detail, bubbles, cancelable });
+ target.dispatchEvent(event);
+ return event;
+ }
+
+ observeMutations(callback, target = this.element, options = { childList: true, subtree: true }) {
+ const observer = new MutationObserver((mutations) => {
+ observer.disconnect();
+ Promise.resolve().then(start); // eslint-disable-line no-use-before-define
+ callback.call(this, mutations);
+ });
+ function start() {
+ if (target.isConnected) observer.observe(target, options);
+ }
+ start();
+ }
+
+ metaValue(name) {
+ const element = document.head.querySelector(`meta[name="${name}"]`);
+ return element && element.getAttribute('content');
+ }
+}
diff --git a/app/javascript/controllers/autofocus_first_element_controller.js b/app/javascript/controllers/autofocus_first_element_controller.js
new file mode 100644
index 000000000..68d54d6b6
--- /dev/null
+++ b/app/javascript/controllers/autofocus_first_element_controller.js
@@ -0,0 +1,7 @@
+export default class extends ApplicationController {
+ static targets = ['element'];
+
+ connect() {
+ this.elementTargets[0].focus();
+ }
+}
diff --git a/app/javascript/controllers/element_focus_controller.js b/app/javascript/controllers/element_focus_controller.js
new file mode 100644
index 000000000..88189b0f2
--- /dev/null
+++ b/app/javascript/controllers/element_focus_controller.js
@@ -0,0 +1,50 @@
+export default class extends ApplicationController {
+ static targets = ['reveal', 'dismiss'];
+ static values = {
+ selector: String
+ }
+
+ connect() {
+ if (this.hasRevealTarget) {
+ this.revealTarget.addEventListener('click', () => setTimeout(() => this.focusFirstElement(), 1))
+ }
+
+ if (this.hasDismissTarget) {
+ this.dismissTarget.addEventListener('click', () => setTimeout(() => this.focusElement(), 1))
+ }
+ }
+
+ focusElement(_event) {
+ if (this.hasRevealTarget && this.focusable(this.revealTarget)) {
+ this.revealTarget.focus()
+ }
+ }
+
+ focusFirstElement(_event) {
+ const element = this.focusableElements[0]
+
+ if (element) {
+ element.focus()
+ }
+ }
+
+ // Private
+
+ visible(el) {
+ return !el.hidden && (!el.type || el.type != 'hidden') && (el.offsetWidth > 0 || el.offsetHeight > 0)
+ }
+
+ focusable(el) {
+ return el.tabIndex >= 0 && !el.disabled && this.visible(el)
+ }
+
+ get focusableElements() {
+ return Array.from(this.element.querySelectorAll(this.selectors)).filter((el) => this.focusable(el)) || []
+ }
+
+ get selectors() {
+ return this.hasSelectorValue ?
+ this.selectorValue :
+ 'input:not([disabled]), select:not([disabled]), textarea:not([disabled])'
+ }
+}
diff --git a/app/javascript/controllers/element_removal_controller.js b/app/javascript/controllers/element_removal_controller.js
new file mode 100644
index 000000000..7341e71d5
--- /dev/null
+++ b/app/javascript/controllers/element_removal_controller.js
@@ -0,0 +1,5 @@
+export default class extends ApplicationController {
+ remove() {
+ this.element.remove();
+ }
+}
diff --git a/app/javascript/controllers/inline_flash_controller.js b/app/javascript/controllers/inline_flash_controller.js
new file mode 100644
index 000000000..481c3316e
--- /dev/null
+++ b/app/javascript/controllers/inline_flash_controller.js
@@ -0,0 +1,70 @@
+export default class extends ApplicationController {
+ static targets = ['form', 'link'];
+
+ static values = {
+ error: String,
+ success: String,
+ };
+
+ connect() {
+ this.formTargets.forEach((el) => {
+ el.addEventListener('ajax:x:success', this.onSuccess);
+ el.addEventListener('ajax:x:error', this.onError);
+ });
+ }
+
+ disconnect() {
+ this.formTargets.forEach((el) => {
+ el.removeEventListener('ajax:x:success', this.onSuccess);
+ el.removeEventListener('ajax:x:error', this.onError);
+ });
+ }
+
+ success(event) {
+ return this.onSuccess(event);
+ }
+
+ error(event) {
+ return this.onError(event);
+ }
+
+ onSuccess = (event) => {
+ const msg = this.hasSuccessValue ? this.successValue : 'Success!';
+ const [alert, identifier] = this.createAlert('success', msg);
+
+ setTimeout(() => this.showAlert(alert, identifier), 1);
+ };
+
+ onError = (event) => {
+ const msg = this.hasErrorValue ? this.errorValue : 'An unknown error has occurred, please try again.';
+ const [alert, identifier] = this.createAlert('danger', msg);
+
+ setTimeout(() => this.showAlert(alert, identifier), 1);
+ };
+
+ createAlert = (type, message) => {
+ const id = 'alert__' + String(Math.random()).slice(2, -1);
+ const element = `
+