-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix duplicate subject validation on secondary course selection #4224
Fix duplicate subject validation on secondary course selection #4224
Conversation
d9b9f08
to
4e064e6
Compare
2f2d61d
to
fa53e42
Compare
This seems like it can only be a vestige of an old implementation
we are adding errors explicitly to the course in the process of updating or creating it. When custom errors are on the course, calling `valid?` will clear the errors unless they are defined on the model
A course has many subjects. When the course is new and we assign subjects like: `course = some_subjects` if there are errors, then we can still access the subjects via: `course.subjects` When the course has been persisted, if there is an error in that assignment, course.subjects returns the value previous to assignment. More importantly, assigning subjects to a persisted course will make a commit to the database. It will also call save on the course and trigger any errors. This clears existing errors on the course. It is far better to consistently "soft assign" the subjects to avoid these pitfalls.
When the params `subject_id=[2,1,3]` reach the ModernLanguagesController we check the ids against a list of other ids. The array returned from this check is in a different order than the array that went in for checking. This PR ensures the subject_ids array is the same order as passed in
fa53e42
to
c4851a8
Compare
This is not a good idea, keeping the empty subject id allows us to determine the source of the subject_id based on it's position in the subjects array. The code is too complicated and brittle to change this now
By passing the empty subordinate param we can destructure the array of subjects assigning the master and subordinate accurately.
Run AssignSubjectService for FurtherEducation and when subject_ids are present
It's easier to separate the parameter validation from the course validation in the Subjects controller. This commit covers all subject types including physics, modern languages, primary, secondary and further education
b091b2b
to
84bb08f
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've left some notes on this PR because it's difficult to read
"course%5Bis_send%5D=0&course%5Blevel%5D=#{level}&course%5Bmaster_subject_id%5D=#{course_subject.id}&course%5Bsubjects_ids%5D%5B%5D=#{course_subject.id}" | ||
[ | ||
'course%5Bis_send%5D=0', | ||
"course%5Blevel%5D=#{level}", | ||
"course%5Bmaster_subject_id%5D=#{course_subject.id}", | ||
"course%5Bsubjects_ids%5D%5B%5D=#{course_subject.id}", | ||
'course%5Bsubordinate_subject_id%5D=' | ||
].join('&') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this makes the string more readable
|
||
require 'rails_helper' | ||
|
||
feature 'selecting a subject', { can_edit_current_and_next_cycles: false } do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I deleted the new_subject_spec
and split it into new_primary_subject_spec
and new_secondary_subject_spec
I added more specs to support the changes and validation.
context 'when course has two subjects' do | ||
it 'set the subordinate subject id to the second subjects_id' do | ||
subordinate_subject = find_or_create(:secondary_subject, :physics) | ||
course.subjects << subordinate_subject | ||
expect(course.reload).to have_attributes({ subordinate_subject_id: subordinate_subject.id }) | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This tests the Course.after_initialize
assignment of the second Secondary subject to the subordinate_subject_id
attribute
describe 'subordinate subject present but master missing' do | ||
let(:subject_ids) { [] } | ||
|
||
context 'with a new course' do | ||
let(:course) { Course.new } | ||
|
||
it 'raises missing subject error' do | ||
expect(subject.errors.full_messages).to include('Select a subject') | ||
end | ||
end | ||
|
||
context 'with a persisted course' do | ||
let(:course) { create(:course) } | ||
|
||
it 'raises missing subject error' do | ||
expect(subject.errors.full_messages).to include('Select a subject') | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This only gets excercised in the "new course flow" becuase we short circuit the validation in the SubjectsController for update.
@updated_subject_list ||= selected_language_subjects_ids.concat(selected_non_language_subjects_ids) | ||
@updated_subject_list ||= selected_non_language_subjects_ids.concat(selected_language_subjects_ids) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This contributes to the new ordering of subject ids where the master and subordinate subject ids are at the front and not unshited back when languages are added
super && assign_subjects_service.valid? | ||
super && assign_subjects_service.errors.none? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The course is returned from assign_subjects_service
.
Calling .valid?
on the course will clear out any validations we've added that do not appear on the Course model as validations.
if is_primary? | ||
self.master_subject_id ||= course_subjects.first&.subject_id | ||
else | ||
sub = course_subjects.select(&:position) | ||
|
||
self.master_subject_id ||= sub.first&.subject&.id | ||
|
||
self.subordinate_subject_id ||= sub&.second&.subject&.id | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This does not account for Further Education, but there is no form inputs for further Education level courses. The master
and subordinate
properties are only used for input and validation.
# NOTE: This looks like unwarranted side effects | ||
course.save | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a hangover from a previous version of the application.
This service gets called during the building of a new course. When course.save
is called, validate
is called on the course, and then all custom errors added to the model are cleared and only the validations defined on the model are run.
This is not what we want when incrementally building up errors in the new course flow.
if course_attributes[:master_subject_id].blank? && course_attributes[:subordinate_subject_id].present? | ||
course.errors.add(:subjects, :course_creation) | ||
elsif subject_ids.present? || course_attributes[:level] == 'further_education' | ||
AssignSubjectsService.call(course:, subject_ids:) | ||
end | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is here so we can avoid validating the subjects unless under specific circumstances.
We don't want to call AssignSubjectsService.call(course:, subject_ids:)
when we have not reached the subjects form (choosing primary / secondary level)
We don't want to call it if there are no subject ids either, except in the case when the level is further education
because no subject ids are passed for this level type and the Service is responsible for assigning the FurtherEducation subject to that course.
AssignSubjectsService.call(course:, subject_ids:) | ||
course.valid?(:new) if course.errors.blank? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid calling valid?
on a model when you've added custom errors.
Calling valid?
will clear your custom errors unless they are defined on the model as validations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are a few little things, but no blocks. You have navigated this nightmare terrain as well as possible.
and_there_is_a_secondary_course_i_want_to_edit | ||
and_the_course_has_two_subjects | ||
when_i_visit_the_edit_course_subject_page | ||
then_i_should_see_populated_selects('Drama', 'Latin') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
then_i_should_see_populated_selects('Drama', 'Latin') | |
then_i_see_populated_selects('Drama', 'Latin') |
I won't mark them all, but just to keep us from having to change this later if we introduce the rubocop rule that prohibits it, let's get rid of the 'should's in the tests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, and had to restrain myself from changing them initially.
when_i_select_a_subordinate_subject(:business_studies) | ||
and_i_click_continue | ||
then_i_am_met_with_course_details_page | ||
and_i_should_see_subjects_updated_success_message |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
and_i_should_see_subjects_updated_success_message | |
and_i_see_subjects_updated_success_message |
and_there_is_a_secondary_course_i_want_to_edit | ||
and_the_course_has_two_languages | ||
when_i_visit_the_edit_course_subject_page | ||
then_i_should_see_populated_selects('Drama', 'Modern Languages') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
then_i_should_see_populated_selects('Drama', 'Modern Languages') | |
then_i_see_populated_selects('Drama', 'Modern Languages') |
expect(page).to have_content('Languages') | ||
end | ||
|
||
def then_i_am_met_with_an_error | ||
within('.govuk-error-summary') do | ||
expect(page).to have_content('Select a subject') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is nitpicking, but I think we should make sure we see the error in the summary and on the input -- So could do expect(page).to have_content('Select a subject).twice
Or you can be more specific and check it is in the error summary as on the input field.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good shout, I'll remember to do that in future
@@ -13,30 +13,69 @@ | |||
when_i_select_a_primary_subject('Primary with English') | |||
and_i_click_continue | |||
then_i_am_met_with_course_details_page | |||
and_i_should_see_a_success_message | |||
and_i_should_see_subject_updated_success_message |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can see we have covered the further education scenarios in the unit testing -- are there features tests that cover that journey? (they might exist, but just haven't been changed, so I don't see them in this PR). Would be good to add a create / update scenario with FE if they don't already exist. That way, if we ever find ourselves in a position to do some refactoring, we can be confident we haven't broken any essential journeys.
I'm merging now. We have tomorrow to pickup any issues. And these changes target the course creation / editing process which is highly unlikely to cause problems overnight. |
It's worth mentioning that there is an edge-case issue identified by @elceebee where setting master subject to Modern Languages and subordinate subject to Mathematics, if clicking back link, to the subjects page after selecting languages, the master and subordinate subjects are switched. This is considered an edge case but should be documented here. |
Context
The primary concern of this PR is to validate the Second level courses' subject inputs.
When choosing two subjects to associate with a Second level course, the validation preventing a provider from choosing the same subject twice was not working.
When the validation was fixed, it was noticed that the form fields do not maintain the values that the user entered.
Changes proposed in this pull request && Guidance to review
Add
subordinate_subject_id
attribute to CourseThe form sends
master_subject_id
,subordinate_subject_id
andsubject_ids
but does not processsubordinate_subject_id
.This is because
master_subject_id
is a persisted column on the schema, andsubordinate_subject_id
is not even an attribute.I've added
subordinate_subject_id
as an ActiveModel attribute on theCourse
so we can work with it explicitly.This requires an
after_initialize
hook on the model to populate the property from the associated subjects.To understand how this happens, you need to understand all about
Subjects
But the long story short is that we can populate the form
subordinate_subject_id
with this attribute.Differences between New and Edit
The Course controllers work significantly differently between creating a new course and editing and existing course. The two process share some of the same modules however.
New Course
The problem here is that this service is called on every step of the course creation service with accumulating parameters from the view which need to be maintained over requests, including when using the back button. This makes it very messy and tricky to manage state.
The subject parameters are passed to
AssignSubjectsService
.Not only is this service called on every step of the create path, it is also called during the editing path.
The params passed to it are an array of
subject_ids
The
subject_ids
are built from the parameters in the URL for the new path.How the
subject_ids
are built and managed is explained:Select
master
dropdown and blanksubordinate
#=> subject_ids [1]
Select
subordinate
dropdown and blankmaster
#=> subject_ids [2]
Click back button after selecting only the
suboridinate
input, and the form populates themaster
input with the values supplied to thesubordinate
input previously.If modern languages is one of the subject ids, the next page is multiple select of languages. These language ids need to be manages across pages in the URL like the original subject ids.
The subject ids array can have more than 3 elements.
The current implementation PREPENDS the language ids to this list of subject ids in the URL.
This means the master id could be the first, second or third+ position in the subject_ids array.
Edit Course
AssignSubjectsService
This service receives an array of
subject_ids
The subject_ids could contain
It's hard to work with such a variable set of values. It's better if the position of the values is relatively consistent.
The Course Creation process
unshifts
language ids onto thesubject_ids
array. I've changed it so they are pushed onto the end now.The
secondary_subject_ids
are passed to the AssignSubjectsService without compacting away the missing values.Errors
The Course creation / edit system depends heavily on
errors
. The errors on the object need to be managed carefully in order to show the right errors at the right time.If we add custom, non-model errors to the object, and then call
#valid?
on the object, the custom errors are cleared and the model errors are applied.We must avoid calling valid? on an object that we have added custom errors to.
course_subjects
When we do
course.subjects = subjects
for a new record, that tries to save the records and raises an exception.There is an ActiveRecord Validation on dupliate subjects, and also a database unique constraint on the foreign keys. So it's impossible to duplicate a subject on a course.
When this happens, the
course.subjects
does not maintain the two assigned subjects.It would be great to be able to use the assigned values to repopulate the form directly.
If we begin to use the
course.course_subjects
association throughout the application, we have more control over the assignment. We can build the association without creating objects.We can use this to populate the form.
The main benefit of always using
course.course_subjects
overcourse.subjects
is consistency betweennew
andpersisted
records.Videos
New
Validating Primary, Further Education and Secondary Subject inputs
Screencast.from.30-05-24.14.53.28.webm
Edit
Validating Secondary Subject inputs
Screencast.from.30-05-24.14.57.16.webm
Checklist