diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..19fde7ce857b610ee9f16942a080f66878414bf7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [X.X.X] - 2024-03-04 + +### Corrections + +- Redmine 20344 : Ajout de la documention dans le fichier de config => VUE_APP_URL_DOCUMENTATION diff --git a/package.json b/package.json index fbb7a77ad81f5c2ec9896ded94f4a01d62476b4d..018deac37a5f06952db51ec55748ae8fcbb1dc86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "geocontrib-frontend", - "version": "5.4.0", + "version": "6.0.0", "private": true, "scripts": { "serve": "npm run init-proxy & npm run init-serve", diff --git a/public/config/config.json.sample b/public/config/config.json.sample index a02925f84901b04e3c4f3e37aad826be41da68bc..c48c56f800763b299c5ff60513eca3feb385a3f1 100644 --- a/public/config/config.json.sample +++ b/public/config/config.json.sample @@ -37,6 +37,7 @@ "PROVIDER": "addok" }, "DISPLAY_FORBIDDEN_PROJECTS": true, - "DISPLAY_FORBIDDEN_PROJECTS_DEFAULT": true + "DISPLAY_FORBIDDEN_PROJECTS_DEFAULT": true, + "VUE_APP_URL_DOCUMENTATION": "https://www.onegeosuite.fr/docs/module-geocontrib/intro" } \ No newline at end of file diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue index 89e496d8bff7bd8659f18308e2ff462c568c1919..cbca05f0e62d0e6756e4aa84ba2e18fa4f95de3c 100644 --- a/src/components/AppHeader.vue +++ b/src/components/AppHeader.vue @@ -442,7 +442,7 @@ export default { /* keep above map controls or buttons */ #app-header { - z-index: 1001; + z-index: 9999; .menu.container .ui.inverted.icon.menu { /* avoid adding space when messages are displayed */ margin: 0; display: flex; diff --git a/src/components/Dropdown.vue b/src/components/Dropdown.vue index b4dd8165090efbbe429774d3c11d4f65b46c087f..da33123b153180a105b65a7362b651055ccfe0c8 100644 --- a/src/components/Dropdown.vue +++ b/src/components/Dropdown.vue @@ -29,7 +29,7 @@ <span class="italic">{{ selected[1] }}</span> </div> <div v-else> - {{ selected }} + {{ selectedDisplay }} </div> </div> <i @@ -44,7 +44,7 @@ :key="option + index" :class="[ filteredOptions ? 'item' : 'message', - { 'active selected': option.name === selected }, + { 'active selected': option.name === selected || option.id === selected }, ]" @click="select(index)" > @@ -114,6 +114,14 @@ export default { placehold() { return this.input ? '' : this.placeholder; }, + + selectedDisplay() { // for project attributes, option are object and selected is an id + if (this.options[0] && this.options[0].name) { + const option = this.options.find(opt => opt.id === this.selected); + if (option) return option.name; + } + return this.selected; + } }, created() { diff --git a/src/components/ExtraForm.vue b/src/components/ExtraForm.vue index d9b56518a31feaf4ce291809df6f27865614bf42..a10641b954ce621e7b8b59014c2400bcb539f2c1 100644 --- a/src/components/ExtraForm.vue +++ b/src/components/ExtraForm.vue @@ -96,19 +96,19 @@ class="checkbox_list" > <div - v-for="(option, index) in field.options" - :key="index" + v-for="option in field.options" + :key="option.id || option" class="ui checkbox" > <input - :id="option" + :id="option.id || option" type="checkbox" - :checked="field.value && field.value.includes(option)" - :name="option" + :checked="field.value && field.value.includes(option.id || option)" + :name="option.id || option" @change="selectMultipleCheckbox" > - <label :for="option"> - {{ option }} + <label :for="option.id || option"> + {{ option.name || option }} </label> </div> </div> @@ -207,7 +207,7 @@ export default { set(newValue) { //* set the value selected in the dropdown if (this.useValueOnly) { - this.$emit('update:value', newValue); + this.$emit('update:value', newValue.id || newValue); } else { const newExtraForm = this.field; newExtraForm['value'] = newValue; diff --git a/src/components/Feature/Detail/FeatureHeader.vue b/src/components/Feature/Detail/FeatureHeader.vue index b9bcba1d94b4bb26166787c66ec776d6a58e60ed..182073d070809a45a0cf1bc85795b96c9e34f64c 100644 --- a/src/components/Feature/Detail/FeatureHeader.vue +++ b/src/components/Feature/Detail/FeatureHeader.vue @@ -98,9 +98,9 @@ aria-hidden="true" /> </button> - <router-link - v-if="permissions && permissions.can_create_feature" + v-if="permissions && permissions.can_create_feature + && (featureType && !featureType.geom_type.includes('multi'))" id="add-feature" :to="{ name: 'ajouter-signalement', @@ -303,6 +303,7 @@ export default { } } }; + </script> <style lang="less"> diff --git a/src/components/Map/Geocoder.vue b/src/components/Map/Geocoder.vue index 0f1a467a3f6e418e835320894d926e7c608572cd..a419e3e36058574b0e7ba93b5d7f7b6e4676a095 100644 --- a/src/components/Map/Geocoder.vue +++ b/src/components/Map/Geocoder.vue @@ -1,74 +1,81 @@ <template> - <div - id="geocoder-container" - :class="{ isExpanded }" - > - <button - class="button-geocoder" - title="Rechercher une adresse" - type="button" - @click="toggleGeocoder" + <div> + <div + id="geocoder-container" + :class="{ isExpanded }" > - <i class="search icon" /> - </button> - <!-- internal-search should be disabled to avoid filtering options, which is done by the api calls anyway https://stackoverflow.com/questions/57813170/vue-multi-select-not-showing-all-the-options --> - <!-- otherwise approximate results are not shown (cannot explain why though) --> - <Multiselect - v-if="isExpanded" - ref="multiselect" - v-model="selection" - class="expanded-geocoder" - :options="addresses" - :options-limit="limit" - :allow-empty="true" - :internal-search="false" - track-by="id" - label="label" - :show-labels="false" - :reset-after="true" - select-label="" - selected-label="" - deselect-label="" - :searchable="true" - :placeholder="placeholder" - :show-no-results="true" - :loading="loading" - :clear-on-select="false" - :preserve-search="true" - @search-change="search" - @select="select" - @open="retrievePreviousPlaces" - @close="close" + <button + class="button-geocoder" + title="Rechercher une adresse" + type="button" + @click="toggleGeocoder" + > + <i class="search icon" /> + </button> + </div> + <div + id="geocoder-select-container" + :class="{ isExpanded }" > - <template - slot="option" - slot-scope="props" + <!-- internal-search should be disabled to avoid filtering options, which is done by the api calls anyway https://stackoverflow.com/questions/57813170/vue-multi-select-not-showing-all-the-options --> + <!-- otherwise approximate results are not shown (cannot explain why though) --> + <Multiselect + v-if="isExpanded" + ref="multiselect" + v-model="selection" + class="expanded-geocoder" + :options="addresses" + :options-limit="limit" + :allow-empty="true" + :internal-search="false" + track-by="id" + label="label" + :show-labels="false" + :reset-after="true" + select-label="" + selected-label="" + deselect-label="" + :searchable="true" + :placeholder="placeholder" + :show-no-results="true" + :loading="loading" + :clear-on-select="false" + :preserve-search="true" + @search-change="search" + @select="select" + @open="retrievePreviousPlaces" + @close="close" > - <div class="option__desc"> - <span class="option__title">{{ props.option.label }}</span> - </div> - </template> - <template slot="clear"> - <div - v-if="selection" - class="multiselect__clear" - @click.prevent.stop="selection = null" + <template + slot="option" + slot-scope="props" > - <i class="close icon" /> - </div> - </template> - <span slot="noResult"> - Aucun résultat. - </span> - <span slot="noOptions"> - Saisissez les premiers caractères ... - </span> - </Multiselect> - <div style="display: none;"> - <div - id="marker" - title="Marker" - /> + <div class="option__desc"> + <span class="option__title">{{ props.option.label }}</span> + </div> + </template> + <template slot="clear"> + <div + v-if="selection" + class="multiselect__clear" + @click.prevent.stop="selection = null" + > + <i class="close icon" /> + </div> + </template> + <span slot="noResult"> + Aucun résultat. + </span> + <span slot="noOptions"> + Saisissez les premiers caractères ... + </span> + </Multiselect> + <div style="display: none;"> + <div + id="marker" + title="Marker" + /> + </div> </div> </div> </template> @@ -150,6 +157,7 @@ export default { mapService.addOverlay(this.selectedAddress.geometry.coordinates, zoomlevel); // On enregistre l'adresse sélectionné pour le proposer à la prochaine recherche this.setLocalstorageSelectedAdress(this.selectedAddress); + this.toggleGeocoder(); } }, @@ -250,11 +258,26 @@ export default { &&.isExpanded { .button-geocoder { - height: 41px; + /*height: 41px;*/ color: rgb(99, 99, 99); border-radius: 2px 0 0 2px; } } +} + +#geocoder-select-container{ + position: absolute; + right: 46px; + // each button have (more or less depends on borders) .5em space between + // zoom buttons are 60px high, geolocation and full screen button is 34px high with borders + top: calc(1.6em + 60px + 34px + 34px - 4px); + pointer-events: auto; + z-index: 999; + border: 2px solid rgba(0,0,0,.2); + background-clip: padding-box; + padding: 0; + border-radius: 4px; + display: flex; // /* keep placeholder width when opening dropdown */ .multiselect { min-width: 208px; @@ -280,6 +303,7 @@ export default { .multiselect__tags { border: 0 !important; min-height: 41px !important; + padding: 10px 40px 0 8px; } .multiselect input { line-height: 1em !important; @@ -288,6 +312,7 @@ export default { .multiselect__content-wrapper { border: 2px solid rgba(0,0,0,.2); } + } </style> \ No newline at end of file diff --git a/src/components/Project/Detail/ProjectFeatureTypes.vue b/src/components/Project/Detail/ProjectFeatureTypes.vue index a9163fbdce2460669f2e6ed02752d2b47fe6c647..faad82a3a85b4dcb3e8e4bab4858dd4b3dd94fcf 100644 --- a/src/components/Project/Detail/ProjectFeatureTypes.vue +++ b/src/components/Project/Detail/ProjectFeatureTypes.vue @@ -429,7 +429,8 @@ export default { }, goToDocumentation() { - window.open('https://geocontrib.readthedocs.io/fr/latest/documentation_fonctionnelle/import_export/'); + + window.open(this.configuration.VUE_APP_URL_DOCUMENTATION); }, toNewGeojsonFeatureType() { diff --git a/src/components/Project/Edition/ProjectAttributeForm.vue b/src/components/Project/Edition/ProjectAttributeForm.vue index 6b2f3d5a1a600573509d344c31e20ef00aa3d8c5..f00d352733ffe1c78c4f298f6e4c399db6298698 100644 --- a/src/components/Project/Edition/ProjectAttributeForm.vue +++ b/src/components/Project/Edition/ProjectAttributeForm.vue @@ -60,8 +60,8 @@ export default { }, created() { - // Checks if the component is being used in the context of creating a new project. - if (this.$route.name === 'project_create') { + // Checks if the component is being used in the context of creating a new project and attribute's default value is set + if (this.$route.name === 'project_create' && this.attribute.default_value !== null) { // If so, initializes the attribute's value with its default value as defined in the attribute's settings. this.updateValue(this.attribute.default_value, this.attribute.id); } diff --git a/src/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters.vue b/src/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters.vue index 926d8201efb8ce836efe928ffe614ad9de2f44ce..981962f573a941e630eb71149852b6ecf2cb73bb 100644 --- a/src/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters.vue +++ b/src/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters.vue @@ -403,7 +403,7 @@ export default { width: 100%; margin-left: 25%; .secondary.menu #button-dropdown { - z-index: 1; + z-index: 10; margin-right: 0; padding-right: 0; } diff --git a/src/components/Projects/DropdownMenuItem.vue b/src/components/Projects/DropdownMenuItem.vue index 8c16b6312e8767cce9cbff2a6f46d13111cc18d7..6282644c46d17600a2f8755b7d95296b4ed58166 100644 --- a/src/components/Projects/DropdownMenuItem.vue +++ b/src/components/Projects/DropdownMenuItem.vue @@ -39,7 +39,7 @@ <span v-else class="multiselect__single" - >{{ currentSelection || selection.label }}</span> + >{{ currentSelectionLabel || selection.label }}</span> </template> </Multiselect> </template> @@ -85,6 +85,16 @@ export default { }; }, + computed: { + /** + * Get the label of an option to work with project attributes options as JSON + */ + currentSelectionLabel() { + const option = this.options.find(opt => opt.value === this.currentSelection); + return option ? option.label : ''; + } + }, + watch: { selection: { deep: true, diff --git a/src/components/Projects/ProjectsMenu.vue b/src/components/Projects/ProjectsMenu.vue index d37b8068464ba1a8afd2b11ea86b8301a0c0023a..0fbeb7877f1f0c47727e6f6ce6f89263a7f6c127 100644 --- a/src/components/Projects/ProjectsMenu.vue +++ b/src/components/Projects/ProjectsMenu.vue @@ -1,8 +1,7 @@ <template> <div - v-if="chunkedNsortedFilters" + v-if="chunkedNsortedFilters.length > 0" id="filters-container" - class="margin-bottom" > <div class="ui styled accordion" @@ -191,8 +190,9 @@ export default { * @returns {Array} An array of arrays, where each sub-array contains up to 4 project attributes with modified options for display. */ displayedAttributeFilters() { - // Filter and process attributes - return this.projectAttributes.filter(attribute => attribute.display_filter) + // Filter displayed filters & filter only attribute of boolean type (no need for option property) or list type with options + return this.projectAttributes.filter(attribute => attribute.display_filter && (attribute.field_type === 'boolean' || attribute.options)) + // Process attributes for display .map(attribute => { // Format the options to be displayed by dropdowns const options = this.generateFilterOptions(attribute); @@ -251,14 +251,15 @@ export default { { filter: filter.id, label: 'Oui', value: 'true' }, { filter: filter.id, label: 'Non', value: 'false' }, ]; + } else if (filter.options) { + // For other filter types, map each option to the expected format + return filter.options.map(option => ({ + filter: filter.id || filter.name, + label: option.name || option.label || option, + value: option.id || option.value || option, + })); } - - // For other filter types, map each option to the expected format - return filter.options.map(option => ({ - filter: filter.id || filter.name, - label: option.label || option, - value: option.value || option, - })); + return []; }, /** @@ -388,6 +389,7 @@ export default { height:auto; max-height:100vh; opacity: 1; + z-index: 1001; .transition-properties(all 0.2s ease;); .filter-row { border: none; diff --git a/src/router/index.js b/src/router/index.js index ee401ccc3c00016818c0060fb383c7535a64a21d..ab3b4f44f18f53a5c3eb0de64ab278fe256229e2 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -130,11 +130,16 @@ const routes = [ query['feature_type_slug'] = slug_type_signal; // set feature_type slug in query } const offset = await featureAPI.getFeaturePosition(slug, slug_signal, query); - next({ - name: 'details-signalement-filtre', - params: { slug }, - query: { ...query, offset } - }); + if (offset) { + next({ + name: 'details-signalement-filtre', + params: { slug }, + query: { ...query, offset } + }); + } else { + store.commit('DISPLAY_MESSAGE', { comment: 'Désolé, une erreur est survenue pendant la recherche du signalement', level: 'negative' }); + next({ path: '/' }); + } } catch (error) { console.error('error', error); store.commit('DISPLAY_MESSAGE', { comment: `Désolé, une erreur est survenue pendant la recherche du signalement - ${error}`, level: 'negative' }); diff --git a/src/services/map-service.js b/src/services/map-service.js index fd61e476439f587bed287b4b2c7c44828e238444..f12c94b4a59f42c1bdc06ada50319b7591ab0876 100644 --- a/src/services/map-service.js +++ b/src/services/map-service.js @@ -230,7 +230,7 @@ const mapService = { this.overlay.setPosition(event.coordinate); this.addRouterToPopup({ featureId, - featureTypeSlug: popupContent.featureType ? popupContent.featureType.slug : '', + featureTypeSlug: popupContent.feature_type ? popupContent.feature_type.slug : '', index: popupContent.index, }); } @@ -383,7 +383,7 @@ const mapService = { }, addWMSLayer: function (url, options) { - options.VERSION = '1.1.1'; // pour compatibilité avec le proxy django + options.VERSION = options.version || '1.3.0'; // pour compatibilité avec le proxy django const source = new TileWMS({ attributions: options.attribution, url: url, diff --git a/src/store/index.js b/src/store/index.js index 0ec3156209fb8b15e59769e24a0bb07ff23c2f39..0578ae4056a55aab189f1a51ad2b23fd1d37e1cf 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -183,33 +183,62 @@ export default new Vuex.Store({ }); } }, - + /** + * Action to retrieve user information. + * - If a token is present in the URL, it indicates a Single Sign-On (SSO) attempt, + * in which case it logs out the user (if logged in) and connects via SSO with the token. + * Otherwise, it fetches user information from the Django API endpoint: + * - If no user is logged AND if the login should be done through SSO with a redirect, + * it naviguates to the login plateform, afterwards the user will be redirected with the token and the original url to open in geocontrib + * - Else it displays a message that the user is not logged but can still access the app as an anonymous user. + */ async GET_USER_INFO({ state, commit, dispatch }) { - const token = new URLSearchParams(window.location.search).get('token'); - if (token && this.state.configuration.VUE_APP_LOGIN_URL) { - // if user was previously connected through SSO, make sure he's logout before connecting through SSO, in case user changed + // Extract token from URL query parameters + const searchParams = new URLSearchParams(window.location.search); + const token = searchParams.get('token'); + const url_redirect = searchParams.get('url_redirect'); + // Check if token exists and SSO login URL is configured + if (token && state.configuration.VUE_APP_LOGIN_URL) { + // If user was previously connected through SSO, ensure they are logged out before reconnecting through SSO, in case user changed await dispatch('LOGOUT'); - dispatch('CONNECT_SSO_WITH_TOKEN', token); + dispatch('CONNECT_SSO_WITH_TOKEN', { token, url_redirect }); } else if (!state.user) { + // If user infos are not set, try to fetch them axios - .get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}user_info/`) + .get(`${state.configuration.VUE_APP_DJANGO_API_BASE}user_info/`) .then((response) => { + // Update the user state with received user data if (response && response.status === 200) { const user = response.data.user; commit('SET_USER', user); } }) - .catch((err) => { - console.error(err); - commit('DISPLAY_MESSAGE', { - comment: `Vous n'êtes pas connecté actuellement. - Vous pouvez accéder à l'application en tant qu'utilisateur anonyme` - }); + .catch(() => { + // If the instance is set to accept login with redirection + if (state.configuration.VUE_APP_SSO_LOGIN_URL_WITH_REDIRECT) { + commit('DISPLAY_MESSAGE', { + comment: 'Vous allez être redirigé vers la plateforme de connexion.' + }); + // Call the SSO login plateform with url to redirect after login + window.open(`${state.configuration.VUE_APP_SSO_LOGIN_URL_WITH_REDIRECT}/?url_redirect=${window.location.href}`, '_self'); + } else { + // If the user is not logged in, display an info message + commit('DISPLAY_MESSAGE', { + comment: `Vous n'êtes pas connecté actuellement. + Vous pouvez accéder à l'application en tant qu'utilisateur anonyme` + }); + } }); } }, - - async CONNECT_SSO_WITH_TOKEN({ state, commit, dispatch }, token) { + /** + * Action to connect user through SSO with a token. + * If the app was opened with a token in the url, it attempts a login, + * if the login is succesfull, it set the user in the state + * and retrieve information that would have been retrieved in GET_USER_INFO when logged. + * If the url contained a url to redirect, it calls the router to open this page. + */ + async CONNECT_SSO_WITH_TOKEN({ state, commit, dispatch }, { token, url_redirect }) { axios .get(`${state.configuration.VUE_APP_DJANGO_API_BASE}login-token/?token=${token}`) .then((response) => { @@ -219,15 +248,29 @@ export default new Vuex.Store({ dispatch('GET_USER_LEVEL_PROJECTS'); dispatch('GET_USER_LEVEL_PERMISSIONS'); commit('DISPLAY_MESSAGE', { - comment: `Vous êtes maintenant connecté ${ user.first_name} ${ user.last_name}`, level: 'positive' + comment: `Vous êtes maintenant connecté ${user.first_name} ${user.last_name}`, + level: 'positive' }); dispatch('projects/GET_PROJECTS'); + if (url_redirect) { + // Prepare the url to redirect with vue-router that prefix the url with DOMAIN+BASE_URL + const substringToRemove = state.configuration.BASE_URL; + // Find the index of the string to remove + const index = url_redirect.indexOf(substringToRemove); + // If found, keep only the remaining part after the substring to remove + if (index !== -1) { + url_redirect = index !== -1 ? url_redirect.substring(index + substringToRemove.length) : url_redirect; + } + // catch error from the router, because of second redirection to feature when call with a feature's id + router.push(url_redirect).catch((e) => e); + } } }) .catch((err) => { console.error(err); commit('DISPLAY_MESSAGE', { - comment: 'La connexion a échoué.', level: 'negative' + comment: 'La connexion a échoué.', + level: 'negative' }); }); }, diff --git a/src/views/Project/ProjectEdit.vue b/src/views/Project/ProjectEdit.vue index d3f6890357aab470cb4a362f735f197d7a271359..e9ce66bb553843c7afc822ac0028beee3cc79721 100644 --- a/src/views/Project/ProjectEdit.vue +++ b/src/views/Project/ProjectEdit.vue @@ -300,13 +300,16 @@ </div> - <div class="ui horizontal divider"> + <div + v-if="filteredAttributes.length > 0" + class="ui horizontal divider" + > ATTRIBUTS </div> <div class="fields grouped"> <ProjectAttributeForm - v-for="(attribute, index) in projectAttributes" + v-for="(attribute, index) in filteredAttributes" :key="index" :attribute="attribute" :form-project-attributes="form.project_attributes" @@ -357,7 +360,6 @@ export default { name: 'Sélectionner une image ...', size: 0, }, - errors_archive_feature: [], errors: { title: [], access_level_pub_feature: [], @@ -394,8 +396,6 @@ export default { creator: null, access_level_pub_feature: { name: '', value: '' }, access_level_arch_feature: { name: '', value: '' }, - archive_feature: 0, - delete_feature: 0, map_max_zoom_level: 22, nb_features: 0, nb_published_features: 0, @@ -482,7 +482,14 @@ export default { }); } return levels; - } + }, + /** + * Filter out attribute of field type list without option + */ + filteredAttributes() { + return this.projectAttributes.filter(attr => attr.field_type === 'boolean' || attr.options); + }, + }, watch: { @@ -594,16 +601,6 @@ export default { } }, - checkEmpty() { - //* forbid empty fields - if (!this.form.archive_feature) { - this.form.archive_feature = 0; - } - if (!this.form.delete_feature) { - this.form.delete_feature = 0; - } - }, - goBackNrefresh(slug) { Promise.all([ this.$store.dispatch('GET_USER_LEVEL_PROJECTS'), //* refresh projects user levels @@ -652,12 +649,6 @@ export default { }, checkForm() { - if (this.form.archive_feature > this.form.delete_feature) { - this.errors_archive_feature.push( - "Le délais de suppression doit être supérieur au délais d'archivage." - ); - return false; - } for (const key in this.errors) { if ((key === 'title' && this.form[key]) || this.form[key].value) { this.errors[key] = []; diff --git a/src/views/Projects/ProjectsList.vue b/src/views/Projects/ProjectsList.vue index 9682c5814278b5510053cdce53a4b3f51560ea51..02eccb1ce8fd30be9ebe9bdaf305f615b7ed78c8 100644 --- a/src/views/Projects/ProjectsList.vue +++ b/src/views/Projects/ProjectsList.vue @@ -42,7 +42,7 @@ <div v-if="configuration.DISPLAY_FORBIDDEN_PROJECTS" id="forbidden-projects" - class="ui toggle checkbox" + class="ui toggle checkbox margin-top" > <input :checked="displayForbiddenProjects" @@ -60,10 +60,7 @@ class="ui divided items dimmable dimmed" data-test="project-list" > - <div - :class="{ active: loading }" - class="ui inverted dimmer" - > + <div :class="['ui inverted dimmer', { active: loading }]"> <div class="ui loader" /> </div>