diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bd01e22edd47c77eb35b09eff7b41198f1463721..8ae10fb420e5c853a1b2b129380a52dffff24a5c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -25,16 +25,14 @@ build testing docker image: only: - develop tags: - - build - image: - name: gcr.io/kaniko-project/executor:debug - entrypoint: [""] + - build_docker + variables: + DOCKER_TAG: testing script: - - mkdir -p /kaniko/.docker - - export - - echo "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json - - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination neogeo/geocontrib-front:testing - - echo Image docker neogeo/geocontrib-front:testing livrée + - cat $DOCKER_PASSWORD | docker login --username $DOCKER_LOGIN --password-stdin + - docker-compose build geocontrib-front + - docker-compose push geocontrib-front + - echo Image docker neogeo/geocontrib-front:${DOCKER_TAG} livrée deploy testing docker image: stage: deploy @@ -52,36 +50,30 @@ build stable docker image: only: - master tags: - - build - image: - name: gcr.io/kaniko-project/executor:debug - entrypoint: [""] + - build_docker + variables: + DOCKER_TAG: latest script: - - mkdir -p /kaniko/.docker - - echo "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json - - - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination neogeo/geocontrib-front:latest - - echo Image docker neogeo/geocontrib:latest livrée - + - cat $DOCKER_PASSWORD | docker login --username $DOCKER_LOGIN --password-stdin + - docker-compose build geocontrib-front + - docker-compose push geocontrib-front + - echo Image docker neogeo/geocontrib-front:${DOCKER_TAG} livrée build tagged docker image: stage: build only: - tags tags: - - build - image: - name: gcr.io/kaniko-project/executor:debug - entrypoint: [""] + - build_docker + variables: + DOCKER_TAG: $CI_COMMIT_TAG script: # Don't build tag id package.json as wrong version - grep "\"version\":.\"$CI_COMMIT_TAG\"" package.json - - mkdir -p /kaniko/.docker - - echo "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json - - - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination neogeo/geocontrib-front:$CI_COMMIT_TAG - - echo Image docker neogeo/geocontrib-front:$CI_COMMIT_TAG livrée - + - cat $DOCKER_PASSWORD | docker login --username $DOCKER_LOGIN --password-stdin + - docker-compose build geocontrib-front + - docker-compose push geocontrib-front + - echo Image docker neogeo/geocontrib-front:${DOCKER_TAG} livrée sonarqube-check: image: diff --git a/docker-compose.yaml b/docker-compose.yaml index 18f164891441f5ecbbaa1bbf86ba056f826570f6..a250dbb007c4693ba0d4c4e033be50cc1ed6fa28 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,7 +2,7 @@ version: "3" services: geocontrib-front: - image: neogeo/geocontrib-front:geocontrib-latest + image: neogeo/geocontrib-front:${DOCKER_TAG:-testing} build: . environment: - BASE_URL=${BASE_URL} diff --git a/package-lock.json b/package-lock.json index e64fcedf2f8ae61f7fb04c6094707c6a02256d76..5f39dbee276a51b9372a1517a72c26cd2aa9dab7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2562,6 +2562,16 @@ "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "dev": true }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "optional": true, + "requires": { + "color-convert": "^2.0.1" + } + }, "array-union": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", @@ -2571,6 +2581,34 @@ "array-uniq": "^1.0.1" } }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "optional": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "optional": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "optional": true + }, "dir-glob": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", @@ -2642,6 +2680,13 @@ "slash": "^2.0.0" } }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "optional": true + }, "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", @@ -2679,6 +2724,28 @@ "requires": { "minipass": "^3.1.1" } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "optional": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "vue-loader-v16": { + "version": "npm:vue-loader@16.8.3", + "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.8.3.tgz", + "integrity": "sha512-7vKN45IxsKxe5GcVCbc2qFU5aWzyiLrYJyUuMz4BQLKctCj/fmCa0w6fGiiQ2cLFetNcek1ppGJQDCup0c1hpA==", + "dev": true, + "optional": true, + "requires": { + "chalk": "^4.1.0", + "hash-sum": "^2.0.0", + "loader-utils": "^2.0.0" + } } } }, @@ -3560,8 +3627,7 @@ "bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" }, "bn.js": { "version": "5.2.0", @@ -5206,6 +5272,16 @@ } } }, + "csvtojson": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/csvtojson/-/csvtojson-2.0.10.tgz", + "integrity": "sha512-lUWFxGKyhraKCW8Qghz6Z0f2l/PqB1W3AO0HKJzGIQ5JRSlR651ekJDiGJbBT4sRNNv5ddnSGVEnsxP9XRCVpQ==", + "requires": { + "bluebird": "^3.5.1", + "lodash": "^4.17.3", + "strip-bom": "^2.0.0" + } + }, "cyclist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", @@ -8320,6 +8396,11 @@ "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==" + }, "is-weakref": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", @@ -12303,6 +12384,14 @@ } } }, + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==", + "requires": { + "is-utf8": "^0.2.0" + } + }, "strip-comments": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-1.0.2.tgz", @@ -13301,75 +13390,6 @@ } } }, - "vue-loader-v16": { - "version": "npm:vue-loader@16.8.3", - "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.8.3.tgz", - "integrity": "sha512-7vKN45IxsKxe5GcVCbc2qFU5aWzyiLrYJyUuMz4BQLKctCj/fmCa0w6fGiiQ2cLFetNcek1ppGJQDCup0c1hpA==", - "dev": true, - "optional": true, - "requires": { - "chalk": "^4.1.0", - "hash-sum": "^2.0.0", - "loader-utils": "^2.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "optional": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "optional": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "optional": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "optional": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "optional": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "optional": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, "vue-multiselect": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-2.1.6.tgz", diff --git a/package.json b/package.json index b300d14a7021816265add7350667c3971d2f171f..e28623c0db2883316c4e62b115bf9773f70e4760 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "geocontrib-frontend", - "version": "3.1.0", + "version": "3.3.0", "private": true, "scripts": { "serve": "npm run init-proxy & npm run init-serve", @@ -21,6 +21,7 @@ "@turf/helpers": "^6.5.0", "axios": "^0.21.1", "core-js": "^3.20.2", + "csvtojson": "^2.0.10", "lodash": "^4.17.21", "ol": "6.8.1", "ol-mapbox-style": "^6.8.3", diff --git a/src/App.vue b/src/App.vue index b45820b6b21df309dee4b1368a6b29b23238866e..56520c47cf02a578d7bc5d8d768073dcd549b360 100644 --- a/src/App.vue +++ b/src/App.vue @@ -66,71 +66,4 @@ export default { ]) }, }; -</script> - -<style> -.vertical { - flex-direction: column; - justify-content: center; -} - -.leaflet-container { - background: white !important; -} - -.flex { - display: flex; -} - -/* keep above loader */ -#menu-dropdown { - z-index: 1001; -} - -@media screen and (max-width: 985px) { - .abstract{ - display: none !important; - } -} - -@media screen and (min-width: 560px) { - .mobile { - display: none !important; - } - #app-header { - min-width: 560px; - } - .menu.container { - width: auto !important; - } - .push-right-desktop { - margin-left: auto; - } -} - -@media screen and (max-width: 590px) { - .desktop { - display: none !important; - } - div.dropdown-list { - width: 100vw; - left: -70px !important; /* should be the same than belows */ - } - .menu.container a.header { - width: 70px; - } - .menu.container a.header > img { - margin: 0; - } - #menu-dropdown { - width: calc(100vw - 70px); - justify-content: space-between; - } - #menu-dropdown > span { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } -} - -</style> +</script> \ No newline at end of file diff --git a/src/assets/js/utils.js b/src/assets/js/utils.js index 0680fcdfad82586d6325e4cc501bf9873abf4d7c..85e7a1b11496402eb57d688f13dcd2d187a90665 100644 --- a/src/assets/js/utils.js +++ b/src/assets/js/utils.js @@ -12,30 +12,4 @@ export function fileConvertSizeToMo(aSize){ aSize = Math.abs(parseInt(aSize, 10)); const def = [1024*1024, 'Mo', 1]; return (aSize/def[0]).toFixed(def[2]); -} - -export function csvToJson(csv, delimiter) { - const result = []; - - const allLines = csv.split('\n'); - const headers = allLines[0].split(delimiter).map(el => { - return el.replace('\r', ''); - }); - const [, ...lines] = allLines; - - for (const line of lines) { - if (line) { - const obj = {}; - const currentLine = line.split(delimiter).map(el => { - return el.replace('\r', ''); - }); - - for (let i = 0; i < headers.length; i++) { - obj[headers[i]] = currentLine[i]; - } - - result.push(obj); - } - } - return JSON.parse(JSON.stringify(result)); -} +} \ No newline at end of file diff --git a/src/components/Account/UserProjectsList.vue b/src/components/Account/UserProjectsList.vue index 3d7302a19b701d5d3d7363424e1459f849b69017..4e26440a571bfb10b66cb2d96ac28b4d09b26378 100644 --- a/src/components/Account/UserProjectsList.vue +++ b/src/components/Account/UserProjectsList.vue @@ -47,11 +47,11 @@ <div class="description"> <p>{{ project.description }}</p> </div> - <div class="meta"> + <div class="meta top"> <span class="right floated" > - Projet {{ project.moderation ? "" : "non" }} modéré + <strong>Projet {{ project.moderation ? "" : "non" }} modéré</strong> </span> <span> Niveau d'autorisation requis : {{ project.access_level_pub_feature }} @@ -228,6 +228,12 @@ export default { } } +.description { + p { + text-align: justify; + } +} + @media only screen and (min-width: 767px) { .item-content-wrapper { align-items: flex-start; @@ -235,6 +241,12 @@ export default { .middle.aligned.content { width: 100%; padding: 0 0 0 1.5em; + + .meta.top { + span { + line-height: 1.2em; + } + } } } } @@ -244,8 +256,25 @@ export default { align-items: center; .middle.aligned.content { - width: 70%; + width: 80%; padding: 1.5em 0 0; + + .meta.top { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + + .right.floated { + float: none !important; + margin-left: 0 !important; + margin-bottom: 0.5em; + } + + span { + margin: 0.15em 0; + } + } } } } diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue index 77f4c4ac316b16c6c5882abf6238d3b3775ac210..5b6fe4777530ebbe7898f2d45712b614b841f0a2 100644 --- a/src/components/AppHeader.vue +++ b/src/components/AppHeader.vue @@ -22,8 +22,15 @@ :class="['ui dropdown item', { 'active visible': menuIsOpen }]" @click="menuIsOpen = !menuIsOpen" > - <!-- empty span to occupy space for style if no project --> - <span> + <div + v-if="!isOnline" + class="crossed-out mobile" + > + <i + class="wifi icon" + /> + </div> + <span class="expand-center"> <span v-if="project"> Projet : {{ project.title }} </span> </span> <i @@ -155,6 +162,20 @@ </span> </div> <div class="desktop flex push-right-desktop"> + <div + v-if="!isOnline" + class="item" + > + <span + data-tooltip="Vous êtes hors-ligne, + vos changements pourront être envoyés au serveur au retour de la connexion" + data-position="bottom right" + > + <div class="crossed-out"> + <i class="wifi icon" /> + </div> + </span> + </div> <router-link :is="isOnline ? 'router-link' : 'span'" v-if="user" @@ -207,21 +228,21 @@ </div> </div> </div> - <MessageInfo /> + <MessageInfoList /> </div> </div> </template> <script> import { mapState } from 'vuex'; -import MessageInfo from '@/components/MessageInfo'; +import MessageInfoList from '@/components/MessageInfoList'; export default { name: 'AppHeader', components: { - MessageInfo + MessageInfoList }, data() { @@ -301,6 +322,86 @@ export default { </script> <style lang="less" scoped> +.vertical { + flex-direction: column; + justify-content: center; +} + +.flex { + display: flex; +} + +/* keep above loader */ +#menu-dropdown { + z-index: 1001; +} + +.expand-center { + width: 100%; + text-align: center; +} + +.crossed-out { + position: relative; + padding: .2em; + &::before { + content: ""; + position: absolute; + top: 45%; + left: -8%; + width: 100%; + border-top: 2px solid #ee2e24; + transform: rotate(45deg); + box-shadow: 0px 0px 0px 1px #373636; + border-radius: 3px; + } +} + +@media screen and (max-width: 985px) { + .abstract{ + display: none !important; + } +} + +@media screen and (min-width: 560px) { + .mobile { + display: none !important; + } + #app-header { + min-width: 560px; + } + .menu.container { + width: auto !important; + } + .push-right-desktop { + margin-left: auto; + } +} + +@media screen and (max-width: 590px) { + .desktop { + display: none !important; + } + div.dropdown-list { + width: 100vw; + left: -70px !important; /* should be the same than belows */ + } + .menu.container a.header { + width: 70px; + } + .menu.container a.header > img { + margin: 0; + } + #menu-dropdown { + width: calc(100vw - 70px); + //justify-content: space-between; + } + #menu-dropdown > span { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } +} .menu.container { position: relative; @@ -348,4 +449,4 @@ export default { height: 100% !important; } -</style> +</style> \ No newline at end of file diff --git a/src/components/Feature/Detail/FeatureHeader.vue b/src/components/Feature/Detail/FeatureHeader.vue index d1ab51b94908bffa2dae66f0f15f177802f1c326..c3784191e44c1abd730f914ceadb8f5189003dc2 100644 --- a/src/components/Feature/Detail/FeatureHeader.vue +++ b/src/components/Feature/Detail/FeatureHeader.vue @@ -2,152 +2,163 @@ <div> <h1 class="ui header"> <div class="content"> - <span - v-if="fastEditionMode && form" - class="form ui half-block" - > - <input - id="feature_detail_title_input" - :value="form.title" - type="text" - required - maxlength="128" - name="title" - @blur="updateTitle" - > - </span> - <span v-else> - {{ currentFeature.title || currentFeature.feature_id }} - </span> - - <div class="ui icon right floated compact buttons"> - <router-link - v-if="displayToListButton" - id="feature-detail-to-features-list" - :to="{ - name: 'liste-signalements', - params: { slug: $route.params.slug }, - }" - custom - > - <div class="ui button tiny-margin teal"> - <i class="ui icon arrow right" /> - Retour à la liste des signalements - </div> - </router-link> - <span - v-if="featuresCount" - id="feature-count" - class="ui button tiny-margin basic" - > - {{ parseInt($route.query.offset) + 1 }} sur {{ featuresCount }} - </span> - <button - v-if="queryparams" - id="previous-feature" - :class="['ui button button-hover-green tiny-margin', { disabled: queryparams.previous < 0 }]" - data-tooltip="Voir le précédent signalement" - data-position="bottom center" - @click="toFeature('previous')" + <div class="two-block"> + <div + v-if="fastEditionMode && form && canEditFeature" + class="form ui half-block" > - <i - class="angle left fitted icon" - aria-hidden="true" - /> - </button> - <button - v-if="queryparams" - id="next-feature" - :class="[ - 'ui button button-hover-green tiny-margin', - { disabled: queryparams.next >= featuresCount } - ]" - data-tooltip="Voir le prochain signalement" - data-position="bottom center" - @click="toFeature('next')" + <input + id="feature_detail_title_input" + :value="form.title" + type="text" + required + maxlength="128" + name="title" + @blur="updateTitle" + > + </div> + <div + v-else + class="ellipsis" > - <i - class="angle right fitted icon" - aria-hidden="true" - /> - </button> + {{ currentFeature.title || currentFeature.feature_id }} + </div> - <button - v-if="fastEditionMode && userCanFastEdit" - id="previous-feature" - :class="['ui button button-hover-orange tiny-margin', { disabled: false }]" - data-tooltip="Enregistrer les modifications" - data-position="bottom center" - @click="$store.dispatch('feature/SEND_FEATURE', $route.name)" + <div + id="feature-actions" + class="ui icon compact buttons" > - <i - class="save fitted icon" - aria-hidden="true" - /> - </button> + <div> + <router-link + v-if="displayToListButton" + id="feature-detail-to-features-list" + :to="{ + name: 'liste-signalements', + params: { slug: $route.params.slug }, + }" + custom + > + <div class="ui button tiny-margin teal"> + <i class="ui icon arrow right" /> + Retour à la liste des signalements + </div> + </router-link> + </div> + <div> + <span + v-if="featuresCount" + id="feature-count" + class="ui button tiny-margin basic disabled no-opacity" + > + {{ parseInt($route.query.offset) + 1 }} sur {{ featuresCount }} + </span> + <button + v-if="queryparams" + id="previous-feature" + :class="['ui button button-hover-green tiny-margin', { disabled: queryparams.previous < 0 }]" + data-tooltip="Voir le précédent signalement" + data-position="bottom center" + @click="toFeature('previous')" + > + <i + class="angle left fitted icon" + aria-hidden="true" + /> + </button> + <button + v-if="queryparams" + id="next-feature" + :class="[ + 'ui button button-hover-green tiny-margin', + { disabled: queryparams.next >= featuresCount } + ]" + data-tooltip="Voir le prochain signalement" + data-position="bottom center" + @click="toFeature('next')" + > + <i + class="angle right fitted icon" + aria-hidden="true" + /> + </button> + </div> + <div> + <button + v-if="fastEditionMode && canEditFeature" + id="save-fast-edit" + :class="['ui button button-hover-orange tiny-margin', { disabled: false }]" + data-tooltip="Enregistrer les modifications" + data-position="bottom center" + @click="validateFastEdition" + > + <i + class="save fitted icon" + aria-hidden="true" + /> + </button> - <router-link - v-if="permissions && permissions.can_create_feature" - id="add-feature" - :to="{ - name: 'ajouter-signalement', - params: { - slug_type_signal: $route.params.slug_type_signal || featureType.slug, - }, - }" - class="ui button button-hover-green tiny-margin" - data-tooltip="Ajouter un signalement" - data-position="bottom center" - > - <i - class="plus icon" - aria-hidden="true" - /> - </router-link> + <router-link + v-if="permissions && permissions.can_create_feature" + id="add-feature" + :to="{ + name: 'ajouter-signalement', + params: { + slug_type_signal: $route.params.slug_type_signal || featureType.slug, + }, + }" + class="ui button button-hover-green tiny-margin" + data-tooltip="Ajouter un signalement" + data-position="bottom center" + > + <i + class="plus icon" + aria-hidden="true" + /> + </router-link> - <router-link - v-if="slugSignal && - ((permissions && permissions.can_update_feature) || - isFeatureCreator || - isModerator) - " - id="edit-feature" - :to="{ - name: 'editer-signalement', - params: { - slug_signal: slugSignal, - slug_type_signal: $route.params.slug_type_signal || featureType.slug, - }, - query: $route.query - }" - class="ui button button-hover-orange tiny-margin" - data-tooltip="Éditer le signalement" - data-position="bottom center" - > - <i - class="inverted grey pencil alternate icon" - aria-hidden="true" - /> - </router-link> + <router-link + v-if="slugSignal && canEditFeature" + id="edit-feature" + :to="{ + name: 'editer-signalement', + params: { + slug_signal: slugSignal, + slug_type_signal: $route.params.slug_type_signal || featureType.slug, + }, + query: $route.query + }" + class="ui button button-hover-orange tiny-margin" + data-tooltip="Éditer le signalement" + data-position="bottom center" + > + <i + class="inverted grey pencil alternate icon" + aria-hidden="true" + /> + </router-link> - <a - v-if="((permissions && permissions.can_update_feature) || isFeatureCreator) && isOnline" - id="currentFeature-delete" - class="ui button button-hover-red tiny-margin" - data-tooltip="Supprimer le signalement" - data-position="bottom right" - @click="$emit('setIsCancelling')" - > - <i - class="inverted grey trash alternate icon" - aria-hidden="true" - /> - </a> + <a + v-if="canDeleteFeature && isOnline" + id="currentFeature-delete" + class="ui button button-hover-red tiny-margin" + data-tooltip="Supprimer le signalement" + data-position="bottom right" + @click="$emit('setIsDeleting')" + > + <i + class="inverted grey trash alternate icon" + aria-hidden="true" + /> + </a> + </div> + </div> </div> - <div class="ui hidden divider" /> + + <!-- <div class="ui hidden divider" /> --> + <div class="sub header prewrap"> <span - v-if="fastEditionMode && form" + v-if="fastEditionMode && canEditFeature && form" class="form ui half-block" > <textarea @@ -196,12 +207,23 @@ export default { type: Boolean, default: false, }, + isFeatureCreator: { + type: Boolean, + default: false, + }, + canEditFeature: { + type: Boolean, + default: false, + }, + canDeleteFeature: { + type: Boolean, + default: false, + }, }, computed: { ...mapState([ 'user', - 'USER_LEVEL_PROJECTS', 'isOnline', ]), ...mapState('feature', [ @@ -212,24 +234,6 @@ export default { 'permissions', ]), - isFeatureCreator() { - if (this.currentFeature && this.user) { - return this.currentFeature.creator === this.user.id; - } - return false; - }, - - isModerator() { - return this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.$route.params.slug] === 'Modérateur'; - }, - - userCanFastEdit() { - const superiorRoles = ['contributor', 'super_contributor', 'moderator', 'admin']; - return this.USER_LEVEL_PROJECTS && - superiorRoles.includes(this.USER_LEVEL_PROJECTS[this.$route.params.slug]) || - this.user.is_superuser; - }, - queryparams() { return this.$route.query.offset >= 0 ? { previous: parseInt(this.$route.query.offset) - 1, @@ -258,26 +262,45 @@ export default { updateDescription(e) { this.$store.commit('feature/UPDATE_FORM_FIELD', { name: 'description', value: e.target.value }); + }, + + validateFastEdition() { + this.$store.dispatch('feature/SEND_FEATURE', this.$route.name) + .then(() => this.$emit('updateEvents')); } } }; </script> <style> -#next-feature { - margin-right: .5rem !important; -} #feature-detail-to-features-list { line-height: 0; margin-right: 5px; } -.half-block { - display: inline-block; - width: 50%; -} #feature_detail_title_input { font-weight: bold; font-size: 2em; padding: .25em; } +.two-block { + display: flex; + justify-content: space-between; + margin-bottom: .5em; +} +#feature-actions > div { + margin-left: .5rem; +} +#feature-actions .no-opacity { + opacity: 1 !important; /* overide disabled low opacity to customize button style */ +} + +@media screen and (max-width: 700px) { + .two-block { + flex-direction: column-reverse; + } + #feature-actions.ui.buttons { + flex-direction: column; + align-items: flex-end; + } +} </style> \ No newline at end of file diff --git a/src/components/Feature/Detail/FeatureTable.vue b/src/components/Feature/Detail/FeatureTable.vue index ef36f677aa2df6d4aa818ac0a5f065e6a020d132..b0e4a8b8e963be24bac5a544dd6464c03d244929 100644 --- a/src/components/Feature/Detail/FeatureTable.vue +++ b/src/components/Feature/Detail/FeatureTable.vue @@ -23,7 +23,8 @@ <td> <strong class="ui form"> <span - v-if="fastEditionMode && extra_forms.length > 0" + v-if="fastEditionMode && canEditFeature && extra_forms.length > 0" + :id="field.label" > <FeatureExtraForm :field="getExtraForm(field)" @@ -60,7 +61,7 @@ aria-hidden="true" /> <FeatureEditStatusField - v-if="fastEditionMode && form" + v-if="fastEditionMode && canEditFeature && form" :status="form.status.value" class="inline" /> @@ -161,7 +162,11 @@ export default { fastEditionMode: { type: Boolean, default: false, - } + }, + canEditFeature: { + type: Boolean, + default: false, + }, }, computed: { diff --git a/src/components/Feature/Edit/FeatureExtraForm.vue b/src/components/Feature/Edit/FeatureExtraForm.vue index 778c46539df03dbc82510be65fb9577478e5a3d4..3ae38ec519c52e664918ec5c33b58702c39cb126 100644 --- a/src/components/Feature/Edit/FeatureExtraForm.vue +++ b/src/components/Feature/Edit/FeatureExtraForm.vue @@ -1,10 +1,9 @@ <template> - <div - v-if="field && field.field_type === 'char'" - > + <div v-if="field && field.field_type === 'char'"> <label - v-if="$route.name !== 'details-signalement'" + v-if="displayLabels" :for="field.name" + :class="{ required: field.is_mandatory }" > {{ field.label }} </label> @@ -13,16 +12,25 @@ :value="field.value" type="text" :name="field.name" + :required="field.is_mandatory" @blur="updateStore_extra_form" > + <ul + v-if="field.is_mandatory && error" + :id="`errorlist-extra-form-${field.name}`" + class="errorlist" + > + <li> + {{ error }} + </li> + </ul> </div> - <div - v-else-if="field && field.field_type === 'list'" - > + <div v-else-if="field && field.field_type === 'list'"> <label - v-if="$route.name !== 'details-signalement'" + v-if="displayLabels" :for="field.name" + :class="{ required: field.is_mandatory }" > {{ field.label }} </label> @@ -30,14 +38,23 @@ :options="field.options" :selected="selected_extra_form_list" :selection.sync="selected_extra_form_list" + :required="field.is_mandatory" /> + <ul + v-if="field.is_mandatory && error" + :id="`errorlist-extra-form-${field.name}`" + class="errorlist" + > + <li> + {{ error }} + </li> + </ul> </div> - <div - v-else-if="field && field.field_type === 'integer'" - > + <div v-else-if="field && field.field_type === 'integer'"> <label - v-if="$route.name !== 'details-signalement'" + v-if="displayLabels" :for="field.name" + :class="{ required: field.is_mandatory }" > {{ field.label }} </label> @@ -48,13 +65,21 @@ :value="field.value" type="number" :name="field.name" + :required="field.is_mandatory" @change="updateStore_extra_form" > </div> + <ul + v-if="field.is_mandatory && error" + :id="`errorlist-extra-form-${field.name}`" + class="errorlist" + > + <li> + {{ error }} + </li> + </ul> </div> - <div - v-else-if="field && field.field_type === 'boolean'" - > + <div v-else-if="field && field.field_type === 'boolean'"> <div class="ui checkbox"> <input :id="field.name" @@ -64,16 +89,15 @@ @change="updateStore_extra_form" > <label :for="field.name"> - {{ $route.name !== 'details-signalement' ? field.label : '' }} + {{ displayLabels ? field.label : '' }} </label> </div> </div> - <div - v-else-if="field && field.field_type === 'date'" - > + <div v-else-if="field && field.field_type === 'date'"> <label - v-if="$route.name !== 'details-signalement'" + v-if="displayLabels" :for="field.name" + :class="{ required: field.is_mandatory }" > {{ field.label }} </label> @@ -82,15 +106,24 @@ :value="field.value" type="date" :name="field.name" + :required="field.is_mandatory" @blur="updateStore_extra_form" > + <ul + v-if="field.is_mandatory && error" + :id="`errorlist-extra-form-${field.name}`" + class="errorlist" + > + <li> + {{ error }} + </li> + </ul> </div> - <div - v-else-if="field && field.field_type === 'decimal'" - > + <div v-else-if="field && field.field_type === 'decimal'"> <label - v-if="$route.name !== 'details-signalement'" + v-if="displayLabels" :for="field.name" + :class="{ required: field.is_mandatory }" > {{ field.label }} </label> @@ -101,25 +134,44 @@ type="number" step=".01" :name="field.name" + :required="field.is_mandatory" @change="updateStore_extra_form" > </div> + <ul + v-if="field.is_mandatory && error" + :id="`errorlist-extra-form-${field.name}`" + class="errorlist" + > + <li> + {{ error }} + </li> + </ul> </div> - <div - v-else-if="field && field.field_type === 'text'" - > + <div v-else-if="field && field.field_type === 'text'"> <label - v-if="$route.name !== 'details-signalement'" + v-if="displayLabels" :for="field.name" + :class="{ required: field.is_mandatory }" > {{ field.label }} </label> <textarea :value="field.value" :name="field.name" + :required="field.is_mandatory" rows="3" @blur="updateStore_extra_form" /> + <ul + v-if="field.is_mandatory && error" + :id="`errorlist-extra-form-${field.name}`" + class="errorlist" + > + <li> + {{ error }} + </li> + </ul> </div> </template> @@ -140,6 +192,12 @@ export default { } }, + data() { + return { + error: null + }; + }, + computed: { selected_extra_form_list: { get() { @@ -152,6 +210,18 @@ export default { this.$store.commit('feature/UPDATE_EXTRA_FORM', newExtraForm); }, }, + + displayLabels() { + return this.$route.name === 'editer-signalement' || this.$route.name === 'ajouter-signalement' || this.$route.name === 'editer-attribut-signalement'; + } + }, + + watch: { + 'field.value': function(newValue) { + if (newValue) { + this.error = null; + } + } }, methods: { @@ -164,6 +234,26 @@ export default { } this.$store.commit('feature/UPDATE_EXTRA_FORM', newExtraForm); }, + + checkForm() { + let isValid = true; + if (this.field.is_mandatory && !this.field.value) { + isValid = false; + this.error = 'Ce champ est obligatoire'; + } else { + this.error = null; + } + return isValid; + } }, }; </script> + +<style lang="less" scoped> + +label.required:after { + content: ' *'; + color: rgb(209, 0, 0); +} + +</style> diff --git a/src/components/Feature/FeatureEditStatusField.vue b/src/components/Feature/FeatureEditStatusField.vue index 806182c3a835cd04a9b8a3d36c3531b7bb7b94bb..3c4133b5c7ff9b8a459ebca9733bd74268604c98 100644 --- a/src/components/Feature/FeatureEditStatusField.vue +++ b/src/components/Feature/FeatureEditStatusField.vue @@ -1,5 +1,8 @@ <template> - <div class="field"> + <div + id="status" + class="field" + > <Dropdown v-if="selectedStatus" :options="allowedStatusChoices" @@ -56,7 +59,7 @@ export default { allowedStatusChoices() { if (this.project && this.currentFeature && this.user) { const isModerate = this.project.moderation; - const userStatus = this.USER_LEVEL_PROJECTS[this.project.slug]; + const userStatus = this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.project.slug]; const isOwnFeature = this.currentFeature.creator === this.user.id; //* si le contributeur est l'auteur du signalement return allowedStatus2change(this.user, isModerate, userStatus, isOwnFeature, /* this.currentRouteName */); } diff --git a/src/components/Feature/FeatureListMassToggle.vue b/src/components/Feature/FeatureListMassToggle.vue deleted file mode 100644 index ff4b1da6ff6616933727a93e6595b10238a7bfd8..0000000000000000000000000000000000000000 --- a/src/components/Feature/FeatureListMassToggle.vue +++ /dev/null @@ -1,59 +0,0 @@ -<template> - <div - class="switch-buttons pointer" - :data-tooltip="`Passer en mode ${massMode === 'modify' ? 'suppression':'édition'}`" - @click="switchMode" - > - <div> - <i - :class="['icon pencil', {disabled: massMode !== 'modify'}]" - aria-hidden="true" - /> - </div> - <span class="grey">| </span> - <div> - <i - :class="['icon trash', {disabled: massMode !== 'delete'}]" - aria-hidden="true" - /> - </div> - </div> -</template> - -<script> -import { mapMutations, mapState } from 'vuex'; - -export default { - name: 'FeatureListMassToggle', - - computed: { - ...mapState('feature', ['massMode']) - }, - - methods: { - ...mapMutations('feature', [ - 'TOGGLE_MASS_MODE', - 'UPDATE_CHECKED_FEATURES', - 'UPDATE_CLICKED_FEATURES']), - - switchMode() { - this.TOGGLE_MASS_MODE(this.massMode === 'modify' ? 'delete' : 'modify'); - this.UPDATE_CLICKED_FEATURES([]); - this.UPDATE_CHECKED_FEATURES([]); - } - }, -}; -</script> - -<style scoped> -.switch-buttons { - display: flex; - justify-content: center; - align-items: baseline; -} - -.grey { - color: #bbbbbb; -} - -</style> diff --git a/src/components/FeatureType/FeatureTypeCustomForm.vue b/src/components/FeatureType/FeatureTypeCustomForm.vue index d064ee6ca7a27c946f98d3543e65621a946da83b..17dfbc157fb94e7b51a79c298153911a453241be 100644 --- a/src/components/FeatureType/FeatureTypeCustomForm.vue +++ b/src/components/FeatureType/FeatureTypeCustomForm.vue @@ -3,19 +3,34 @@ :id="`custom_form-${form.position.value}`" class="ui teal segment pers-field" > - <h4> - Champ personnalisé - <button - class="ui small compact right floated icon button remove-field" - type="button" - @click="removeCustomForm()" - > - <i - class="ui times icon" - aria-hidden="true" - /> - </button> - </h4> + <div class="custom-field-header"> + <h4> + Champ personnalisé + </h4> + <div class="top-right"> + <div + v-if="(form.label.value || form.name.value) && selectedFieldType !== 'Booléen'" + class="ui checkbox" + > + <input + type="checkbox" + name="mandatory-custom-field" + @change="setIsFieldMandatory($event)" + > + <label>Champ obligatoire</label> + </div> + <button + class="ui small compact right floated icon button remove-field" + type="button" + @click="removeCustomForm()" + > + <i + class="ui times icon" + aria-hidden="true" + /> + </button> + </div> + </div> <div class="visible-fields"> <div class="two fields"> <div class="required field"> @@ -193,6 +208,7 @@ export default { ], form: { dataKey: 0, + isFieldMandatory: false, label: { errors: [], id_for_label: 'label', @@ -292,12 +308,28 @@ export default { }, }, + watch: { + 'form.isFieldMandatory': { + deep: true, + handler(newValue) { + console.log(newValue); + } + } + }, + mounted() { //* add datas from store to state to avoid mutating directly store with v-model (not good practice), could have used computed with getter and setter as well this.fillCustomFormData(this.customForm); }, methods: { + + setIsFieldMandatory(e) { + this.form.isFieldMandatory = e.target.checked; + this.updateStore(); + console.log(this.form.isFieldMandatory); + }, + hasDuplicateOptions() { this.form.options.errors = []; const isDup = @@ -312,7 +344,7 @@ export default { fillCustomFormData(customFormData) { for (const el in customFormData) { - if (el && this.form[el] && customFormData[el]) { + if (el && this.form[el] && customFormData[el] !== undefined && customFormData[el] !== null) { //* check if is an object, because data from api is a string, while import from django is an object this.form[el].value = customFormData[el].value ? customFormData[el].value @@ -332,6 +364,7 @@ export default { updateStore() { const data = { dataKey: this.customForm.dataKey, + isMandatory: this.form.isFieldMandatory, label: this.form.label.value, name: this.form.name.value, position: this.form.position.value, @@ -365,54 +398,66 @@ export default { return occurences.length === 1; }, - checkFilledOptions() { - if (this.form.field_type.value === 'list') { - if (this.form.options.value.length < 1) { - return false; - } else if ( - this.form.options.value.length === 1 && - this.form.options.value[0] === '' - ) { - return false; - } - } - return true; + checkListOptions() { + if (this.form.field_type.value !== 'list') return true; + return this.form.options.value.length >= 2 && !this.form.options.value.includes(''); }, checkCustomForm() { this.form.label.errors = []; this.form.name.errors = []; this.form.options.errors = []; + let isValid = true; if (!this.form.label.value) { //* vérifier que le label est renseigné this.form.label.errors = ['Veuillez compléter ce champ.']; - return false; + isValid = false; } else if (!this.form.name.value) { //* vérifier que le nom est renseigné this.form.name.errors = ['Veuillez compléter ce champ.']; - return false; + isValid = false; } else if (!this.hasRegularCharacters(this.form.name.value)) { //* vérifier qu'il n'y a pas de caractères spéciaux this.form.name.errors = [ 'Veuillez utiliser seulement les caratères autorisés.', ]; - return false; + isValid = false; } else if (!this.checkUniqueName()) { //* vérifier si les noms sont pas dupliqués this.form.name.errors = [ 'Les champs personnalisés ne peuvent pas avoir des noms similaires.', ]; - return false; - } else if (!this.checkFilledOptions()) { + isValid = false; + } else if (!this.checkListOptions()) { //* s'il s'agit d'un type liste, vérifier que le champ option est bien renseigné this.form.options.errors = ['Veuillez compléter ce champ.']; - return false; + isValid = false; } else if (this.hasDuplicateOptions()) { //* pour le cas d'options dupliqués - return false; + isValid = false; } - return true; + if (!isValid) document.getElementById(`custom_form-${this.form.position.value}`).scrollIntoView({ block: 'start', inline: 'nearest' }); + return isValid; }, }, }; </script> + +<style lang="less" scoped> + +.custom-field-header { + display: flex; + align-items: center; + justify-content: space-between; + + .top-right { + display: flex; + align-items: center; + + .checkbox { + margin-right: 5rem; + } + } +} + +</style> diff --git a/src/components/ImportTask.vue b/src/components/ImportTask.vue index 026b7cbfe30e8b8ffcc4e7354b33642f2a7b13ec..c5792c73764138b2c6d00795e1109a1222dda73f 100644 --- a/src/components/ImportTask.vue +++ b/src/components/ImportTask.vue @@ -26,7 +26,9 @@ <td> <h4 class="ui header align-right"> <div :data-tooltip="importFile.geojson_file_name"> - {{ importFile.geojson_file_name | subString }} + <div class="ellipsis"> + {{ importFile.geojson_file_name | subString }} + </div> <div class="sub header"> ajouté le {{ importFile.created_on | setDate }} </div> @@ -234,5 +236,9 @@ and also iPads specifically. .margin-left { margin-left: 94%; } + h4.ui.header { + margin-left: 60px; + white-space: nowrap; + } } </style> diff --git a/src/components/MessageInfo.vue b/src/components/MessageInfo.vue index 3996dc9de95d967afa14deee2d063ddf3dfeddf0..4fe49097eaeefdfd6129215601c6921ce2e80111 100644 --- a/src/components/MessageInfo.vue +++ b/src/components/MessageInfo.vue @@ -1,36 +1,30 @@ <template> - <transition name="fadeDownUp"> - <div - v-if="messages && messages.length > 0" - class="row over-content" - > - <div class="fourteen wide column"> - <div - v-for="(message, index) in messages" - :key="'message-' + index" - :class="['ui', message.level ? message.level : 'info', 'message']" - > + <li + :ref="'message-' + message.counter" + :class="['list-container', { show }]" + > + <div :class="['list-item', { show}]"> + <div :class="['ui', message.level ? message.level : 'info', 'message']"> + <i + class="close icon" + aria-hidden="true" + @click="removeListItem" + /> + <div class="header"> <i - class="close icon" + class="info circle icon" aria-hidden="true" - @click="DISCARD_MESSAGE(message)" /> - <div class="header"> - <i - class="info circle icon" - aria-hidden="true" - /> - Informations - </div> - <ul class="list"> - {{ - message.comment - }} - </ul> + Informations </div> + <ul class="list"> + {{ + message.comment + }} + </ul> </div> </div> - </transition> + </li> </template> <script> @@ -39,54 +33,100 @@ import { mapState, mapMutations } from 'vuex'; export default { name: 'MessageInfo', + props: { + message: { + type: Object, + default: () => {}, + }, + }, + + data() { + return { + listMessages: [], + show: false, + }; + }, + computed: { ...mapState(['messages']), }, + mounted() { + setTimeout(() => { + this.show = true; + }, 15); + }, + methods: { ...mapMutations(['DISCARD_MESSAGE']), + + removeListItem(){ + const container = this.$refs['message-' + this.message.counter]; + container.ontransitionend = () => { + this.DISCARD_MESSAGE(this.message.counter); + }; + this.show = false; + }, }, }; </script> -<style> -.row.over-content { - position: absolute; /* to display message info over page content */ - z-index: 99; - opacity: 0.95; - width: calc(100% - 4em); /* 4em is #content left + right paddings */ - top: calc(61px + 1em); /* 61px is #app-header height */ - right: 2em; /* 2em is #content left paddings */ +<style scoped> +.list-container{ + list-style: none; + width: 100%; + height: 0; + position: relative; + cursor: pointer; + overflow: hidden; + transition: all 0.6s ease-out; } - -.fadeDownUp-enter-active { - animation: fadeInDown .5s; +.list-container.show{ + height: 6em; } -.fadeDownUp-leave-active { - animation: fadeOutUp .5s; +.list-container.show:not(:first-child){ + margin-top: 10px; } -@keyframes fadeOutUp { - 0% { - opacity: 1; - } - - 100% { +.list-container .list-item{ + padding: .5rem 0; + width: 100%; + position: absolute; opacity: 0; - transform: translate3d(0, -100%, 0); - } + top: 0; + left: 0; + transition: all 0.6s ease-out; +} +.list-container .list-item.show{ + opacity: 1; } -@keyframes fadeInDown { - from { - opacity: 0; - transform: translate3d(0, -100%, 0); - } +ul.list{ + overflow: scroll; + height: 2.2em; + margin-bottom: .5em !important; +} - to { - opacity: 1; - transform: translate3d(0, 0, 0); - } +.ui.message { + overflow: hidden; + padding-bottom: 0 !important; +} +.ui.message::after { + content: ""; + position: absolute; + bottom: 0; + left: 1em; + right: 0; + width: calc(100% - 2em); +} +.ui.info.message::after { + box-shadow: 0px -8px 5px 3px rgb(248, 255, 255); +} +.ui.positive.message::after { + box-shadow: 0px -8px 5px 3px rgb(248, 255, 255); +} +.ui.negative.message::after { + box-shadow: 0px -8px 5px 3px rgb(248, 255, 255); } .ui.message > .close.icon { diff --git a/src/components/MessageInfoList.vue b/src/components/MessageInfoList.vue new file mode 100644 index 0000000000000000000000000000000000000000..5974a28d54314d12975ceb58e0cf6eee917aa603 --- /dev/null +++ b/src/components/MessageInfoList.vue @@ -0,0 +1,50 @@ +<template> + <div + v-if="messages && messages.length > 0" + class="row over-content" + > + <div class="fourteen wide column"> + <ul + class="message-list" + aria-live="assertive" + > + <MessageInfo + v-for="message in messages" + :key="'message-' + message.counter" + :message="message" + /> + </ul> + </div> + </div> +</template> + +<script> +import { mapState } from 'vuex'; +import MessageInfo from '@/components/MessageInfo'; + +export default { + name: 'MessageInfoList', + + components: { + MessageInfo, + }, + computed: { + ...mapState(['messages']), + }, +}; +</script> + +<style scoped> + +.row.over-content { + position: absolute; /* to display message info over page content */ + z-index: 99; + opacity: 0.95; + width: calc(100% - 4em); /* 4em is #content left + right paddings */ + top: calc(61px + 1em); /* 61px is #app-header height */ + right: 2em; /* 2em is #content left paddings */ +} +.message-list{ + list-style: none; +} +</style> \ No newline at end of file diff --git a/src/components/Project/Detail/ProjectFeatureTypes.vue b/src/components/Project/Detail/ProjectFeatureTypes.vue index 7cb7968533a7c1f3a17d9d919486a52e05b3075b..f98acb30e2b0eb3b59d8be7f9adca67a805e03a8 100644 --- a/src/components/Project/Detail/ProjectFeatureTypes.vue +++ b/src/components/Project/Detail/ProjectFeatureTypes.vue @@ -426,10 +426,11 @@ </template> <script> +import { csv } from 'csvtojson'; import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'; -import { fileConvertSizeToMo, csvToJson } from '@/assets/js/utils'; +import { fileConvertSizeToMo } from '@/assets/js/utils'; import FeatureTypeLink from '@/components/FeatureType/FeatureTypeLink'; export default { @@ -633,7 +634,6 @@ export default { try { fr.readAsText(this.csvFileToImport); fr.onloadend = () => { - // Find csv delimiter const commaDelimited = fr.result.split('\n')[0].includes(','); const semicolonDelimited = fr.result.split('\n')[0].includes(';'); @@ -644,19 +644,16 @@ export default { this.featureTypeImporting = false; return; } - // Check if file contains 'lat' and 'long' fields - const headersLine = - fr.result - .split('\n')[0] - .split(delimiter) - .map(el => { - return el.replace('\r', ''); - }) - .filter(el => { - return el === 'lat' || el === 'lon'; - }); - + const headers = fr.result + .split('\n')[0] + .split(delimiter) + .map(el => { + return el.replace('\r', ''); + }); + const headersCoord = headers.filter(el => { + return el === 'lat' || el === 'lon'; + }); // Look for 2 decimal fields in first line of csv // corresponding to lon and lat const sampleLine = @@ -667,9 +664,13 @@ export default { return !isNaN(el) && el.indexOf('.') !== -1; }) .filter(Boolean); - if (sampleLine.length > 1 && headersLine.length === 2) { + if (sampleLine.length > 1 && headersCoord.length === 2) { this.csvError = null; - this.csvImport = csvToJson(fr.result, delimiter); + csv() + .fromString(fr.result) + .then((jsonObj)=>{ + this.csvImport = jsonObj; + }); this.featureTypeImporting = false; //* stock filename to import features afterward this.SET_FILE_TO_IMPORT(this.csvFileToImport); diff --git a/src/components/Project/FeaturesListAndMap/FeatureListTable.vue b/src/components/Project/FeaturesListAndMap/FeatureListTable.vue index fff79a991ecb1479945455bb91747a99b68c5ed4..a522fc895075e010c2c9e902d4cc6e395bf1c636 100644 --- a/src/components/Project/FeaturesListAndMap/FeatureListTable.vue +++ b/src/components/Project/FeaturesListAndMap/FeatureListTable.vue @@ -1,8 +1,53 @@ <template> <div> - <div class="table-mobile-buttons left-align"> - <FeatureListMassToggle /> + <div class="ui form"> + <div + v-if="isOnline" + class="inline fields" + > + <label + data-tooltip="Choisir un type de sélection de signalements pour effectuer une action" + data-position="bottom left" + >Mode de sélection :</label> + <div class="field"> + <div class="ui radio checkbox"> + <input + id="edit-status" + v-model="mode" + type="radio" + name="mode" + value="edit-status" + > + <label for="edit-status">Édition de statut</label> + </div> + </div> + <div class="field"> + <div class="ui radio checkbox"> + <input + id="edit-attributes" + v-model="mode" + type="radio" + name="mode" + value="edit-attributes" + > + <label for="edit-attributes">Édition d'attribut</label> + </div> + </div> + <div class="field"> + <div class="ui radio checkbox"> + <input + id="delete-features" + v-model="mode" + type="radio" + name="mode" + value="delete-features" + > + <label for="delete-features">Suppression de signalement</label> + </div> + </div> + </div> </div> + <div data-tab="list" class="dataTables_wrapper no-footer" @@ -15,10 +60,11 @@ <thead> <tr> <th + v-if="isOnline" scope="col" class="dt-center" > - <FeatureListMassToggle /> + Sélection </th> <th @@ -26,7 +72,7 @@ class="dt-center" > <div - class="pointer" + :class="isOnline ? 'pointer' : 'disabled'" @click="changeSort('status')" > Statut @@ -45,7 +91,7 @@ class="dt-center" > <div - class="pointer" + :class="isOnline ? 'pointer' : 'disabled'" @click="changeSort('feature_type')" > Type @@ -64,7 +110,7 @@ class="dt-center" > <div - class="pointer" + :class="isOnline ? 'pointer' : 'disabled'" @click="changeSort('title')" > Nom @@ -83,7 +129,7 @@ class="dt-center" > <div - class="pointer" + :class="isOnline ? 'pointer' : 'disabled'" @click="changeSort('updated_on')" > Dernière modification @@ -103,7 +149,7 @@ class="dt-center" > <div - class="pointer" + :class="isOnline ? 'pointer' : 'disabled'" @click="changeSort('display_creator')" > Auteur @@ -123,7 +169,7 @@ class="dt-center" > <div - class="pointer" + :class="isOnline ? 'pointer' : 'disabled'" @click="changeSort('display_last_editor')" > Dernier éditeur @@ -144,7 +190,11 @@ v-for="(feature, index) in paginatedFeatures" :key="index" > - <td class="dt-center"> + <td + v-if="isOnline" + id="select" + class="dt-center" + > <div :class="['ui checkbox', {disabled: !checkRights(feature)}]" > @@ -161,7 +211,10 @@ </div> </td> - <td class="dt-center"> + <td + id="status" + class="dt-center" + > <div v-if="feature.status === 'archived'" data-tooltip="Archivé" @@ -199,7 +252,10 @@ /> </div> </td> - <td class="dt-center"> + <td + id="type" + class="dt-center" + > <router-link :to="{ name: 'details-type-signalement', @@ -207,11 +263,15 @@ feature_type_slug: feature.feature_type.slug, }, }" + class="ellipsis space-left" > {{ feature.feature_type.title }} </router-link> </td> - <td class="dt-center"> + <td + id="name" + class="dt-center" + > <router-link :to="{ name: 'details-signalement-filtre', @@ -220,21 +280,27 @@ }, query: { ...queryparams, offset: queryparams.offset + index } }" + class="ellipsis space-left" > {{ feature.title || feature.feature_id }} </router-link> </td> - <td class="dt-center"> + <td + id="update" + class="dt-center" + > {{ feature.updated_on | formatDate }} </td> <td v-if="user" + id="author" class="dt-center" > {{ feature.display_creator || ' ---- ' }} </td> <td v-if="user" + id="last_editor" class="dt-center" > {{ feature.display_last_editor || ' ---- ' }} @@ -266,7 +332,7 @@ sur {{ featuresCount }} éléments </div> <div - v-if="pageNumbers.length > 1" + v-if="pageNumbers.length > 1 && isOnline" id="table-features_paginate" class="dataTables_paginate paging_simple_numbers" > @@ -335,7 +401,6 @@ <script> import { mapState, mapGetters, mapMutations } from 'vuex'; -import FeatureListMassToggle from '@/components/Feature/FeatureListMassToggle'; import { formatStringDate } from '@/utils'; export default { @@ -347,10 +412,13 @@ export default { }, }, - components: { - FeatureListMassToggle, + beforeRouteLeave (to, from, next) { + if (to.name !== 'editer-attribut-signalement') { + this.UPDATE_CHECKED_FEATURES([]); // empty if not needed anymore + } + next(); // continue navigation }, - + props: { paginatedFeatures: { type: Array, @@ -379,15 +447,34 @@ export default { queryparams: { type: Object, default: null, - } + }, + editAttributesFeatureType: { + type: String, + default: null, + }, }, computed: { ...mapGetters(['permissions']), - ...mapState(['user', 'USER_LEVEL_PROJECTS']), + ...mapState([ + 'user', + 'USER_LEVEL_PROJECTS', + 'isOnline' + ]), ...mapState('projects', ['project']), ...mapState('feature', ['clickedFeatures', 'massMode']), + mode: { + get() { + return this.massMode; + }, + set(newMode) { + this.TOGGLE_MASS_MODE(newMode); + this.UPDATE_CLICKED_FEATURES([]); + this.UPDATE_CHECKED_FEATURES([]); + }, + }, + userStatus() { return this.USER_LEVEL_PROJECTS[this.$route.params.slug]; }, @@ -432,17 +519,21 @@ export default { }, }, - destroyed() { - this.UPDATE_CHECKED_FEATURES([]); - }, - methods: { ...mapMutations('feature', [ 'UPDATE_CLICKED_FEATURES', 'UPDATE_CHECKED_FEATURES', + 'TOGGLE_MASS_MODE', ]), storeClickedFeature(feature) { + if (this.massMode === 'edit-attributes') { // if modifying attributes + if (this.checkedFeatures.length === 0) { // store feature type slug to restrict selection for next selected features + this.$emit('update:editAttributesFeatureType', feature.feature_type.slug); + } else if (this.checkedFeatures.length === 1 && this.checkedFeatures[0] === feature.feature_id) { + this.$emit('update:editAttributesFeatureType', null); // delete feature type slug if last checkedFeatures is unselected, to allow other types selection + } + } this.UPDATE_CLICKED_FEATURES([ ...this.clickedFeatures, { feature_id: feature.feature_id, feature_type: feature.feature_type.slug } @@ -465,7 +556,13 @@ export default { Contributeur : ['draft', 'pending', 'published'], }; - if (this.userStatus === 'Contributeur' && feature.display_creator !== this.user.username) { + if (this.checkedFeatures.length > 0 && // check if selection should be restricted to a specific feature type, for attributes modification + feature.feature_type.slug !== this.editAttributesFeatureType && + this.massMode === 'edit-attributes') { + return false; + } else if (this.user.is_superuser) { + return true; + } else if (this.userStatus === 'Contributeur' && feature.display_creator !== `${this.user.first_name} ${this.user.last_name}`) { return false; } else if (permissions[this.userStatus]) { return permissions[this.userStatus].includes(feature.status); @@ -475,20 +572,13 @@ export default { }, checkRights(feature) { - switch (this.massMode) { - case 'modify': + if (this.massMode.includes('edit')) { return this.canEditFeature(feature); - case 'delete': + } else if (this.massMode === 'delete-features') { return this.canDeleteFeature(feature); } }, - switchMode() { - this.$emit('update:mode', this.mode === 'modify' ? 'delete' : 'modify'); - this.UPDATE_CLICKED_FEATURES([]); - this.$store.commit('feature/UPDATE_CHECKED_FEATURES', []); - }, - isSortedAsc(column) { return this.sort.column === column && this.sort.ascending; }, @@ -497,6 +587,7 @@ export default { }, changeSort(column) { + if (!this.isOnline) return; if (this.sort.column === column) { //changer only order this.$emit('update:sort', { @@ -612,6 +703,12 @@ i.icon.sort:not(.down):not(.up) { .table-mobile-buttons { margin-bottom: 1em; } + +/* increase contrast between available checkboxes and disabled ones */ +#table-features .ui.disabled.checkbox label::before { + background-color: #fbf5f5;; +} + @media only screen and (min-width: 761px) { .table-mobile-buttons { display: none !important; @@ -688,25 +785,25 @@ and also iPads specifically. /* Label the data */ - td:nth-of-type(1):before { + td#select:before { content: ""; } - td:nth-of-type(2):before { + td#status:before { content: "Statut"; } - td:nth-of-type(3):before { + td#type:before { content: "Type"; } - td:nth-of-type(4):before { + td#name:before { content: "Nom"; } - td:nth-of-type(5):before { + td#update:before { content: "Dernière modification"; } - td:nth-of-type(6):before { + td#author:before { content: "Auteur"; } - td:nth-of-type(7):before { + td#last_editor:before { content: "Dernier éditeur"; } @@ -725,6 +822,11 @@ and also iPads specifically. text-align: center; margin: .5em 0; } + .space-left { + max-width: 100%; + display: inline-block; + padding-left: 3em; + } } @media only screen and (max-width: 410px) { .ui.table tr td { diff --git a/src/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters.vue b/src/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters.vue index a2e8029578b979abb062e153e80da30acace7988..5d2c39aac1ef890d6bbb17b0d2e572ff9b2a0493 100644 --- a/src/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters.vue +++ b/src/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters.vue @@ -86,11 +86,11 @@ </div> </div> <div - v-if="checkedFeatures.length > 0 && massMode === 'modify'" + v-if="checkedFeatures.length > 0 && massMode.includes('edit') && isOnline" class="ui dropdown button compact button-hover-green tiny-margin-left" - data-tooltip="Modifier le statut des Signalements" + :data-tooltip="`Modifier le${massMode.includes('status') ? ' statut' : 's attributs'} des signalements`" data-position="bottom right" - @click="toggleModifyStatus" + @click="editFeatures" > <i class="pencil fitted icon" @@ -109,7 +109,7 @@ v-for="status in availableStatus" :key="status.value" class="item" - @click="$emit('modify-status', status.value)" + @click="$emit('edit-status', status.value)" > {{ status.name }} </span> @@ -117,7 +117,7 @@ </div> </div> <div - v-if="checkedFeatures.length > 0 && massMode === 'delete'" + v-if="checkedFeatures.length > 0 && massMode === 'delete-features' && isOnline" class="ui button compact button-hover-red tiny-margin-left" data-tooltip="Supprimer tous les signalements sélectionnés" data-position="bottom right" @@ -138,7 +138,7 @@ > <div id="type" - class="field column" + :class="['field column', { 'disabled': !isOnline }]" > <label>Type</label> <Dropdown @@ -151,7 +151,7 @@ </div> <div id="statut" - class="field column" + :class="['field column', { 'disabled': !isOnline }]" > <label>Statut</label> <!-- //* giving an object mapped on key name --> @@ -165,7 +165,7 @@ </div> <div id="name" - class="field column" + :class="['field column', { 'disabled': !isOnline }]" > <label>Nom</label> <div class="ui icon input"> @@ -235,7 +235,12 @@ export default { ...initialPagination }; } - } + }, + editAttributesFeatureType: { + type: String, + default: null, + }, + }, data() { @@ -260,7 +265,8 @@ export default { computed: { ...mapState([ 'user', - 'USER_LEVEL_PROJECTS' + 'USER_LEVEL_PROJECTS', + 'isOnline' ]), ...mapState('feature', [ 'checkedFeatures', @@ -333,11 +339,37 @@ export default { this.showModifyStatus = false; }, + editFeatures() { + switch (this.massMode) { + case 'edit-status': + this.toggleModifyStatus(); + break; + case 'edit-attributes': + this.displayAttributesForm(); + break; + } + }, + toggleModifyStatus() { this.showModifyStatus = !this.showModifyStatus; this.showAddFeature = false; }, + displayAttributesForm() { + if (this.checkedFeatures.length > 1) { + this.$router.push({ + name: 'editer-attribut-signalement', + params: { + slug_type_signal: this.editAttributesFeatureType, + }, + }); + } else { + this.$store.commit('DISPLAY_MESSAGE', { + comment: 'Veuillez sélectionner au moins 2 signalements pour l\'édition multiple d\'attributs' + }); + } + }, + clickOutsideDropdown(e) { if (!e.target.closest('#button-dropdown')) { this.showModifyStatus = false; diff --git a/src/components/Projects/ProjectsListItem.vue b/src/components/Projects/ProjectsListItem.vue index 37d03f00fa41b3dd90b186e78f239a449cba028c..1059a36c29463f8528fb450f0137b0c571f18757 100644 --- a/src/components/Projects/ProjectsListItem.vue +++ b/src/components/Projects/ProjectsListItem.vue @@ -32,7 +32,7 @@ class="preview" /> </div> - <div class="meta"> + <div class="meta top"> <span class="right floated"> <strong v-if="project.moderation">Projet modéré</strong> <strong v-else>Projet non modéré</strong> @@ -132,4 +132,34 @@ export default { overflow: scroll; margin-bottom: 0.8em; } + +.description { + p { + text-align: justify; + } +} + +@media screen and (max-width: 767px) { + .content { + width: 90% !important; + + .meta.top { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + + .right.floated { + float: none !important; + margin-left: 0 !important; + margin-bottom: 0.5em; + } + + span { + margin: 0.15em 0; + } + } + } +} + </style> diff --git a/src/components/Projects/ProjectsMenu.vue b/src/components/Projects/ProjectsMenu.vue index 442ab3a5a7e68e24e0c26a5a4ec8cad38f6d0a64..ece71b68de5d7d37283af90a410e65280395f4a9 100644 --- a/src/components/Projects/ProjectsMenu.vue +++ b/src/components/Projects/ProjectsMenu.vue @@ -1,18 +1,21 @@ <template> - <div class="filters-container"> - <div class="ui styled accordion"> + <div id="filters-container"> + <div + class="ui styled accordion" + @click="displayFilters = !displayFilters" + > <div id="filters" class="title collapsible-filters" > FILTRES <i - class="ui icon caret right down" + :class="['ui icon customcaret', { 'collapsed': !displayFilters }]" aria-hidden="true" /> </div> </div> - <div class="ui menu filters hidden"> + <div :class="['ui menu filters', { 'hidden': displayFilters }]"> <div class="item"> <label> Niveau d'autorisation requis @@ -40,7 +43,7 @@ v-on="$listeners" /> </div> - <div class="right item"> + <div class="item"> <label> Recherche par nom </label> @@ -69,6 +72,7 @@ export default { data() { return { + displayFilters: false, moderationOptions: [ { label: 'Tous', @@ -157,26 +161,10 @@ export default { } }, - mounted() { - const el = document.getElementsByClassName('collapsible-filters'); - - el[0].addEventListener('click', function() { - const icon = document.getElementsByClassName('caret'); - icon[0].classList.toggle('right'); - const content = document.getElementsByClassName('filters'); - content[0].classList.toggle('hidden'); - if (content[0].style.maxHeight){ - content[0].style.maxHeight = null; - } else { - content[0].style.maxHeight = content[0].scrollHeight + 5 + 'px'; - } - }); - }, - methods: { ...mapActions('projects', [ 'SEARCH_PROJECTS' - ]) + ]), } }; </script> @@ -189,7 +177,7 @@ export default { transition: @arguments; } -.filters-container { +#filters-container { width: 100%; display: flex; flex-direction: column; @@ -200,6 +188,23 @@ export default { .collapsible-filters { font-size: 1.25em; padding-right: 0; + .customcaret{ + transition: transform .2s ease; + &.collapsed { + transform: rotate(180deg); + } + &::before{ + position: relative; + right: 0; + top: 65%; + color: #999; + margin-top: 4px; + border-color: #999 transparent transparent; + border-style: solid; + border-width: 5px 5px 0; + content: ""; + } + } } } .filters { @@ -207,16 +212,16 @@ export default { height:auto; min-height: 0; max-height:75px; + opacity: 1; margin: 0 0 1em 0; border: none; box-shadow: none; - .transition-properties(max-height 0.2s ease-out;); + .transition-properties(all 0.2s ease-out;); .item { display: flex; flex-direction: column; align-items: flex-start !important; - - padding: 0.4em 0.6em 0.4em 0; + padding: 0.5em; label { margin-bottom: 0.2em; @@ -230,20 +235,43 @@ export default { .item::before { width: 0; } - .right.item { - padding-right: 0; - #search-projects { - width: 100%; - } + #search-projects { + width: 100%; } - .right.item::before { - width: 0; - } } .filters.hidden { - max-height: 0; overflow: hidden; - border: none; + opacity: 0; + max-height: 0; } } + +@media screen and (min-width: 701px) { + .item { + &:first-child { + padding-left: 0; + } + &:last-child { + padding-right: 0; + } + } +} + +@media screen and (max-width: 700px) { + #filters-container { + + .filters { + display: flex; + flex-direction: column; + max-height: 275px; + .transition-properties(all 0.2s ease-out;); + + .item { + width: 100%; + padding-right: 0; + padding-left: 0; + } + } + } +} </style> diff --git a/src/main.js b/src/main.js index 4bf957d27033d17d20e97b20b73a92e60d13335f..cabb80a8ddc6293333db579cbb747d5852b9590b 100644 --- a/src/main.js +++ b/src/main.js @@ -26,7 +26,7 @@ Vue.config.productionTip = false; // gestion mise à jour du serviceWorker et du precache var refreshing=false; -if(navigator.serviceWorker){ +if (navigator.serviceWorker) { navigator.serviceWorker.addEventListener('controllerchange', () => { // We'll also need to add 'refreshing' to our data originally set to false. if (refreshing) { @@ -44,7 +44,7 @@ const onConfigLoaded = function(config){ store.commit('SET_CONFIG', config); setInterval(() => { //* check if navigator is online store.commit('SET_IS_ONLINE', navigator.onLine); - }, 10000); + }, 2000); // set title and favico document.title = `${config.VUE_APP_APPLICATION_NAME} ${config.VUE_APP_APPLICATION_ABSTRACT}`; diff --git a/src/router/index.js b/src/router/index.js index 8fe8e9d76322ca63af83c24d4c95a744059aec64..56953714fbab4848193e56d9fab1c6dcd82e8f04 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -130,6 +130,11 @@ const routes = [ name: 'editer-signalement', component: () => import('../views/Feature/FeatureEdit.vue') }, + { + path: `/${projectBase}/:slug/type-signalement/:slug_type_signal/editer-signalements-attributs/`, + name: 'editer-attribut-signalement', + component: () => import('../views/Feature/FeatureEdit.vue') + }, { path: '/projet/:slug/catalog/:feature_type_slug', diff --git a/src/services/map-service.js b/src/services/map-service.js index c64c318d9e024d9bb3900caff85d1ef8456df50c..63e4204c00689cba3b4120faf5aa77b7abc72ef1 100644 --- a/src/services/map-service.js +++ b/src/services/map-service.js @@ -561,7 +561,12 @@ const mapService = { </div> ${author} `; - const featureId = feature.getProperties ? feature.getProperties().feature_id || feature.getId() : feature.id; //* feature.id was used with leaflet, with ol feature.getId replace it, but keeping it as fallback can prevent regression + const featureId = + feature.getId() ? + feature.getId() : + feature.getProperties ? + feature.getProperties().feature_id : + feature.id; return { html, feature_type, featureId }; }, diff --git a/src/store/index.js b/src/store/index.js index f5ed15a226fbf68d621784197be62e48aa7c1968..2be432d43f1c6c21dc1de190ecb8cc843cac176b 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -34,6 +34,7 @@ export default new Vuex.Store({ message: 'En cours de chargement' }, logged: false, + messageCount: 0, messages: [], reloadIntervalId: null, staticPages: null, @@ -75,16 +76,18 @@ export default new Vuex.Store({ state.levelsPermissions = levelsPermissions; }, DISPLAY_MESSAGE(state, message) { - state.messages = [message, ...state.messages]; + message['counter'] = state.messageCount; + state.messageCount += 1; + state.messages = [message, ...state.messages]; // add new message at the beginning of the list if (document.getElementById('scroll-top-anchor')) { document.getElementById('scroll-top-anchor').scrollIntoView({ block: 'start', inline: 'nearest' }); } setTimeout(() => { - state.messages = []; + state.messages = state.messages.slice(0, -1); // remove one message from the end of the list }, 3000); }, - DISCARD_MESSAGE(state, message) { - state.messages = state.messages.filter((el) => el.comment !== message.comment); + DISCARD_MESSAGE(state, messageCount) { + state.messages = state.messages.filter((mess) => mess.counter !== messageCount); }, CLEAR_MESSAGES(state) { state.messages = []; diff --git a/src/store/modules/feature-type.store.js b/src/store/modules/feature-type.store.js index 49921f0132dfd260b45b198d359bd45fc4f4fd30..0fe6b25c8de60295e8d2ba2a166fdd592ceead4e 100644 --- a/src/store/modules/feature-type.store.js +++ b/src/store/modules/feature-type.store.js @@ -111,6 +111,7 @@ const feature_type = { customfield_set: state.customForms.map(el => { return { position: el.position, + is_mandatory: el.isMandatory, label: el.label, name: el.name, field_type: el.field_type, diff --git a/src/store/modules/feature.store.js b/src/store/modules/feature.store.js index ef93083a8691c11c3de37a5dbd79082f3058b67f..7f82a9c848fcf6fa8e385472d0cdc7c5b40b073f 100644 --- a/src/store/modules/feature.store.js +++ b/src/store/modules/feature.store.js @@ -15,7 +15,7 @@ const feature = { form: null, linkedFormset: [], //* used to edit in feature_edit linked_features: [], //* used to display in feature_detail - massMode: 'modify', + massMode: 'edit-status', }, mutations: { SET_FEATURES(state, features) { @@ -174,7 +174,6 @@ const feature = { const cancelToken = axios.CancelToken.source(); commit('SET_CANCELLABLE_SEARCH_REQUEST', cancelToken, { root: true }); - //commit('SET_CURRENT_FEATURE', null); //? Est-ce que c'est nécessaire ? -> fait sauter l'affichage au clic sur un signalement lié (feature_detail) const url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}projects/${project_slug}/feature/?id=${feature_id}`; return axios .get(url, { cancelToken: cancelToken.token }) @@ -186,18 +185,20 @@ const feature = { return response; }) .catch((error) => { + console.error('Error while getting feature for id = ', feature_id, error); throw error; }); }, SEND_FEATURE({ state, rootState, commit, dispatch }, routeName) { - function redirect(featureId) { + function redirect(featureId, featureName, response) { + if (routeName === 'editer-attribut-signalement') return response; // exit function to avoid conflict with next feature call to GET_PROJECT_FEATURE when modifying more than 2 features commit( 'DISPLAY_MESSAGE', { comment: routeName === 'ajouter-signalement' ? 'Le signalement a été crée' : - 'Le signalement a été mis à jour', + `Le signalement ${featureName} a été mis à jour`, level: 'positive' }, { root: true }, @@ -209,35 +210,34 @@ const feature = { feature_id: featureId }) .then(() => { - if (routeName.includes('details-signalement')) return; + if (routeName === 'details-signalement') return response; router.push({ name: 'details-signalement', params: { slug_type_signal: rootState['feature-type'].current_feature_type_slug, slug_signal: featureId, - message: routeName === 'ajouter-signalement' ? 'Le signalement a été crée' : 'Le signalement a été mis à jour' }, }); }); + return response; } - async function handleOtherForms(featureId) { + async function handleOtherForms(featureId, featureName, response) { await dispatch('SEND_ATTACHMENTS', featureId); await dispatch('PUT_LINKED_FEATURES', featureId); - redirect(featureId); + return redirect(featureId, featureName, response); } function createGeojson() { //* prepare feature data to send const extraFormObject = {}; //* prepare an object to be flatten in properties of geojson for (const field of state.extra_forms) { - extraFormObject[field.name] = field.value; + if (field.value !== null) extraFormObject[field.name] = field.value; } - //const feature = state.form || state.currentFeature; return { id: state.form.feature_id || state.currentFeature.feature_id, type: 'Feature', geometry: state.form.geometry || state.form.geom || - state.currentFeature.geometry || state.currentFeature.geom, + state.currentFeature.geometry || state.currentFeature.geom, properties: { title: state.form.title, description: state.form.description.value, @@ -266,12 +266,14 @@ const feature = { data: geojson }).then((response) => { if ((response.status === 200 || response.status === 201) && response.data) { + const featureId = response.data.id; + const featureName = response.data.properties.title; if (state.attachmentFormset.length > 0 || state.linkedFormset.length > 0 || state.attachmentsToDelete.length > 0) { - handleOtherForms(response.data.id); + return handleOtherForms(featureId, featureName, response); } else { - redirect(response.data.id); + return redirect(featureId, featureName, response); } } }) @@ -298,7 +300,7 @@ const feature = { }); } else { - console.error(error); + console.error('Error while sending feature', error); throw error; } throw error; diff --git a/src/utils/index.js b/src/utils/index.js index 432cd2970a33e4be66ba5293a3501b9e7f76b2d5..a89354b8305aaae4b1d12e04003990b7e25cda55 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -58,4 +58,29 @@ export function allowedStatus2change(user, isModerate, userStatus, isOwnFeature, } } return []; +} + +export function transformProperties(prop) { + const type = typeof prop; + const date = new Date(prop); + const regInteger = /^-*?\d+$/; + const regFloat = /^-*?\d*?\.\d+$/; + const regText = /[\r\n]/; + if (type === 'boolean' || prop.toLowerCase() === 'true' || prop.toLowerCase() === 'False') { + return 'boolean'; + } else if (regInteger.test(prop) || Number.isSafeInteger(prop)) { + return 'integer'; + } else if ( + type === 'string' && + ['/', ':', '-'].some((el) => prop.includes(el)) && // check for chars found in datestring + date instanceof Date && + !isNaN(date.valueOf()) + ) { + return 'date'; + } else if (regFloat.test(prop) || type === 'number' && !isNaN(parseFloat(prop))) { + return 'decimal'; + } else if (regText.test(prop)) { + return 'text'; + } + return 'char'; //* string by default, most accepted type in database } \ No newline at end of file diff --git a/src/views/Feature/FeatureDetail.vue b/src/views/Feature/FeatureDetail.vue index 6926b84925ecad4dde6bd8fe2422beddc5bafdf5..fd547578dd6184020837ccb692fce61e37dcd08f 100644 --- a/src/views/Feature/FeatureDetail.vue +++ b/src/views/Feature/FeatureDetail.vue @@ -13,8 +13,12 @@ :feature-type="featureType" :fast-edition-mode="project.fast_edition_mode" :display-to-list-button="displayToListButton" - @setIsCancelling="isCanceling = true" + :is-feature-creator="isFeatureCreator" + :can-edit-feature="canEditFeature" + :can-delete-feature="canDeleteFeature" + @setIsDeleting="isDeleting = true" @tofeature="pushNgo" + @updateEvents="getFeatureEvents" /> </div> </div> @@ -24,6 +28,7 @@ v-if="project" :feature-type="featureType" :fast-edition-mode="project.fast_edition_mode" + :can-edit-feature="canEditFeature" @tofeature="pushNgo" /> </div> @@ -60,28 +65,33 @@ /> </div> </div> + <div - v-if="isCanceling" + v-if="isDeleting" class="ui dimmer modals visible active" > <div :class="[ - 'ui mini modal subscription', - { 'active visible': isCanceling }, + 'ui mini modal', + { 'active visible': isDeleting }, ]" > <i class="close icon" aria-hidden="true" - @click="isCanceling = false" + @click="isDeleting = false" /> - <div class="ui icon header"> + <div + v-if="isDeleting" + class="ui icon header" + > <i class="trash alternate icon" aria-hidden="true" /> Supprimer le signalement </div> + <div class="actions"> <button type="button" @@ -93,7 +103,61 @@ </div> </div> </div> + + <div + v-if="isLeaving" + class="ui dimmer modals visible active" + > + <div + :class="[ + 'ui mini modal', + { 'active visible': isLeaving }, + ]" + > + <i + class="close icon" + aria-hidden="true" + @click="isLeaving = false" + /> + <div class="ui icon header"> + <i + :class="[project.fast_edition_mode && hasUnsavedChange ? 'sign-out' : 'random', 'icon']" + aria-hidden="true" + /> + Abandonner {{ + project.fast_edition_mode && hasUnsavedChange ? + 'les modifications' : + 'la vue signalement filtré' + }} + </div> + <div class="content"> + {{ + project.fast_edition_mode && hasUnsavedChange ? + 'Les modifications apportées au signalement ne seront pas sauvegardées, continuer ?': + `Vous allez quittez la vue signalement filtré, + l\'ordre des signalements pourrait changer après édition d\'un signalement.` + }} + </div> + <div class="actions"> + <button + type="button" + class="ui green compact button" + @click="stayOnPage" + > + Annuler + </button> + <button + type="button" + class="ui red compact button" + @click="leavePage" + > + Continuer + </button> + </div> + </div> + </div> </div> + <div v-else> Pas de signalement correspondant trouvé </div> @@ -101,7 +165,7 @@ </template> <script> -import { mapState, mapActions, mapMutations } from 'vuex'; +import { mapState, mapActions, mapMutations, mapGetters } from 'vuex'; import mapService from '@/services/map-service'; import axios from '@/axios-client.js'; @@ -136,19 +200,19 @@ export default { }, beforeRouteUpdate (to, from, next) { - let leaving = true; // by default navigate to next route if (this.hasUnsavedChange) { - leaving = this.confirmLeave(); // prompt user that there is unsaved changes or that features order might change + this.confirmLeave(next); // prompt user that there is unsaved changes or that features order might change + } else { + next(); // continue navigation } - next(leaving); }, beforeRouteLeave (to, from, next) { - let leaving = true; // by default navigate to next route if (this.hasUnsavedChange || (from.query.offset >= 0 && to.name === 'editer-signalement')) { - leaving = this.confirmLeave(); // prompt user that there is unsaved changes or that features order might change + this.confirmLeave(next); // prompt user that there is unsaved changes or that features order might change + } else { + next(); // continue navigation } - next(leaving); }, data() { @@ -170,27 +234,37 @@ export default { events: [], featureType: {}, featuresCount: null, - isCanceling: false, + isDeleting: false, + isLeaving: false, slugSignal: '', displayToListButton: false, }; }, computed: { + ...mapState([ + 'USER_LEVEL_PROJECTS', + 'user' + ]), ...mapState('projects', [ 'project' ]), ...mapState('feature-type', [ 'feature_types', - 'feature_type', ]), ...mapState('feature', [ 'currentFeature', 'form', ]), + ...mapGetters('feature-type', [ + 'feature_type', + ]), + ...mapGetters([ + 'permissions', + ]), hasUnsavedChange() { - if (this.form) { + if (this.project.fast_edition_mode && this.form && this.currentFeature) { if (this.form.title !== this.currentFeature.title) return true; if (this.form.description.value !== this.currentFeature.description) return true; if (this.form.status.value !== this.currentFeature.status) return true; @@ -200,6 +274,36 @@ export default { } } return false; + }, + + isFeatureCreator() { + if (this.currentFeature && this.user) { + return this.currentFeature.creator === this.user.id; + } + return false; + }, + + isModerator() { + return this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.$route.params.slug] === 'Modérateur'; + }, + + isAdministrator() { + return this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.$route.params.slug] === 'Administrateur projet'; + }, + + canEditFeature() { + return (this.permissions && this.permissions.can_update_feature) || + this.isFeatureCreator || + this.isModerator || + this.user.is_superuser; + }, + + canDeleteFeature() { + return (this.permissions && this.permissions.can_delete_feature && this.isFeatureCreator) || + this.isFeatureCreator || + this.isModerator || + this.isAdministrator || + this.user.is_superuser; } }, @@ -283,10 +387,18 @@ export default { } }, - confirmLeave() { - return window.confirm(this.project.fast_edition_mode && this.hasUnsavedChange ? - 'Les modifications apportées au signalement ne seront pas sauvegardées, continuer ?': - 'Vous allez quittez la vue signalement filtré, l\'ordre des signalements pourrait changer après édition d\'un signalement.'); + confirmLeave(next) { + this.next = next; + this.isLeaving = true; + }, + + stayOnPage() { + this.isLeaving = false; + }, + + leavePage() { + this.isLeaving = false; + this.next(); }, async reloadPage() { diff --git a/src/views/Feature/FeatureEdit.vue b/src/views/Feature/FeatureEdit.vue index 68b7a5d39641400209748e13a8f2cc28b4f3cdf0..cf127b78cb27621d87db4914cb742293841fcc8b 100644 --- a/src/views/Feature/FeatureEdit.vue +++ b/src/views/Feature/FeatureEdit.vue @@ -1,12 +1,15 @@ <template> <div id="feature-edit"> - <h1 v-if="feature && currentRouteName === 'editer-signalement'"> - Mise à jour du signalement "{{ feature.title || feature.feature_id }}" - </h1> - <h1 - v-else-if="feature_type && currentRouteName === 'ajouter-signalement'" - > - Création d'un signalement <small>[{{ feature_type.title }}]</small> + <h1> + <span v-if="feature_type && currentRouteName === 'ajouter-signalement'"> + Création d'un signalement <small>[{{ feature_type.title }}]</small> + </span> + <span v-else-if="feature && currentRouteName === 'editer-signalement'"> + Mise à jour du signalement "{{ feature.title || feature.feature_id }}" + </span> + <span v-else-if="feature_type && currentRouteName === 'editer-attribut-signalement'"> + Mise à jour des attributs de {{ checkedFeatures.length }} signalements + </span> </h1> <form @@ -17,7 +20,10 @@ class="ui form" > <!-- Feature Fields --> - <div class="two fields"> + <div + v-if="currentRouteName !== 'editer-attribut-signalement'" + class="two fields" + > <div :class="field_title"> <label :for="form.title.id_for_label">{{ form.title.label }}</label> <input @@ -64,7 +70,10 @@ </div> </div> - <div class="field"> + <div + v-if="currentRouteName !== 'editer-attribut-signalement'" + class="field" + > <label :for="form.description.id_for_label">{{ form.description.label }}</label> @@ -77,7 +86,10 @@ </div> <!-- Geom Field --> - <div class="field"> + <div + v-if="currentRouteName !== 'editer-attribut-signalement'" + class="field" + > <label :for="form.geom.id_for_label">{{ form.geom.label }}</label> <!-- Import GeoImage --> <div @@ -264,14 +276,18 @@ <div v-for="(field, index) in orderedCustomFields" :key="field.field_type + index" - class="field" > - <FeatureExtraForm :field="field" /> + <FeatureExtraForm + :id="field.label" + ref="extraForm" + :field="field" + class="field" + /> {{ field.errors }} </div> <!-- Pièces jointes --> - <div v-if="isOnline"> + <div v-if="isOnline && currentRouteName !== 'editer-attribut-signalement'"> <div class="ui horizontal divider"> PIÈCES JOINTES </div> @@ -301,7 +317,7 @@ </div> <!-- Signalements liés --> - <div v-if="isOnline"> + <div v-if="isOnline && currentRouteName !== 'editer-attribut-signalement'"> <div class="ui horizontal divider"> SIGNALEMENTS LIÉS </div> @@ -330,7 +346,7 @@ <button type="button" :class="['ui teal icon button', { loading: sendingFeature }]" - @click="postForm" + @click="onSave" > <i class="white save icon" @@ -436,9 +452,11 @@ export default { ]), ...mapState('feature', [ 'attachmentFormset', - 'linkedFormset', - 'features', + 'checkedFeatures', + 'currentFeature', 'extra_forms', + 'features', + 'linkedFormset', ]), ...mapState('feature-type', [ 'feature_types' @@ -517,7 +535,7 @@ export default { this.$route.params.slug_type_signal ); //* empty previous feature data, not emptying by itself since it doesn't update by itself anymore - if (this.currentRouteName === 'ajouter-signalement') { + if (this.currentRouteName === 'ajouter-signalement' || this.currentRouteName === 'editer-attribut-signalement') { this.$store.commit('feature/SET_CURRENT_FEATURE', []); } @@ -528,10 +546,13 @@ export default { }, mounted() { - const promises = [ - this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug), - this.$store.dispatch('projects/GET_PROJECT_INFO', this.$route.params.slug), - ]; + const promises = []; + if (!this.project) { + promises.push( + this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug), + this.$store.dispatch('projects/GET_PROJECT_INFO', this.$route.params.slug), + ); + } if (this.$route.params.slug_signal) { promises.push( this.$store.dispatch('feature/GET_PROJECT_FEATURE', { @@ -542,9 +563,11 @@ export default { } Promise.all(promises).then(() => { - this.initForm(); - this.initMap(); - this.onFeatureTypeLoaded(); + if (this.currentRouteName !== 'editer-attribut-signalement') { + this.initForm(); + this.initMap(); + this.onFeatureTypeLoaded(); // init map tools + } this.$store.dispatch('feature/INIT_EXTRA_FORMS'); }); }, @@ -559,7 +582,7 @@ export default { methods: { initForm() { - if (this.currentRouteName === 'editer-signalement') { + if (this.currentRouteName.includes('editer')) { for (const key in this.feature) { if (key && this.form[key]) { if (key === 'status') { @@ -750,6 +773,13 @@ export default { checkAddedForm() { let isValid = true; //* fallback if all customForms returned true + if (this.$refs.extraForm) { + for (const extraForm of this.$refs.extraForm) { + if (extraForm.checkForm() === false) { + isValid = false; + } + } + } if (this.$refs.attachementForm) { for (const attachementForm of this.$refs.attachementForm) { if (attachementForm.checkForm() === false) { @@ -767,22 +797,27 @@ export default { return isValid; }, - postForm() { - let is_valid = true; - if (!this.feature_type.title_optional) { - is_valid = - this.checkFormTitle() && - this.checkFormGeom() && - this.checkAddedForm(); + onSave() { + if (this.currentRouteName === 'editer-attribut-signalement') { + this.postMultipleFeatures(); } else { - is_valid = this.checkFormGeom() && this.checkAddedForm(); + this.postForm(); } + }, + + async postForm() { + let is_valid = true; + let response; + is_valid = + this.checkFormGeom() && + this.checkAddedForm(); + if (!this.feature_type.title_optional) is_valid = this.checkFormTitle() && is_valid; if (is_valid) { //* in a moderate project, at edition of a published feature by someone else than admin or moderator, switch published status to draft. if ( this.project.moderation && - this.currentRouteName === 'editer-signalement' && + this.currentRouteName.includes('editer') && this.form.status.value.value === 'published' && !this.permissions.is_project_administrator && !this.permissions.is_project_moderator @@ -791,9 +826,58 @@ export default { this.updateStore(); } this.sendingFeature = true; - this.$store.dispatch('feature/SEND_FEATURE', this.currentRouteName) - .then(() => this.sendingFeature = false); + response = await this.$store.dispatch('feature/SEND_FEATURE', this.currentRouteName); + this.sendingFeature = false; + return response; + } + }, + + async postMultipleFeatures() { + this.$store.commit('DISPLAY_LOADER', 'Envoi des signalements en cours...'); + const extraForms = [...this.extra_forms];// store extra forms for multiple features to not be overide by current feature + let results = []; + for (const featureId of this.checkedFeatures) { + const response = await this.$store.dispatch('feature/GET_PROJECT_FEATURE', { + project_slug: this.$route.params.slug, + feature_id: featureId, + }); + if (response.status === 200) { + this.initForm(); // fill title, status, description needed to send request + for (let xtraForm of extraForms) { // fill extra forms with features values, only if the value of the extra form for multiple features is null + if (xtraForm.value === null) { // if no value to overide in feature, keep the feature value + xtraForm['value'] = this.feature.feature_data.find((feat) => feat.label === xtraForm.label).value; + await this.$store.commit('feature/UPDATE_EXTRA_FORM', xtraForm); + } + } + const result = await this.postForm(); + results.push(result); + } } + this.$store.commit('DISCARD_LOADER'); + const errors = results.filter((res) => res === undefined || res.status !== 200); + if (errors.length > 0) { + this.$store.commit( + 'DISPLAY_MESSAGE', + { + comment: 'Des signalements n\'ont pas pu être mis à jour', + level: 'negative' + }, + ); + } else { + this.$store.commit( + 'DISPLAY_MESSAGE', + { + comment: 'Les signalements ont été mis à jour', + level: 'positive' + }, + ); + } + this.$router.push({ + name: 'liste-signalements', + params: { + slug: this.$route.params.slug, + }, + }); }, //* ************* MAP *************** *// @@ -844,9 +928,9 @@ export default { } }, - updateGeomField(newGeom) { + async updateGeomField(newGeom) { this.form.geom.value = newGeom; - this.updateStore(); + await this.updateStore(); }, initMap() { diff --git a/src/views/FeatureType/FeatureTypeDetail.vue b/src/views/FeatureType/FeatureTypeDetail.vue index 2c20b6c90632cacfd92a6942e2b0539e14cb7a9c..97db52d8babd1925b82899592d1a4b3ee069e619 100644 --- a/src/views/FeatureType/FeatureTypeDetail.vue +++ b/src/views/FeatureType/FeatureTypeDetail.vue @@ -40,10 +40,12 @@ </div> </div> <div class="value"> - {{ features_count }} + {{ isOnline ? features_count : '?' }} </div> - <div class="label"> - Signalement{{ features.length > 1 ? "s" : "" }} + <div + class="label" + > + Signalement{{ features.length > 1 || !isOnline ? "s" : "" }} </div> </div> @@ -221,7 +223,10 @@ </div> </div> - <div class="nine wide column"> + <div + v-if="isOnline" + class="nine wide column" + > <h3 class="ui header"> Derniers signalements </h3> @@ -347,17 +352,32 @@ </router-link> <br> </div> + <div + v-else + class="nine wide column" + > + <h3 class="ui header"> + Derniers signalements + </h3> + <div class="ui message info"> + <p> + Information non disponible en mode déconnecté. + </p> + </div> + </div> </div> </div> </template> <script> +import { csv } from 'csvtojson'; + import { mapActions, mapMutations, mapGetters, mapState } from 'vuex'; -import { formatStringDate } from '@/utils'; +import { formatStringDate, transformProperties } from '@/utils'; import ImportTask from '@/components/ImportTask'; import featureAPI from '@/services/feature-api'; -import { fileConvertSizeToMo, csvToJson } from '@/assets/js/utils'; +import { fileConvertSizeToMo } from '@/assets/js/utils'; // TODO: refactor with above utils, those files are similar export default { name: 'FeatureTypeDetail', @@ -539,26 +559,6 @@ export default { } }, - transformProperties(prop) { - const type = typeof prop; - const date = new Date(prop); - if (type === 'boolean') { - return 'boolean'; - } else if (Number.isSafeInteger(prop)) { - return 'integer'; - } else if ( - type === 'string' && - ['/', ':', '-'].some((el) => prop.includes(el)) && // check for chars found in datestring - date instanceof Date && - !isNaN(date.valueOf()) - ) { - return 'char'; - } else if (type === 'number' && !isNaN(parseFloat(prop))) { - return 'decimal'; - } - return 'char'; //* string by default, most accepted type in database - }, - checkJsonValidity(json) { this.importError = ''; const fields = this.structure.customfield_set.map((el) => { @@ -570,9 +570,10 @@ export default { }); for (const feature of json.features) { for (const { name, field_type, options } of fields) { - if (name in feature.properties) { - const fieldInFeature = feature.properties[name]; - const customType = this.transformProperties(fieldInFeature); + const properties = feature.properties || feature; + if (name in properties) { + const fieldInFeature = properties[name]; + const customType = transformProperties(fieldInFeature); //* if custom field value is not null, then check validity of field if (fieldInFeature !== null) { //* if field type is list, it's not possible to guess from value type @@ -595,22 +596,20 @@ export default { return true; }, - checkCsvValidity(csv) { + async checkCsvValidity(csvString) { this.importError = ''; - - // Find csv delimiter - const commaDelimited = csv.split('\n')[0].includes(','); - const semicolonDelimited = csv.split('\n')[0].includes(';'); + // Find csvString delimiter + const commaDelimited = csvString.split('\n')[0].includes(','); + const semicolonDelimited = csvString.split('\n')[0].includes(';'); const delimiter = commaDelimited && !semicolonDelimited ? ',' : semicolonDelimited ? ';' : false; if ((commaDelimited && semicolonDelimited) || !delimiter) { this.importError = `Le fichier ${this.csvFileToImport.name} n'est pas formaté correctement`; return false; } - // Check if file contains 'lat' and 'long' fields const headersLine = - csv + csvString .split('\n')[0] .replace(/(\r\n|\n|\r)/gm, '') .split(delimiter) @@ -622,7 +621,7 @@ export default { return false; } const sampleLine = - csv + csvString .split('\n')[1] .split(delimiter) .map(el => { @@ -630,55 +629,8 @@ export default { }) .filter(Boolean); if (sampleLine.length > 1 && headersLine.length === 2) { - const fields = this.structure.customfield_set.map((el) => { - return { - name: el.name, - field_type: el.field_type, - options: el.options, - }; - }); - const csvFeatures = csvToJson(csv, delimiter); - for (const feature of csvFeatures) { - for (let { name, field_type, options } of fields) { - if (name in feature) { - const fieldInFeature = feature[name]; - - // overide some specific cases on date type data - if ( - typeof fieldInFeature === 'string' && - ['/', ':', '-'].some((el) => fieldInFeature.includes(el)) && - (new Date(fieldInFeature)) instanceof Date && - !isNaN((new Date(fieldInFeature)).valueOf()) - ) { - field_type = 'char'; - } else if ( - field_type === 'date' && - ((new Date(fieldInFeature)) instanceof Date) - ) { - field_type = 'char'; - } - - const customType = this.transformProperties(fieldInFeature); - //* if custom field value is not null, then check validity of field - if (fieldInFeature !== null) { - //* if field type is list, it's not possible to guess from value type - if (field_type === 'list') { - //*then check if the value is an available option - if (fieldInFeature && !options.includes(fieldInFeature)) { - this.importError = `Le fichier est invalide: la valeur [ ${fieldInFeature} ] n'est pas une option valide - pour le champ personnalisé "${name}".`; - return false; - } - } else if (customType !== field_type) { - //* check if custom field value match - this.importError = `Le fichier est invalide: Un champ de type ${field_type} ne peut pas avoir la valeur [ ${fieldInFeature} ]`; - return false; - } - } - } - } - } - return true; + const features = await csv().fromString(csvString); + return this.checkJsonValidity({ features }); } else { return false; } diff --git a/src/views/FeatureType/FeatureTypeEdit.vue b/src/views/FeatureType/FeatureTypeEdit.vue index 5682366fa74a2f7e5540ddcefbd9f87884d11fda..47bad6dceb3668063f9c76a1b9de51d7c13fdc7c 100644 --- a/src/views/FeatureType/FeatureTypeEdit.vue +++ b/src/views/FeatureType/FeatureTypeEdit.vue @@ -159,6 +159,7 @@ import { mapGetters, mapState, mapMutations, mapActions } from 'vuex'; import Dropdown from '@/components/Dropdown.vue'; import FeatureTypeCustomForm from '@/components/FeatureType/FeatureTypeCustomForm.vue'; +import { transformProperties } from'@/utils'; export default { name: 'FeatureTypeEdit', @@ -247,6 +248,7 @@ export default { 'archived_on', 'deletion_on', 'feature_type', + 'feature_id', 'display_creator', 'display_last_editor', 'project', @@ -638,23 +640,21 @@ export default { return 'point'; }, - transformProperties(prop) { - const type = typeof prop; - const date = new Date(prop); - if (type === 'boolean') { - return 'boolean'; - } else if (Number.isSafeInteger(prop)) { - return 'integer'; - } else if ( - type === 'string' && - date instanceof Date && - !isNaN(date.valueOf()) - ) { - return 'date'; - } else if (type === 'number' && !isNaN(parseFloat(prop))) { - return 'decimal'; + buildCustomForm(properties) { + for (const [key, val] of Object.entries(properties)) { + //* check that the property is not a keyword from the backend or map style + // todo: add map style keywords + if (!this.reservedKeywords.includes(key)) { + const customForm = { + label: { value: key || '' }, + name: { value: key || '' }, + position: this.dataKey, // * use dataKey already incremented by addCustomForm + field_type: { value: transformProperties(val) }, // * guessed from the type + options: { value: [] }, // * not available in export + }; + this.addCustomForm(customForm); + } } - return 'char'; //* string by default, most accepted type in database }, importGeoJsonFeatureType() { @@ -664,93 +664,16 @@ export default { this.form.title.value = properties.feature_type; this.form.geom_type.value = this.translateLabel(geometry.type); this.updateStore(); //* register title & geom_type in store - - //* loop properties to create a customForm for each of them - for (const [key, val] of Object.entries(properties)) { - //* check that the property is not a keyword from the backend or map style - // todo: add map style keywords - if (!this.reservedKeywords.includes(key)) { - const customForm = { - label: { value: key || '' }, - name: { value: key || '' }, - position: this.dataKey, // * use dataKey already incremented by addCustomForm - field_type: { value: this.transformProperties(val) }, // * guessed from the type - options: { value: [] }, // * not available in export - }; - this.addCustomForm(customForm); - } - } + this.buildCustomForm(properties); } }, importCSVFeatureType() { if (this.csv.length) { this.updateStore(); //* register title & geom_type in store - // List fileds for user to select coords fields - // this.csvFields = - // Object.keys(this.csv[0]) - // .map(el => { - // return { - // field: el, - // x: false, - // y:false - // }; - // }); - for (const [key, val] of Object.entries(this.csv[0])) { - //* check that the property is not a keyword from the backend or map style - // todo: add map style keywords - if (!this.reservedKeywords.includes(key)) { - const customForm = { - label: { value: key || '' }, - name: { value: key || '' }, - position: this.dataKey, // * use dataKey already incremented by addCustomForm - field_type: { value: this.transformProperties(val) }, // * guessed from the type - options: { value: [] }, // * not available in export - }; - this.addCustomForm(customForm); - } - } + this.buildCustomForm(this.csv[0]); } }, - - // pickXcsvCoordField(e) { - // this.csvFields.forEach(el => { - // if (el.field === e.field) { - // el.x = true; - // } else { - // el.x = false; - // } - // }); - // }, - // pickYcsvCoordField(e) { - // this.csvFields.forEach(el => { - // if (el.field === e.field) { - // el.y = true; - // } else { - // el.y = false; - // } - // }); - // }, - // setCSVCoordsFields() { - // const xField = this.csvFields.find(el => el.x === true).field; - // const yField = this.csvFields.find(el => el.y === true).field; - // this.csvFields = null; - - // for (const [key, val] of Object.entries(this.csv[0])) { - // //* check that the property is not a keyword from the backend or map style - // // todo: add map style keywords - // if (!this.reservedKeywords.includes(key) && key !== xField && key !== yField) { - // const customForm = { - // label: { value: key || '' }, - // name: { value: key || '' }, - // position: this.dataKey, // * use dataKey already incremented by addCustomForm - // field_type: { value: this.transformProperties(val) }, // * guessed from the type - // options: { value: [] }, // * not available in export - // }; - // this.addCustomForm(customForm); - // } - // } - // } }, }; </script> diff --git a/src/views/Project/FeaturesListAndMap.vue b/src/views/Project/FeaturesListAndMap.vue index fde352a957e3980fea4e7b76329ca27f1d3fdd78..fd67094f18b6eb966acf80b64f9fd4dc60007f3b 100644 --- a/src/views/Project/FeaturesListAndMap.vue +++ b/src/views/Project/FeaturesListAndMap.vue @@ -5,16 +5,17 @@ :show-map="showMap" :features-count="featuresCount" :pagination="pagination" + :edit-attributes-feature-type="editAttributesFeatureType" @set-filter="setFilters" @reset-pagination="resetPagination" @fetch-features="fetchPagedFeatures" @show-map="setShowMap" - @modify-status="modifyStatus" + @edit-status="modifyStatus" @toggle-delete-modal="toggleDeleteModal" /> <div - :class="['ui tab active map-container', {visible: showMap}]" + :class="['ui tab active map-container', { 'visible': showMap }]" data-tab="map" > <div @@ -48,6 +49,7 @@ :features-count="featuresCount" :pagination="pagination" :sort="sort" + :edit-attributes-feature-type.sync="editAttributesFeatureType" :queryparams="queryparams" @update:page="handlePageChange" @update:sort="handleSortChange" @@ -62,7 +64,7 @@ > <div :class="[ - 'ui mini modal subscription', + 'ui mini modal', { 'active visible': isDeleteModalOpen }, ]" > @@ -125,6 +127,7 @@ export default { data() { return { + editAttributesFeatureType: null, currentLayer: null, featuresCount: 0, form: { @@ -154,6 +157,9 @@ export default { }, computed: { + ...mapState([ + 'isOnline' + ]), ...mapState('projects', [ 'project', ]), @@ -177,13 +183,26 @@ export default { }, }, + + watch: { + isOnline(newValue, oldValue) { + if (newValue != oldValue && !newValue) { + this.DISPLAY_MESSAGE({ + comment: 'Les signalements du projet non mis en cache ne sont pas accessibles en mode déconnecté', + }); + } + }, + }, + mounted() { + this.UPDATE_CHECKED_FEATURES([]); // empty for when turning back from edit attributes page if (!this.project) { // Chargements des features et infos projet en cas d'arrivée directe sur la page ou de refresh - this.$store.dispatch('projects/GET_PROJECT', this.projectSlug); - this.$store - .dispatch('projects/GET_PROJECT_INFO', this.projectSlug) - .then(() => this.initMap()); + Promise.all([ + this.$store.dispatch('projects/GET_PROJECT', this.projectSlug), + this.$store.dispatch('projects/GET_PROJECT_INFO', this.projectSlug) + ]) + .then(()=> this.initMap()); } else { this.initMap(); } @@ -195,6 +214,9 @@ export default { }, methods: { + ...mapMutations([ + 'DISPLAY_MESSAGE', + ]), ...mapActions('feature', [ 'DELETE_FEATURE', ]), @@ -206,7 +228,7 @@ export default { setShowMap(newValue) { this.showMap = newValue; //* expanded sidebar is visible under the list, even when the map is closed (position:absolute), solved by closing it whin switching to list - if (newValue === false) this.$refs.sidebar.toggleSidebar(false); + if (newValue === false && this.$refs.sidebar) this.$refs.sidebar.toggleSidebar(false); }, resetPagination() { this.pagination = { ...initialPagination }; @@ -242,7 +264,7 @@ export default { this.UPDATE_CHECKED_FEATURES(newCheckedFeatures); this.modifyStatus(newStatus); } else { - this.$store.commit('DISPLAY_MESSAGE', { + this.DISPLAY_MESSAGE({ comment: `Le signalement ${feature.title} n'a pas pu être modifié`, level: 'negative' }); @@ -252,7 +274,7 @@ export default { } } else { this.fetchPagedFeatures(); - this.$store.commit('DISPLAY_MESSAGE', { + this.DISPLAY_MESSAGE({ comment: 'Tous les signalements ont été modifié avec succès.', level: 'positive' }); @@ -281,6 +303,10 @@ export default { this.toggleDeleteModal(); }, + modifyFeaturesAttributes() { + console.log('modifyFeaturesAttributes'); + }, + onFilterChange() { if (mapService.getMap() && mapService.mvtLayer) { mapService.mvtLayer.changed(); @@ -383,6 +409,12 @@ export default { }, fetchPagedFeatures(newUrl) { + if (!navigator.onLine) { + this.DISPLAY_MESSAGE({ + comment: 'Les signalements du projet non mis en cache ne sont pas accessibles en mode déconnecté', + }); + return; + } let url = `${this.API_BASE_URL}projects/${this.projectSlug}/feature-paginated/?limit=${this.pagination.pagesize}&offset=${this.pagination.start}`; //* if receiving next & previous url (// todo : might be not used anymore, to check) if (newUrl && typeof newUrl === 'string') { @@ -486,6 +518,9 @@ export default { margin-left: 50%; visibility: hidden; position: absolute; + #map { + min-height: 0; + } } .map-container.visible { visibility: visible; diff --git a/src/views/Project/ProjectMembers.vue b/src/views/Project/ProjectMembers.vue index 679f9a59f0296d909dc87961b118b035cbe6dad2..adf13ec5c98f698da9682758e614b4c514ee75c4 100644 --- a/src/views/Project/ProjectMembers.vue +++ b/src/views/Project/ProjectMembers.vue @@ -301,6 +301,11 @@ export default { }, saveMembers() { + this.$store.commit( + 'DISPLAY_LOADER', + 'Mise à jour des membres du projet en cours ...' + ); + const data = this.projectUsers.map((member) => { return { user: member.user, @@ -329,8 +334,10 @@ export default { } ); } + this.$store.commit('DISCARD_LOADER'); }) .catch((error) => { + this.$store.commit('DISCARD_LOADER'); throw error; }); },