From d77c52c56454c432ac27d670d25be13f9f9d7232 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 30 Jan 2026 16:22:26 +0000 Subject: [PATCH 1/6] Reduce code duplication to pass SonarQube quality gate Centralize duplicated error messages and form validation code to reduce duplication from 4.87% to below the 3% threshold. Backend changes: - Add controllers/api/constants.py with 40+ standardized error messages - Update 17 controller files to use shared constants - Update 2 test files with improved error message format Frontend changes: - Add mixins/formValidationMixin.js with reusable validation methods - Update 9 Vue components to use the shared mixin Co-Authored-By: Claude Opus 4.5 --- client/src/mixins/formValidationMixin.js | 99 +++++++++++++++++ client/src/views/show/config/ConfigCast.vue | 26 ++--- .../views/show/config/ConfigCharacters.vue | 26 ++--- .../config/acts_and_scenes/ConfigActs.vue | 26 ++--- .../config/acts_and_scenes/ConfigScenes.vue | 34 ++---- .../config/characters/CharacterGroups.vue | 26 ++--- .../show/config/stage/CrewList.vue | 22 +--- .../show/config/stage/PropsList.vue | 26 ++--- .../show/config/stage/SceneryList.vue | 26 ++--- .../user/settings/CueColourPreferences.vue | 24 ++--- server/controllers/api/constants.py | 101 ++++++++++++++++++ server/controllers/api/show/acts.py | 40 ++++--- server/controllers/api/show/cast.py | 36 ++++--- server/controllers/api/show/characters.py | 59 +++++----- server/controllers/api/show/cues.py | 71 +++++++----- server/controllers/api/show/microphones.py | 48 +++++---- server/controllers/api/show/scenes.py | 34 +++--- .../controllers/api/show/script/compiled.py | 14 ++- .../controllers/api/show/script/revisions.py | 30 +++--- server/controllers/api/show/script/script.py | 13 +-- .../api/show/script/stage_direction_styles.py | 41 ++++--- .../api/show/session/assign_tags.py | 3 +- .../controllers/api/show/session/sessions.py | 7 +- server/controllers/api/show/session/tags.py | 36 ++++--- server/controllers/api/show/shows.py | 5 +- server/controllers/api/show/stage/crew.py | 35 +++--- server/controllers/api/show/stage/props.py | 88 ++++++++------- server/controllers/api/show/stage/scenery.py | 88 ++++++++------- server/controllers/api/user/overrides.py | 23 ++-- .../controllers/api/show/stage/test_props.py | 2 +- .../api/show/stage/test_scenery.py | 2 +- 31 files changed, 658 insertions(+), 453 deletions(-) create mode 100644 client/src/mixins/formValidationMixin.js create mode 100644 server/controllers/api/constants.py diff --git a/client/src/mixins/formValidationMixin.js b/client/src/mixins/formValidationMixin.js new file mode 100644 index 00000000..89911d3f --- /dev/null +++ b/client/src/mixins/formValidationMixin.js @@ -0,0 +1,99 @@ +/** + * Mixin providing common form validation helper methods for Vuelidate. + * + * This mixin centralizes the repeated validation and form reset patterns used + * across multiple components that handle new/edit forms. + * + * Usage: + * 1. Import and include in your component's mixins array + * 2. Define your form state properties (e.g., newFormState, editFormState) + * 3. Define your validations object as usual + * 4. Use the helper methods in your template and component logic + * + * Example: + * import formValidationMixin from '@/mixins/formValidationMixin'; + * + * export default { + * mixins: [formValidationMixin], + * data() { + * return { + * newFormState: { name: '' }, + * editFormState: { id: null, name: '' }, + * }; + * }, + * validations: { + * newFormState: { name: { required } }, + * editFormState: { name: { required } }, + * }, + * methods: { + * resetNewForm() { + * this.resetForm('newFormState', { name: '' }); + * }, + * }, + * }; + */ +export default { + methods: { + /** + * Get Bootstrap validation state for a form field. + * + * @param {string} formStateKey - The validation group key (e.g., 'newFormState', 'editFormState') + * @param {string} fieldName - The field name within the validation group + * @returns {boolean|null} - true if valid, false if invalid, null if pristine + */ + getValidationState(formStateKey, fieldName) { + const field = this.$v[formStateKey][fieldName]; + if (!field) { + return null; + } + const { $dirty, $error } = field; + return $dirty ? !$error : null; + }, + + /** + * Validate state for a field in newFormState. + * Convenience method that wraps getValidationState. + * + * @param {string} fieldName - The field name to validate + * @returns {boolean|null} - Bootstrap validation state + */ + validateNewState(fieldName) { + return this.getValidationState('newFormState', fieldName); + }, + + /** + * Validate state for a field in editFormState. + * Convenience method that wraps getValidationState. + * + * @param {string} fieldName - The field name to validate + * @returns {boolean|null} - Bootstrap validation state + */ + validateEditState(fieldName) { + return this.getValidationState('editFormState', fieldName); + }, + + /** + * Reset a form to its initial state and reset validation. + * + * @param {string} formStateKey - The data property key (e.g., 'newFormState') + * @param {Object} initialState - The initial state object to reset to + */ + resetForm(formStateKey, initialState) { + this[formStateKey] = { ...initialState }; + this.$nextTick(() => { + this.$v.$reset(); + }); + }, + + /** + * Check if a form has validation errors after touching all fields. + * + * @param {string} formStateKey - The validation group key + * @returns {boolean} - true if the form has any validation errors + */ + hasFormErrors(formStateKey) { + this.$v[formStateKey].$touch(); + return this.$v[formStateKey].$anyError; + }, + }, +}; diff --git a/client/src/views/show/config/ConfigCast.vue b/client/src/views/show/config/ConfigCast.vue index 39b0e7f7..9bf571cb 100644 --- a/client/src/views/show/config/ConfigCast.vue +++ b/client/src/views/show/config/ConfigCast.vue @@ -148,10 +148,12 @@ import { required } from 'vuelidate/lib/validators'; import { mapGetters, mapActions } from 'vuex'; import CastLineStats from '@/vue_components/show/config/cast/CastLineStats.vue'; import log from 'loglevel'; +import formValidationMixin from '@/mixins/formValidationMixin'; export default { name: 'ConfigCast', components: { CastLineStats }, + mixins: [formValidationMixin], data() { return { castFields: ['first_name', 'last_name', { key: 'btn', label: '' }], @@ -198,15 +200,11 @@ export default { }, methods: { resetNewForm() { - this.newFormState = { + this.resetForm('newFormState', { firstName: '', lastName: '', - }; - this.submittingNewCast = false; - - this.$nextTick(() => { - this.$v.$reset(); }); + this.submittingNewCast = false; }, async onSubmitNew(event) { this.$v.newFormState.$touch(); @@ -227,10 +225,6 @@ export default { this.submittingNewCast = false; } }, - validateNewState(name) { - const { $dirty, $error } = this.$v.newFormState[name]; - return $dirty ? !$error : null; - }, openEditForm(castMember) { if (castMember != null) { this.editFormState.id = castMember.item.id; @@ -241,18 +235,14 @@ export default { } }, resetEditForm() { - this.editFormState = { + this.resetForm('editFormState', { id: null, showID: null, firstName: '', lastName: '', - }; + }); this.submittingEditCast = false; this.deletingCast = false; - - this.$nextTick(() => { - this.$v.$reset(); - }); }, async onSubmitEdit(event) { this.$v.editFormState.$touch(); @@ -273,10 +263,6 @@ export default { this.submittingEditCast = false; } }, - validateEditState(name) { - const { $dirty, $error } = this.$v.editFormState[name]; - return $dirty ? !$error : null; - }, async deleteCastMember(castMember) { if (this.deletingCast) { return; diff --git a/client/src/views/show/config/ConfigCharacters.vue b/client/src/views/show/config/ConfigCharacters.vue index ffe45695..30805e1a 100644 --- a/client/src/views/show/config/ConfigCharacters.vue +++ b/client/src/views/show/config/ConfigCharacters.vue @@ -168,10 +168,12 @@ import { mapGetters, mapActions } from 'vuex'; import CharacterLineStats from '@/vue_components/show/config/characters/CharacterLineStats.vue'; import log from 'loglevel'; import CharacterGroups from '@/vue_components/show/config/characters/CharacterGroups.vue'; +import formValidationMixin from '@/mixins/formValidationMixin'; export default { name: 'ConfigCharacters', components: { CharacterGroups, CharacterLineStats }, + mixins: [formValidationMixin], data() { return { rowsPerPage: 15, @@ -232,16 +234,12 @@ export default { }, methods: { resetNewForm() { - this.newFormState = { + this.resetForm('newFormState', { name: '', description: '', played_by: null, - }; - this.submittingNewCharacter = false; - - this.$nextTick(() => { - this.$v.$reset(); }); + this.submittingNewCharacter = false; }, async onSubmitNew(event) { this.$v.newFormState.$touch(); @@ -262,10 +260,6 @@ export default { this.submittingNewCharacter = false; } }, - validateNewState(name) { - const { $dirty, $error } = this.$v.newFormState[name]; - return $dirty ? !$error : null; - }, openEditForm(character) { if (character != null) { this.editFormState.id = character.item.id; @@ -277,19 +271,15 @@ export default { } }, resetEditForm() { - this.editFormState = { + this.resetForm('editFormState', { id: null, showID: null, name: '', description: '', played_by: null, - }; + }); this.submittingEditCharacter = false; this.deletingCharacter = false; - - this.$nextTick(() => { - this.$v.$reset(); - }); }, async onSubmitEdit(event) { this.$v.editFormState.$touch(); @@ -310,10 +300,6 @@ export default { this.submittingEditCharacter = false; } }, - validateEditState(name) { - const { $dirty, $error } = this.$v.editFormState[name]; - return $dirty ? !$error : null; - }, async deleteCharacter(character) { if (this.deletingCharacter) { return; diff --git a/client/src/vue_components/show/config/acts_and_scenes/ConfigActs.vue b/client/src/vue_components/show/config/acts_and_scenes/ConfigActs.vue index e1487819..9b5581ed 100644 --- a/client/src/vue_components/show/config/acts_and_scenes/ConfigActs.vue +++ b/client/src/vue_components/show/config/acts_and_scenes/ConfigActs.vue @@ -165,9 +165,11 @@ import { required, integer } from 'vuelidate/lib/validators'; import { mapGetters, mapActions } from 'vuex'; import log from 'loglevel'; +import formValidationMixin from '@/mixins/formValidationMixin'; export default { name: 'ConfigActs', + mixins: [formValidationMixin], data() { return { loading: true, @@ -271,20 +273,12 @@ export default { }, methods: { resetNewForm() { - this.newFormState = { + this.resetForm('newFormState', { name: '', interval_after: false, previous_act_id: null, - }; - this.submittingNewAct = false; - - this.$nextTick(() => { - this.$v.$reset(); }); - }, - validateNewState(name) { - const { $dirty, $error } = this.$v.newFormState[name]; - return $dirty ? !$error : null; + this.submittingNewAct = false; }, async onSubmitNew(event) { this.$v.newFormState.$touch(); @@ -318,19 +312,15 @@ export default { } }, resetEditForm() { - this.editFormState = { + this.resetForm('editFormState', { id: null, showID: null, name: '', interval_after: false, previous_act_id: null, - }; + }); this.submittingEditAct = false; this.deletingAct = false; - - this.$nextTick(() => { - this.$v.$reset(); - }); }, async onSubmitEdit(event) { this.$v.editFormState.$touch(); @@ -351,10 +341,6 @@ export default { this.submittingEditAct = false; } }, - validateEditState(name) { - const { $dirty, $error } = this.$v.editFormState[name]; - return $dirty ? !$error : null; - }, async deleteAct(act) { if (this.deletingAct) { return; diff --git a/client/src/vue_components/show/config/acts_and_scenes/ConfigScenes.vue b/client/src/vue_components/show/config/acts_and_scenes/ConfigScenes.vue index 78d1cc18..d34434dc 100644 --- a/client/src/vue_components/show/config/acts_and_scenes/ConfigScenes.vue +++ b/client/src/vue_components/show/config/acts_and_scenes/ConfigScenes.vue @@ -215,9 +215,11 @@ import { required, integer } from 'vuelidate/lib/validators'; import { mapGetters, mapActions } from 'vuex'; import log from 'loglevel'; +import formValidationMixin from '@/mixins/formValidationMixin'; export default { name: 'ConfigScenes', + mixins: [formValidationMixin], data() { return { loading: true, @@ -420,20 +422,12 @@ export default { }, methods: { resetNewForm() { - this.newFormState = { + this.resetForm('newFormState', { name: '', act_id: null, previous_scene_id: null, - }; - this.submittingNewScene = false; - - this.$nextTick(() => { - this.$v.$reset(); }); - }, - validateNewState(name) { - const { $dirty, $error } = this.$v.newFormState[name]; - return $dirty ? !$error : null; + this.submittingNewScene = false; }, async onSubmitNew(event) { this.$v.newFormState.$touch(); @@ -473,15 +467,11 @@ export default { } }, resetFirstSceneForm() { - this.firstSceneFormState = { + this.resetForm('firstSceneFormState', { act_id: null, scene_id: null, - }; - this.submittingFirstScene = false; - - this.$nextTick(() => { - this.$v.$reset(); }); + this.submittingFirstScene = false; }, openFirstSceneEdit(act) { if (act != null) { @@ -513,18 +503,14 @@ export default { }, resetEditForm() { this.editSceneID = null; - this.editFormState = { + this.resetForm('editFormState', { scene_id: null, name: '', act_id: null, previous_scene_id: null, - }; + }); this.submittingEditScene = false; this.deletingScene = false; - - this.$nextTick(() => { - this.$v.$reset(); - }); }, openEditForm(scene) { if (scene != null) { @@ -540,10 +526,6 @@ export default { this.$bvModal.show('edit-scene'); } }, - validateEditState(name) { - const { $dirty, $error } = this.$v.editFormState[name]; - return $dirty ? !$error : null; - }, async onSubmitEdit(event) { this.$v.editFormState.$touch(); if (this.$v.editFormState.$anyError || this.submittingEditScene) { diff --git a/client/src/vue_components/show/config/characters/CharacterGroups.vue b/client/src/vue_components/show/config/characters/CharacterGroups.vue index 1788981a..d2275bab 100644 --- a/client/src/vue_components/show/config/characters/CharacterGroups.vue +++ b/client/src/vue_components/show/config/characters/CharacterGroups.vue @@ -168,9 +168,11 @@ import { mapActions, mapGetters } from 'vuex'; import { required } from 'vuelidate/lib/validators'; import log from 'loglevel'; +import formValidationMixin from '@/mixins/formValidationMixin'; export default { name: 'CharacterGroups', + mixins: [formValidationMixin], data() { return { loading: true, @@ -224,16 +226,12 @@ export default { }, resetNewForm() { this.tempCharacterList = []; - this.newFormState = { + this.resetForm('newFormState', { name: '', description: '', characters: [], - }; - this.submittingNewGroup = false; - - this.$nextTick(() => { - this.$v.$reset(); }); + this.submittingNewGroup = false; }, async onSubmitNew(event) { this.$v.newFormState.$touch(); @@ -254,10 +252,6 @@ export default { this.submittingNewGroup = false; } }, - validateNewState(name) { - const { $dirty, $error } = this.$v.newFormState[name]; - return $dirty ? !$error : null; - }, async deleteCharacterGroup(characterGroup) { if (this.deletingGroup) { return; @@ -293,19 +287,15 @@ export default { } }, resetEditForm() { - this.editFormState = { + this.resetForm('editFormState', { id: null, name: '', description: '', characters: [], - }; + }); this.tempEditCharacterList = []; this.submittingEditGroup = false; this.deletingGroup = false; - - this.$nextTick(() => { - this.$v.$reset(); - }); }, editSelectChanged(value, id) { this.$v.editFormState.characters.$model = value.map((character) => character.id); @@ -329,10 +319,6 @@ export default { this.submittingEditGroup = false; } }, - validateEditState(name) { - const { $dirty, $error } = this.$v.editFormState[name]; - return $dirty ? !$error : null; - }, ...mapActions([ 'GET_CHARACTER_LIST', 'GET_CHARACTER_GROUP_LIST', diff --git a/client/src/vue_components/show/config/stage/CrewList.vue b/client/src/vue_components/show/config/stage/CrewList.vue index 0a5388c6..5cd80be3 100644 --- a/client/src/vue_components/show/config/stage/CrewList.vue +++ b/client/src/vue_components/show/config/stage/CrewList.vue @@ -121,9 +121,11 @@