diff --git a/.env b/.env index a8b74c222..3dcd8ac8a 100644 --- a/.env +++ b/.env @@ -37,3 +37,7 @@ HASURA_GRAPHQL_JWT_SECRET={"type": "HS256", "key": "a_pretty_long_secret_key_tha ACTIONS_SECRET=a random string that will be verify when calling the webhook PUBLICATION_SECRET=a random string that will be verify when calling the webhook +# Contributions +CDTN_API_URL=http://localhost:3003 +# CDTN_API_URL=https://cdtn-api.fabrique.social.gouv.fr + diff --git a/.github/workflows/preproduction.yml b/.github/workflows/preproduction.yml index c28bf3ec9..af5424302 100644 --- a/.github/workflows/preproduction.yml +++ b/.github/workflows/preproduction.yml @@ -34,6 +34,12 @@ jobs: - images: "cdtn-admin-export-elasticsearch" path: "./targets/export-elasticsearch" context: "." + - images: "cdtn-admin-contributions" + path: "./targets/contributions" + context: "." + args: | + NODE_ENV=production + CDTN_API_URL=https://cdtn-api.fabrique.social.gouv.fr steps: - name: Register docker images uses: SocialGouv/actions/autodevops-build-register@v1 @@ -42,6 +48,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} dockerfile: "${{ matrix.path }}/Dockerfile" dockercontext: "${{ matrix.context }}" + dockerbuildargs: ${{ matrix.args }} environment: preprod deploy: diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index f968efff8..cff769db0 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -32,6 +32,12 @@ jobs: - images: "cdtn-admin-export-elasticsearch" path: "./targets/export-elasticsearch" context: "." + - images: "cdtn-admin-contributions" + path: "./targets/contributions" + context: "." + args: | + NODE_ENV=production + CDTN_API_URL=https://cdtn-api.fabrique.social.gouv.fr steps: - name: Register docker images uses: SocialGouv/actions/autodevops-build-register@v1 @@ -40,6 +46,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} dockerfile: "${{ matrix.path }}/Dockerfile" dockercontext: "${{ matrix.context }}" + dockerbuildargs: ${{ matrix.args }} environment: prod deploy: diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 275cd70cf..2baa97f4c 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -34,6 +34,12 @@ jobs: - images: "cdtn-admin-export-elasticsearch" path: "./targets/export-elasticsearch" context: "." + - images: "cdtn-admin-contributions" + path: "./targets/contributions" + context: "." + args: | + NODE_ENV=production + CDTN_API_URL=https://cdtn-api.fabrique.social.gouv.fr steps: - name: Register docker images uses: SocialGouv/actions/autodevops-build-register@v1 @@ -42,6 +48,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} dockerfile: "${{ matrix.path }}/Dockerfile" dockercontext: "${{ matrix.context }}" + dockerbuildargs: ${{ matrix.args }} deploy: name: Deploy diff --git a/.kube-workflow/dev/templates/contributions.configmap.yaml b/.kube-workflow/dev/templates/contributions.configmap.yaml new file mode 100644 index 000000000..8e766366d --- /dev/null +++ b/.kube-workflow/dev/templates/contributions.configmap.yaml @@ -0,0 +1,9 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: contributions +data: + CDTN_API_URL: "https://cdtn-api.fabrique.social.gouv.fr" + API_URI: "http://app-www" + HASURA_GRAPHQL_ENDPOINT: "http://hasura/v1/graphql" + NODE_ENV: "production" diff --git a/.kube-workflow/dev/templates/contributions.sealed-secret.yaml b/.kube-workflow/dev/templates/contributions.sealed-secret.yaml new file mode 100644 index 000000000..c0d415307 --- /dev/null +++ b/.kube-workflow/dev/templates/contributions.sealed-secret.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: "bitnami.com/v1alpha1" +kind: "SealedSecret" +metadata: + annotations: &a1 + sealedsecrets.bitnami.com/cluster-wide: "true" + name: contributions + namespace: null +spec: + encryptedData: + HASURA_GRAPHQL_ADMIN_SECRET: AgC4DoTx/Burraa4LQnxWJ8pa4gK7mSHWd4fFFUHWeVrTmcoxLRyVVqH8ajgXFBZ1uksTujs8LM0eDtxeO/d6e88y7lF8epCutVm/9Lr39E8eMA4zwMU6QNrCX4j34YRonf4vbOnlUfm88XKek7DTOsUsbTp5BbO/IrESzabBJ9ntLBAdCVK+j6eGZyCC44VcfszHoiQINwi0Iax66KuA4oUvpQuTdTbB20wr9vzZarMatDDQRYtjugtYFzQQAv45+BZscb1Q+cMz+MXGOeJTDpFrhhV0OeDp5llEDhmx3nhyeRGyeWZdvONrDl4lDvftO6b8CTV1jBaoCX1r8pKcdp69WbkX2V+62lqamr5bmgJiuBWeOTHT1qQrv9uuInhrBBrkcHrXXMKlTY+p9auY/IQLw9CyBn41sK+RDteQfoZFRHSo2UOipbfgB5j9mCVnyJLDYLfyH4TEdGM6lkCATnKHr7Jz16IeR+GwmLfHrHkv3TW6zPijBBZRVcC5Z6LQNVv/6BzrKlzOkgZCKsX5pgaQWQBDSJSe8QGkx3Ccik6XpC/jgdHPEuP8zqDhE4VzWM6FrsxLAvso5WWNgdmS3i01RFa+pnFK7wLp7oLtDjKo7GyG2mXGt2B2+uvUffAmrE4jTCIbjUQtgrHHDBpq0hhxrG0njbh3SL29fNz1FeaXOFdubRfs0Zg2aNXtwDFHDrM8jCt/Yj4mY21uKup82mQZG6FCENCx+Hl7GGtuhs= + HASURA_GRAPHQL_JWT_SECRET: AgDSW4ceV9HFoxBNqg/hGa6uunl76cjtKBRSYf5LEPKzNHtXixd73LoFGa2y6/cQ/yGevNU6k7w62pkbWnz5rNAznsBE8QtBt1eRKQqjyJJ21m4sWI/MK6UI3gUS/Ad+LeCMxSoKG3XEzFSAwSndSXsgmwAP4KPpBF83DvdpVVpZtAjvPbNYNE6Ocjhtmt/TLqWjsV4Z+hkS8CKGk+PsYDEuR74Mj5TJys0MALJA9TB2dTIqMHOVZofD8T9hKH42yJWCDruH+gNNPKcleG/R0Pt7LJSamZtsqtXdxeE02tB+I9/VaEMrBL2WCJKhGHTjlDxexl4V/fswFYgXc/T4+hmPVB76xWVi6yFfggD/tLcAvgw3Ivdmbk/1c4CayKjU0+B+57EVrRqCGbJieBfbv6L3EWjU0uQj/uJEaZxIF4st3/r+iSGnrtPveBqnUgkJeqOmhf4sWJwE3xsPJM2kNYDPelWpYdxpoPaq3PNcLs7WpxMtRvwvvz2LMsWhEuUyHpe5yLOvi5swhcGqX1ALxGxfxzV+O229WfuTmV/kGUmcHhdf/VXdJRa+PYS83YK39wC1ZsoXJvPjAvEMgoTaRE8NHPWVD2ookNwdiRQIoLrSrGMP6F+Team2KtCic9ONYnM2wwUj2JCQRkFX2LZXwm9g9uR/Leuyd/IL5+tczwfF+PHfs6TQv+uKVz2qoZQZk4myQ/0JwHluOSXHTwEWkvuwGYO8QMTDOTTdp4Dor3kN7hQZyvWf41V/EHmOoLeFsdpQkWexo1FWHRZYtgG7ZVrgs07bY65gY6c1tZOKFaWhacTuneiLosz79vxn5omsFpjOOiWdSQOPZU8GkGxqJVq16H3VJg== + template: + metadata: + annotations: *a1 + name: contributions + type: "Opaque" diff --git a/.kube-workflow/preprod/templates/contributions.configmap.yaml b/.kube-workflow/preprod/templates/contributions.configmap.yaml new file mode 100644 index 000000000..8e766366d --- /dev/null +++ b/.kube-workflow/preprod/templates/contributions.configmap.yaml @@ -0,0 +1,9 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: contributions +data: + CDTN_API_URL: "https://cdtn-api.fabrique.social.gouv.fr" + API_URI: "http://app-www" + HASURA_GRAPHQL_ENDPOINT: "http://hasura/v1/graphql" + NODE_ENV: "production" diff --git a/.kube-workflow/preprod/templates/contributions.sealed-secret.yaml b/.kube-workflow/preprod/templates/contributions.sealed-secret.yaml new file mode 100644 index 000000000..c0d415307 --- /dev/null +++ b/.kube-workflow/preprod/templates/contributions.sealed-secret.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: "bitnami.com/v1alpha1" +kind: "SealedSecret" +metadata: + annotations: &a1 + sealedsecrets.bitnami.com/cluster-wide: "true" + name: contributions + namespace: null +spec: + encryptedData: + HASURA_GRAPHQL_ADMIN_SECRET: AgC4DoTx/Burraa4LQnxWJ8pa4gK7mSHWd4fFFUHWeVrTmcoxLRyVVqH8ajgXFBZ1uksTujs8LM0eDtxeO/d6e88y7lF8epCutVm/9Lr39E8eMA4zwMU6QNrCX4j34YRonf4vbOnlUfm88XKek7DTOsUsbTp5BbO/IrESzabBJ9ntLBAdCVK+j6eGZyCC44VcfszHoiQINwi0Iax66KuA4oUvpQuTdTbB20wr9vzZarMatDDQRYtjugtYFzQQAv45+BZscb1Q+cMz+MXGOeJTDpFrhhV0OeDp5llEDhmx3nhyeRGyeWZdvONrDl4lDvftO6b8CTV1jBaoCX1r8pKcdp69WbkX2V+62lqamr5bmgJiuBWeOTHT1qQrv9uuInhrBBrkcHrXXMKlTY+p9auY/IQLw9CyBn41sK+RDteQfoZFRHSo2UOipbfgB5j9mCVnyJLDYLfyH4TEdGM6lkCATnKHr7Jz16IeR+GwmLfHrHkv3TW6zPijBBZRVcC5Z6LQNVv/6BzrKlzOkgZCKsX5pgaQWQBDSJSe8QGkx3Ccik6XpC/jgdHPEuP8zqDhE4VzWM6FrsxLAvso5WWNgdmS3i01RFa+pnFK7wLp7oLtDjKo7GyG2mXGt2B2+uvUffAmrE4jTCIbjUQtgrHHDBpq0hhxrG0njbh3SL29fNz1FeaXOFdubRfs0Zg2aNXtwDFHDrM8jCt/Yj4mY21uKup82mQZG6FCENCx+Hl7GGtuhs= + HASURA_GRAPHQL_JWT_SECRET: AgDSW4ceV9HFoxBNqg/hGa6uunl76cjtKBRSYf5LEPKzNHtXixd73LoFGa2y6/cQ/yGevNU6k7w62pkbWnz5rNAznsBE8QtBt1eRKQqjyJJ21m4sWI/MK6UI3gUS/Ad+LeCMxSoKG3XEzFSAwSndSXsgmwAP4KPpBF83DvdpVVpZtAjvPbNYNE6Ocjhtmt/TLqWjsV4Z+hkS8CKGk+PsYDEuR74Mj5TJys0MALJA9TB2dTIqMHOVZofD8T9hKH42yJWCDruH+gNNPKcleG/R0Pt7LJSamZtsqtXdxeE02tB+I9/VaEMrBL2WCJKhGHTjlDxexl4V/fswFYgXc/T4+hmPVB76xWVi6yFfggD/tLcAvgw3Ivdmbk/1c4CayKjU0+B+57EVrRqCGbJieBfbv6L3EWjU0uQj/uJEaZxIF4st3/r+iSGnrtPveBqnUgkJeqOmhf4sWJwE3xsPJM2kNYDPelWpYdxpoPaq3PNcLs7WpxMtRvwvvz2LMsWhEuUyHpe5yLOvi5swhcGqX1ALxGxfxzV+O229WfuTmV/kGUmcHhdf/VXdJRa+PYS83YK39wC1ZsoXJvPjAvEMgoTaRE8NHPWVD2ookNwdiRQIoLrSrGMP6F+Team2KtCic9ONYnM2wwUj2JCQRkFX2LZXwm9g9uR/Leuyd/IL5+tczwfF+PHfs6TQv+uKVz2qoZQZk4myQ/0JwHluOSXHTwEWkvuwGYO8QMTDOTTdp4Dor3kN7hQZyvWf41V/EHmOoLeFsdpQkWexo1FWHRZYtgG7ZVrgs07bY65gY6c1tZOKFaWhacTuneiLosz79vxn5omsFpjOOiWdSQOPZU8GkGxqJVq16H3VJg== + template: + metadata: + annotations: *a1 + name: contributions + type: "Opaque" diff --git a/.kube-workflow/prod/templates/contributions.configmap.yaml b/.kube-workflow/prod/templates/contributions.configmap.yaml new file mode 100644 index 000000000..c73272f89 --- /dev/null +++ b/.kube-workflow/prod/templates/contributions.configmap.yaml @@ -0,0 +1,10 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: contributions +data: + CDTN_API_URL: "https://cdtn-api.fabrique.social.gouv.fr" + API_URI: "http://app-www" + HASURA_GRAPHQL_ENDPOINT: "http://hasura/v1/graphql" + NODE_ENV: "production" + diff --git a/.kube-workflow/prod/templates/contributions.sealed-secret.yaml b/.kube-workflow/prod/templates/contributions.sealed-secret.yaml new file mode 100644 index 000000000..1122df7f0 --- /dev/null +++ b/.kube-workflow/prod/templates/contributions.sealed-secret.yaml @@ -0,0 +1,14 @@ +apiVersion: "bitnami.com/v1alpha1" +kind: "SealedSecret" +metadata: + name: contributions + namespace: cdtn-admin +spec: + encryptedData: + HASURA_GRAPHQL_ADMIN_SECRET: AgA/oEQJToFMrm/N+uHwfhDnRu7XDNm+Bkioqq1a8cGNuwhNC51dl3rxHw0l4l4ggpcRsHR/gt4h1V/kHjsKtfKSl1opnvAqlHeEiFHMqs3Dv27o+rLR8xYxrbW//3ZXKXeQufIKkrg8eD4ZGzc9KvR/HdOQtJ4oG3hWc+Lq/47C55wfBWjvGRnrlGf4IcnmZ6JEK0mF+AukD6Jd+BFAyeVhzZ52/1W1w55rWNrOf3OkjUEJAwDVakqnkAjK42gGAZh25XvkvV23PUeopL5oX4GqmdQctMU12kX+QwPy+B0biIE9LKCeohAZlDfxY4KrUsXRnWD7W8M9YpmTqdDOpHdJov/0hd8mjfsrxA3HOfVPxVMvieXbICEiUtkcOPxuuuidU89Iz+WFO2NM20IOeCVQWCba1OT4pM5pX4IKUB01Hj7Or57nhZa7DWpVL4HWIFdeRHlZFXpSRj3d4gUo4LlumGgFwtBhr+rU9/KGsdMtaWXuMIZXx7g6UcaaMxi69Qc0tZastY922NA+KQW3/r9I1+NJm+8lcNBndl5YlvYos5eodPnfgAo77+5RV5R2idNf7nDJZ9/hZCp28k8Dh97cMhAclT/Xc7gVloiFZuCuzlmlKyBS3D3mSdAml6P7q55LxP0oPq/U0UAKiq27+lAJt7LCYT8qrqVwUX50O8ODeC6lAwxdPqtn+rncEkt1gkEF+pcv0DGPuZN0YykxI5USza7x9cCc2ol4YxyY0IU= + HASURA_GRAPHQL_JWT_SECRET: AgBk8DBUbfxqEW+0c9Omk5p+fL8ZCpL93AvZ0dKENqZHqPgE5Eou3eqce0Yp1eXhx3zpfhyXUWug/Io5FiIotje9VHoVzCzU40o/AK8E6pJ5lX5frcyH4VSn1iABoBp2zU3351DQNAs9KX7JhPaRqgkGgddjSAWNV+m+RQyBurJncNvP7CpE2e3379VEJJqhBxgYKiN32vtjmEPZHEIl23hkuPH7GyOdP8OO+ZBMG8wkHUuMsl4GHKgJicHydPeP3GVdfvD0GQR5x+zmrEMyGob/2oedsCCeKfURxH+QP/F7c4L8EYDerbHxH7FZdFBqG5ogmc35OUkyLWGTZl/8gJfslt7OyWzH1J1vo+C7n1kthxRh1lCWAyBz0Gv6kkm3uroyXJFfmWbR19zQNP5TsrjqZUD28hmWyE0wxI5/L+h1cdsqCkm5cupe0u1lXFmArMU90LAzrRR6M5jZJpkwOGOIrAwJ6Ohsgue7DIEB/jPiwdCMXBs9PP2iLyqfR6suUgngfYoHMNtiBHX+b05OxhoJDunEFWo9tYfweby/DeGH6KV2/T55rmBNh4eGgHNmAiG4Mx4IVXkGqr22CkHHz36SHliHeF5jXmIfo6UBrEEQBJ4OKBuJSINuDqFQNS8ENME2MgfEd8xttWJxs3An4jYihhGdBnA5IYcxiCzD8No2VIop89CKrCCtfsKnmBKvfbbVfk5Z3k+0DBmAO6NXqMQZ18mgVod6pjueB3YfLCoDH1L3hs31OH3lupzic9OY4MkKGkYn6Yppt/oErAp/Cc02bPm1QeUva2OUekZ9/Ar9EC6vvasohmBWbVEMLXV1xQPOxDHjTpGe6h26Ok3PJohQCxnfLw== + template: + metadata: + name: contributions + namespace: cdtn-admin + type: "Opaque" diff --git a/.kube-workflow/prod/values.yaml b/.kube-workflow/prod/values.yaml index 0aca68bc6..b9d4f2c00 100644 --- a/.kube-workflow/prod/values.yaml +++ b/.kube-workflow/prod/values.yaml @@ -6,3 +6,9 @@ app-www: env: - name: "FRONTEND_HOST" value: https://cdtn-admin.fabrique.social.gouv.fr + +app-contributions: + host: cdtn-contributions.fabrique.social.gouv.fr + ingress: + annotations: + nginx.ingress.kubernetes.io/whitelist-source-range: 86.247.240.55,90.44.219.207,185.24.184.196,185.24.185.196,185.24.186.196,185.24.187.196,45.87.212.184,54.216.19.70,86.247.251.159,90.52.73.222,51.11.226.128,92.184.117.135,90.92.100.236,20.40.132.82,51.11.226.128,89.86.85.155 diff --git a/.kube-workflow/values.yaml b/.kube-workflow/values.yaml index 7ac248ac7..861de01db 100644 --- a/.kube-workflow/values.yaml +++ b/.kube-workflow/values.yaml @@ -17,6 +17,23 @@ app-www: cpu: "5m" memory: "128Mi" +app-contributions: + probesPath: / + imagePackage: cdtn-admin-contributions + containerPort: 3200 + envFrom: + - configMapRef: + name: contributions + - secretRef: + name: contributions + resources: + limits: + cpu: "1000m" + memory: "560Mi" + requests: + cpu: "5m" + memory: "128Mi" + app-export: probesPath: /healthz ingress: diff --git a/README-dev.md b/README-dev.md index bfbebae52..73ed0f44a 100644 --- a/README-dev.md +++ b/README-dev.md @@ -199,7 +199,7 @@ et pour remettre les utilisateurs par défaut ```sh docker-compose exec -T postgres psql \ --dbname postgres --user postgres \ - < .k8s/components/jobs/restore/post-restore.sql + < .kube-workflow/sql/post-restore.sql ``` ### Alimenter l'elasticsearch en local (pour le CDTN frontend) diff --git a/docker-compose.yml b/docker-compose.yml index 5ebc64139..f00456fd5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,21 @@ services: env_file: - .env + contributions: + build: + context: . + dockerfile: targets/contributions/Dockerfile + shm_size: 512m + depends_on: + - hasura + - www + ports: + - "3200:3200" + environment: + API_URI: http://www + env_file: + - .env + ingester: build: context: . diff --git a/shared/logger/src/index.test.ts b/shared/logger/src/index.test.ts index 77b50e66e..a413e7323 100644 --- a/shared/logger/src/index.test.ts +++ b/shared/logger/src/index.test.ts @@ -27,11 +27,11 @@ test("should log less than or equal to info level to stdout", async () => { expect(output.stderr).toMatchInlineSnapshot(`Array []`); expect(output.stdout).toMatchInlineSnapshot(` Array [ - "{\\"message\\":\\"an error\\",\\"level\\":\\"error\\"} + "{\\"level\\":\\"error\\",\\"message\\":\\"an error\\"} ", - "{\\"message\\":\\"an warn\\",\\"level\\":\\"warn\\"} + "{\\"level\\":\\"warn\\",\\"message\\":\\"an warn\\"} ", - "{\\"message\\":\\"an info\\",\\"level\\":\\"info\\"} + "{\\"level\\":\\"info\\",\\"message\\":\\"an info\\"} ", ] `); @@ -59,19 +59,19 @@ test("should log all levels to stdout", async () => { expect(output.stderr).toMatchInlineSnapshot(`Array []`); expect(output.stdout).toMatchInlineSnapshot(` Array [ - "{\\"message\\":\\"an error\\",\\"level\\":\\"error\\"} + "{\\"level\\":\\"error\\",\\"message\\":\\"an error\\"} ", - "{\\"message\\":\\"an warn\\",\\"level\\":\\"warn\\"} + "{\\"level\\":\\"warn\\",\\"message\\":\\"an warn\\"} ", - "{\\"message\\":\\"an info\\",\\"level\\":\\"info\\"} + "{\\"level\\":\\"info\\",\\"message\\":\\"an info\\"} ", - "{\\"message\\":\\"an http\\",\\"level\\":\\"http\\"} + "{\\"level\\":\\"http\\",\\"message\\":\\"an http\\"} ", - "{\\"message\\":\\"an verbose\\",\\"level\\":\\"verbose\\"} + "{\\"level\\":\\"verbose\\",\\"message\\":\\"an verbose\\"} ", - "{\\"message\\":\\"an debug\\",\\"level\\":\\"debug\\"} + "{\\"level\\":\\"debug\\",\\"message\\":\\"an debug\\"} ", - "{\\"message\\":\\"an debug\\",\\"level\\":\\"silly\\"} + "{\\"level\\":\\"silly\\",\\"message\\":\\"an debug\\"} ", ] `); diff --git a/targets/alert-cli/Dockerfile b/targets/alert-cli/Dockerfile index 32c1ef108..faaa61ce4 100644 --- a/targets/alert-cli/Dockerfile +++ b/targets/alert-cli/Dockerfile @@ -1,4 +1,4 @@ -ARG NODE_VERSION=14.18.0-slim +ARG NODE_VERSION=14.18.2-slim # dist FROM node:$NODE_VERSION AS dist diff --git a/targets/contributions/.babelrc b/targets/contributions/.babelrc new file mode 100644 index 000000000..c0e09b2e0 --- /dev/null +++ b/targets/contributions/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": [["@emotion"]] +} diff --git a/targets/contributions/.browserslistrc b/targets/contributions/.browserslistrc new file mode 100644 index 000000000..49dac8329 --- /dev/null +++ b/targets/contributions/.browserslistrc @@ -0,0 +1,3 @@ +last 2 Chrome versions +last 2 Edge versions +last 2 Firefox versions diff --git a/targets/contributions/.dockerignore b/targets/contributions/.dockerignore new file mode 100644 index 000000000..aab51031a --- /dev/null +++ b/targets/contributions/.dockerignore @@ -0,0 +1,11 @@ +.next/ +__tests__/ +node_modules/ +tests/ + +*.d.ts +*.md + +jest.config.js +LICENSE + diff --git a/targets/contributions/.gitignore b/targets/contributions/.gitignore new file mode 100644 index 000000000..991dcb7e9 --- /dev/null +++ b/targets/contributions/.gitignore @@ -0,0 +1,6 @@ +.env.production +.eslintcache +.next +*.DS_Store +data/* +node_modules diff --git a/targets/contributions/Dockerfile b/targets/contributions/Dockerfile new file mode 100644 index 000000000..8ad3e87b3 --- /dev/null +++ b/targets/contributions/Dockerfile @@ -0,0 +1,44 @@ +ARG NODE_VERSION=14.18.2 + +FROM node:$NODE_VERSION AS dist + +WORKDIR /app + +ARG CDTN_API_URL +ENV CDTN_API_URL=$CDTN_API_URL + +COPY shared/graphql-client/package.json /shared/graphql-client/package.json +COPY targets/contributions/package.json /targets/contributions/package.json +COPY package.json /package.json +COPY yarn.lock /yarn.lock + +COPY . / + +RUN yarn --frozen-lockfile + +RUN yarn workspace @shared/graphql-client build + +RUN yarn workspace @socialgouv/contibutions build + +# app +FROM node:$NODE_VERSION + +WORKDIR /app + +COPY --from=dist shared/graphql-client/build /app/shared/graphql-client/build +COPY --from=dist shared/graphql-client/package.json /app/shared/graphql-client/package.json +COPY --from=dist targets/contributions/package.json /app/targets/contributions/package.json +COPY --from=dist targets/contributions/next.config.js /app/targets/contributions/next.config.js +COPY --from=dist targets/contributions/public /app/targets/contributions/public +COPY --from=dist targets/contributions/server /app/targets/contributions/server +COPY --from=dist targets/contributions/.next /app/targets/contributions/.next +COPY --from=dist targets/contributions/node_modules /app/targets/contributions/node_modules +COPY --from=dist package.json /app/package.json +COPY --from=dist node_modules /app/node_modules + +USER 1000 + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +CMD ["yarn", "--cwd", "targets/contributions", "start"] diff --git a/targets/contributions/LICENSE b/targets/contributions/LICENSE new file mode 100644 index 000000000..32700c5b2 --- /dev/null +++ b/targets/contributions/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019-present, Fabrique numérique des Ministères Sociaux. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/targets/contributions/README.md b/targets/contributions/README.md new file mode 100644 index 000000000..b5c148042 --- /dev/null +++ b/targets/contributions/README.md @@ -0,0 +1 @@ +# @socialgouv/code-du-travail-backoffice__app diff --git a/targets/contributions/jest.config.js b/targets/contributions/jest.config.js new file mode 100644 index 000000000..a8d0c6f41 --- /dev/null +++ b/targets/contributions/jest.config.js @@ -0,0 +1,18 @@ +const pack = require("./package"); + +module.exports = { + bail: 1, + clearMocks: true, + displayName: pack.name, + moduleNameMapper: { + "\\.(jpg|jpeg|png|gif|eot|svg|ttf|woff|woff2)$": + "/packages/app/tests/mappers/fileTransformer.js", + "\\.css$": "identity-obj-proxy", + }, + rootDir: "../..", + roots: [`/packages/app`], + // https://github.com/facebook/create-react-app/issues/2007#issuecomment-296694661 + setupFilesAfterEnv: ["/packages/app/tests/jest.setup.js"], + snapshotSerializers: ["@emotion/jest"], + watchPathIgnorePatterns: ["/packages/app/.next/"], +}; diff --git a/targets/contributions/next.config.js b/targets/contributions/next.config.js new file mode 100644 index 000000000..57b0bfe11 --- /dev/null +++ b/targets/contributions/next.config.js @@ -0,0 +1,8 @@ +module.exports = { + publicRuntimeConfig: { + // Will be available on both server and client + CDTN_API_URL: + process.env.CDTN_API_URL || "https://cdtn-api.fabrique.social.gouv.fr", + API_URL: process.env.API_URL || "http://localhost:8080", + }, +}; diff --git a/targets/contributions/package.json b/targets/contributions/package.json new file mode 100644 index 000000000..146fed336 --- /dev/null +++ b/targets/contributions/package.json @@ -0,0 +1,102 @@ +{ + "name": "@socialgouv/contibutions", + "description": "Application de contributions aux données du code du travail numérique.", + "version": "0.0.0", + "license": "Apache-2.0", + "private": true, + "scripts": { + "build": "next build", + "build:docker": "node --max-old-space-size=1024 ./node_modules/.bin/next build", + "clean": "rimraf ./.next", + "dev": "nodemon --exec \"node --inspect -r dotenv/config ./server dotenv_config_path=../../.env\" --watch ./server", + "start": "node ./server", + "start:docker": "yarn build:docker && yarn start" + }, + "dependencies": { + "@emotion/babel-plugin": "11.1.2", + "@emotion/react": "11.1.4", + "@emotion/styled": "11.0.0", + "@fortawesome/fontawesome-svg-core": "1.2.32", + "@fortawesome/free-solid-svg-icons": "5.15.1", + "@fortawesome/react-fontawesome": "0.1.14", + "@koa/router": "10.0.0", + "@shared/graphql-client": "^1.16.4", + "@urql/core": "^2.4.4", + "axios": "^0.24.0", + "colors": "1.4.0", + "diff": "5.0.0", + "emotion": "11.0.0", + "emotion-server": "11.0.0", + "isomorphic-unfetch": "^3.1.0", + "js-cookie": "3.0.1", + "jsonwebtoken": "^8.5.1", + "koa": "2.13.1", + "koa-body": "^5.0.0", + "koa-proxies": "^0.12.2", + "lodash.debounce": "4.0.8", + "moment-timezone": "0.5.32", + "next": "10.0.8", + "next-cookies": "2.0.3", + "next-redux-saga": "4.1.2", + "next-redux-wrapper": "7.0.1", + "numeral": "2.0.6", + "password-generator": "2.3.2", + "pg": "8.5.1", + "postgrester": "1.5.0", + "prop-types": "15.7.2", + "quill": "1.3.7", + "ramda": "0.27.1", + "react": "17.0.1", + "react-diff-viewer": "3.1.1", + "react-dom": "17.0.1", + "react-form": "4.0.1", + "react-medixtor": "0.1.0-alpha.16", + "react-onclickoutside": "6.9.0", + "react-redux": "7.2.2", + "react-select": "3.1.1", + "react-table": "6.11.5", + "react-tag-autocomplete": "6.1.0", + "react-toastify": "6.2.0", + "react-tooltip": "4.2.11", + "rebass": "4.0.7", + "redux": "4.0.5", + "redux-devtools-extension": "2.13.8", + "redux-saga": "1.1.3", + "rehype-parse": "7.0.1", + "rehype-remark": "8.0.0", + "rehype-sanitize": "4.0.0", + "rehype-stringify": "8.0.0", + "remark-parse": "9.0.0", + "remark-rehype": "8.0.0", + "remark-stringify": "9.0.1", + "ua-parser-js": "0.7.24", + "unified": "9.2.0", + "unist-util-find": "1.0.2", + "unist-util-parents": "1.0.3", + "unist-util-remove": "3.0.0" + }, + "devDependencies": { + "@emotion/jest": "11.1.0", + "@socialgouv/kali-data": "2.30.0", + "@socialgouv/legi-data": "2.24.0", + "@testing-library/jest-dom": "5.11.8", + "@testing-library/react": "12.1.2", + "@types/hast": "2.3.1", + "@types/node": "^17.0.0", + "@types/ramda": "0.27.34", + "@types/react": "^17.0.37", + "@types/react-test-renderer": "17.0.0", + "dotenv": "10.0.0", + "eslint": "^8.4.1", + "eslint-plugin-jest": "^25.3.0", + "eslint-plugin-only-warn": "^1.0.3", + "identity-obj-proxy": "3.0.0", + "mutationobserver-shim": "0.3.7", + "nodemon": "2.0.7", + "prettier": "2.2.1", + "react-test-renderer": "17.0.1", + "rimraf": "3.0.2", + "waait": "1.0.5", + "zxcvbn": "4.4.2" + } +} diff --git a/targets/contributions/pages/404.js b/targets/contributions/pages/404.js new file mode 100644 index 000000000..8b69db72d --- /dev/null +++ b/targets/contributions/pages/404.js @@ -0,0 +1,22 @@ +import React from "react"; +import css from "styled-jsx/css"; + +import Main from "../src/layouts/Main"; + +const styles = css` + .Container { + align-items: center; + display: flex; + flex-grow: 1; + justify-content: center; + } +`; + +export default function NotFound() { + return ( +
+ +
{`Cette page n'existe pas ou plus.`}
+
+ ); +} diff --git a/targets/contributions/pages/_app.js b/targets/contributions/pages/_app.js new file mode 100644 index 000000000..4429dedae --- /dev/null +++ b/targets/contributions/pages/_app.js @@ -0,0 +1,48 @@ +import App from "next/app"; +import React from "react"; + +import Login from "../src/blocks/Login"; +import cache from "../src/cache"; +import getMe from "../src/libs/getMe"; +import { wrapper } from "../src/store"; + +// https://github.com/zeit/next.js/blob/canary/examples/with-redux-saga/pages/_app.js +class MainApp extends App { + static async getInitialProps({ Component, ctx }) { + const pageProps = Component.getInitialProps ? await Component.getInitialProps({ ctx }) : {}; + pageProps.id = ctx.query.id; + pageProps.me = await getMe(ctx); + + return { pageProps }; + } + + constructor(props) { + super(props); + + const { + pageProps: { me }, + } = props; + + cache.set("me", me); + } + + async login() { + window.location.reload(); + } + + render() { + const me = cache.get("me"); + const { Component, pageProps } = this.props; + const { statusCode } = pageProps; + + const hasError = statusCode !== undefined && statusCode >= 400; + + if (hasError || me.isAuthenticated) { + return ; + } + + return ; + } +} + +export default wrapper.withRedux(MainApp); diff --git a/targets/contributions/pages/_document.js b/targets/contributions/pages/_document.js new file mode 100644 index 000000000..9bad4669f --- /dev/null +++ b/targets/contributions/pages/_document.js @@ -0,0 +1,30 @@ +import Document, { Head, Html, Main, NextScript } from "next/document"; +import React from "react"; + +class MyDocument extends Document { + render() { + return ( + + + + {/* + We use this meta to block this website from being referenced in search engines: + DO NOT ADD A "robot.txt"! + https://support.google.com/webmasters/answer/6062608 + */} + + + + + + + +
+ + + + ); + } +} + +export default MyDocument; diff --git a/targets/contributions/pages/_error.js b/targets/contributions/pages/_error.js new file mode 100644 index 000000000..6ffef617b --- /dev/null +++ b/targets/contributions/pages/_error.js @@ -0,0 +1,45 @@ +import React from "react"; +import css from "styled-jsx/css"; + +import Main from "../src/layouts/Main"; + +const styles = css` + .Container { + align-items: center; + display: flex; + flex-grow: 1; + justify-content: center; + } +`; + +function Error({ statusCode }) { + let message; + switch (true) { + case statusCode >= 500: + message = "Une erreur serveur s'est produite."; + break; + + case statusCode === 404: + message = "Cette page n'existe pas ou plus."; + break; + + default: + message = "Une erreur inattendue s'est produite."; + break; + } + + return ( +
+ +
{message}
+
+ ); +} + +Error.getInitialProps = ({ res, err }) => { + const statusCode = res ? res.statusCode : err ? err.statusCode : 404; + + return { statusCode }; +}; + +export default Error; diff --git a/targets/contributions/pages/admin/agreements/edit.js b/targets/contributions/pages/admin/agreements/edit.js new file mode 100644 index 000000000..8a183156c --- /dev/null +++ b/targets/contributions/pages/admin/agreements/edit.js @@ -0,0 +1,45 @@ +import withAdminEdit from "../../../src/templates/withAdminEdit"; + +const componentDidMount = async (api, id) => { + const agreements = await api.fetchAll("/agreements", { id }); + const allAgreements = await api.fetchAll("/agreements"); + + const fields = [ + { + label: "Nom", + name: "name", + type: "input", + }, + { + label: "IDCC", + name: "idcc", + type: "input", + }, + { + label: "Convention parente", + name: "parent_id", + options: allAgreements.map(({ id, idcc, name }) => ({ + label: `${idcc} - ${name}`, + value: id, + })), + type: "select", + }, + ]; + + return { + defaultData: agreements[0], + fields, + }; +}; + +const AdminAgreementsEditPage = withAdminEdit( + { + apiPath: "/agreements", + i18nIsFeminine: true, + i18nSubject: "convention", + indexPath: "/agreements", + }, + componentDidMount +); + +export default AdminAgreementsEditPage; diff --git a/targets/contributions/pages/admin/agreements/index.js b/targets/contributions/pages/admin/agreements/index.js new file mode 100644 index 000000000..a823afb41 --- /dev/null +++ b/targets/contributions/pages/admin/agreements/index.js @@ -0,0 +1,24 @@ +import React from "react"; + +import AdminIndex from "../../../src/components/AdminIndex"; +import shortenAgreementName from "../../../src/helpers/shortenAgreementName"; + +const COLUMNS = [ + { + Header: "Nom", + accessor: ({ name }) => shortenAgreementName(name), + id: "name", + }, + { + Header: "IDCC", + accessor: "idcc", + style: { textAlign: "center" }, + width: 64, + }, +]; + +const AdminAgreementIndexPage = () => ( + +); + +export default AdminAgreementIndexPage; diff --git a/targets/contributions/pages/admin/agreements/new.jsx b/targets/contributions/pages/admin/agreements/new.jsx new file mode 100644 index 000000000..495565f5f --- /dev/null +++ b/targets/contributions/pages/admin/agreements/new.jsx @@ -0,0 +1,72 @@ +import { useRouter } from "next/router"; +import React from "react"; + +import { addAgreement } from "../../../src/api"; +import AdminForm from "../../../src/components/AdminForm"; +import AdminMain from "../../../src/layouts/AdminMain"; +import toast from "../../../src/libs/toast"; +import { GraphQLApi } from "../../../src/libs/GraphQLApi"; + +export default function AdminNew() { + const [isLoading, setIsLoading] = React.useState(true); + const [fields, setFields] = React.useState([]); + const { back } = useRouter(); + + React.useEffect(() => { + async function init() { + const api = new GraphQLApi(); + const agreements = await api.fetchAll("/agreements"); + const fields = [ + { + label: "Nom", + name: "name", + type: "input", + }, + { + label: "IDCC", + name: "idcc", + type: "input", + }, + { + label: "Convention parente", + name: "parent_id", + options: agreements.map(({ id, idcc, name }) => ({ + label: `${idcc} - ${name}`, + value: id, + })), + type: "select", + }, + ]; + setFields(fields); + setIsLoading(false); + } + + init(); + }, []); + + const onSubmit = async (data) => { + try { + await addAgreement(data.name, data.idcc, data.parent_id); + back(); + } catch (e) { + toast.error(e.message); + } + }; + + return ( + <> + {isLoading ? ( + + ) : ( + + )} + + ); +} diff --git a/targets/contributions/pages/admin/answers-references/index.js b/targets/contributions/pages/admin/answers-references/index.js new file mode 100644 index 000000000..2af9e1e96 --- /dev/null +++ b/targets/contributions/pages/admin/answers-references/index.js @@ -0,0 +1,217 @@ +import styled from "@emotion/styled"; +import debounce from "lodash.debounce"; +import React from "react"; +import { connect } from "react-redux"; +import { Flex } from "rebass"; + +import * as actions from "../../../src/actions"; +import LegalReferenceTag from "../../../src/components/LegalReferences/Tag"; +import Pagination from "../../../src/components/Pagination"; +import * as C from "../../../src/constants"; +import Button from "../../../src/elements/Button"; +import Idcc from "../../../src/elements/Idcc"; +import Input from "../../../src/elements/Input"; +import LoadingSpinner from "../../../src/elements/LoadingSpinner"; +import Select from "../../../src/elements/Select"; +import Title from "../../../src/elements/Title"; +import AdminMainLayout from "../../../src/layouts/AdminMain"; +import customNumeral from "../../../src/libs/customNumeral"; +import T from "../../../src/texts"; + +const Container = styled(Flex)` + flex-grow: 1; + margin: 0 1rem 1rem; +`; + +const FiltersContainer = styled(Flex)` + border-bottom: solid 1px var(--color-border); + padding: 0.5rem 0; + + > * { + flex-grow: 0.25; + } +`; +const InfoContainer = styled(Flex)` + background-color: var(--color-alice-blue); + border-bottom: solid 1px var(--color-border); + padding: 0.5rem 1rem; +`; + +const List = styled(Flex)` + flex-grow: 1; + padding-right: 0.5rem; + min-height: 0; + overflow-y: auto; +`; +const ListRow = styled(Flex)` + background-color: white; + border: solid 1px var(--color-border); + border-radius: 0.4rem; + margin-top: 0.5rem; + padding: 0 0.75rem 0.5rem; +`; +export const OpenButton = styled(Button)` + font-size: 0.875rem; + + padding: 0.325rem 0.375rem 0.375rem 0.5rem; +`; + +export class AdminAnswersReferencesIndexPage extends React.Component { + get queryFilter() { + return this.$queryFilter !== undefined && this.$queryFilter !== null + ? this.$queryFilter.value + : ""; + } + + constructor(props) { + super(props); + + this.setCategoryFilter = this.setCategoryFilter.bind(this); + this.setPageIndex = this.setPageIndex.bind(this); + this.setQueryFilter = debounce(this.setQueryFilter, 250).bind(this); + this.setStatesFilter = this.setStatesFilter.bind(this); + } + + componentDidMount() { + this.props.dispatch(actions.answersReferences.setFilter("pageLength", 10)); + } + + open(answerId) { + window.open(`/admin/answers/${answerId}`, "_blank"); + } + + setPageIndex(pageIndex) { + const { answersReferences, dispatch } = this.props; + if (answersReferences.isLoading) return; + + dispatch(actions.answersReferences.load(pageIndex)); + } + + setQueryFilter() { + this.props.dispatch(actions.answersReferences.setFilter("query", this.queryFilter)); + } + + setCategoryFilter(selectedOption) { + this.props.dispatch(actions.answersReferences.setFilter("category", selectedOption.value)); + } + + setStatesFilter(_selectedOptions) { + const selectedOptions = _selectedOptions ?? []; + const nextStates = selectedOptions.map(({ value }) => value); + + this.props.dispatch(actions.answersReferences.setFilter("states", nextStates)); + } + + getSelectedCategoryOption() { + const { + answersReferences: { + filters: { category }, + }, + } = this.props; + + return C.ANSWER_REFERENCE_CATEGORY_OPTIONS.find(({ value }) => category.includes(value)); + } + + getSelectedStatesOptions() { + const { + answersReferences: { + filters: { states }, + }, + } = this.props; + + return C.ANSWER_REFERENCE_STATE_OPTIONS.filter(({ value }) => states.includes(value)); + } + + renderAnswerReferencesList() { + const { answers, list, isLoading } = this.props.answersReferences; + + if (list.length === 0) { + return ( + + {isLoading ? : T.ADMIN_ANSWERS_INFO_NO_DATA} + + ); + } + + return ( + + {list.map(answerReference => { + const answer = answers.find(({ id }) => id === answerReference.answer_id); + + return ( + + {answer.agreement !== undefined ? ( + + ) : ( + + )} + + + this.open(answerReference.answer_id)} + /> + + ); + })} + + ); + } + + render() { + const { answersReferences } = this.props; + + return ( + + + + Références légales + + + + (this.$queryFilter = node)} + /> + + + + + {`${customNumeral(answersReferences.length).format("0,0")} résultats.`} + + + {this.renderAnswerReferencesList()} + + + + + ); + } +} + +export default connect(({ answersReferences }) => ({ + answersReferences, +}))(AdminAnswersReferencesIndexPage); diff --git a/targets/contributions/pages/admin/answers/edit.js b/targets/contributions/pages/admin/answers/edit.js new file mode 100644 index 000000000..998a1721b --- /dev/null +++ b/targets/contributions/pages/admin/answers/edit.js @@ -0,0 +1,741 @@ +import styled from "@emotion/styled"; +import debounce from "lodash.debounce"; +import React from "react"; +import { connect } from "react-redux"; +import { Flex } from "rebass"; + +import * as actions from "../../../src/actions"; +import Comment from "../../../src/components/Comment"; +import LegalReferences from "../../../src/components/LegalReferences"; +import * as C from "../../../src/constants"; +import Button from "../../../src/elements/Button"; +import Hr from "../../../src/elements/Hr"; +import Icon from "../../../src/elements/Icon"; +import Idcc from "../../../src/elements/Idcc"; +import Input from "../../../src/elements/Input"; +import LoadingSpinner from "../../../src/elements/LoadingSpinner"; +import MarkdownEditor from "../../../src/elements/MarkdownEditor"; +import Radio from "../../../src/elements/Radio"; +import Select from "../../../src/elements/Select"; +import Subtitle from "../../../src/elements/Subtitle"; +import Textarea from "../../../src/elements/Textarea"; +import AdminMainLayout from "../../../src/layouts/AdminMain"; +import { api } from "../../../src/libs/GraphQLApi"; +import T from "../../../src/texts"; +import { + createAnswerReference, + deleteAnswerReference, + updateAnswersStates, +} from "../../../src/libs/graphql"; + +const Container = styled(Flex)` + height: 100%; +`; +const Content = styled(Flex)` + flex-grow: 1; + overflow-y: scroll; + padding: 1rem; +`; +const Sidebar = styled(Flex)` + display: ${({ isHidden }) => (isHidden ? "none" : "flex")}; + padding: 1rem; + position: relative; + right: 0; + min-width: 23rem; +`; + +const Question = styled(Subtitle)` + margin: 0; + user-select: text; +`; + +const Loaditor = styled(Flex)` + min-height: 30rem; +`; +const Preditor = styled(MarkdownEditor)` + border-right: 0 !important; + min-height: 30rem; + + .editor { + background-color: rgba(0, 0, 0, 0.025); + border: 0 !important; + cursor: text; + } + .editor-status { + display: none; + } + + .preview { + background-color: rgba(0, 0, 0, 0.025); + } +`; +const Editor = styled(MarkdownEditor)` + min-height: 30rem; + + .editor { + border: 0 !important; + } +`; +const Strong = styled.p` + font-weight: 600; + margin: ${(props) => (props.isFirst ? "0 0 0.5rem" : "1rem 0 0.5rem")}; +`; +const Form = styled.form` + display: flex; + flex-direction: column; +`; +const FormHiddenSubmit = styled.button` + height: 1px; + visibility: hidden; + width: 1px; +`; + +const Comments = styled(Flex)` + flex-grow: 1; + margin-top: 0.5rem; + max-height: 100%; + overflow-y: auto; +`; +const CommentEditor = styled(Textarea)` + background: ${({ isPrivate }) => + !isPrivate + ? "white" + : `repeating-linear-gradient( + 45deg, + #f4f4f4, + #f4f4f4 10px, + #ffffff 10px, + #ffffff 20px + )`}; + border-radius: 0.25rem; + font-size: 0.75rem; + opacity: ${(props) => (props.disabled ? 0.25 : 1)}; + margin-top: 1rem; + max-height: 10rem; + padding: 0.5rem; + width: 100%; +`; +const CommentEditorIcon = styled(Icon)` + align-self: flex-end; + cursor: pointer; + margin-top: 0.25rem; + opacity: 0.25; + + :hover { + opacity: 0.5; + } +`; + +export class AdminAnwsersEditPage extends React.Component { + constructor(props) { + super(props); + + this.state = { + agreementReferenceValueInputKey: 0, + isFirstLoad: true, + isSidebarHidden: true, + otherReferenceUrlInputKey: 0, + otherReferenceValueInputKey: 2, + selectedAgreementIdcc: null, + }; + + this.isGeneric = Boolean(props.isGeneric); + this.originalPrevalue = ""; + this.originalValue = ""; + + this.updateAnswerValue = debounce(this._updateAnswerValue.bind(this), 500); + this.loadLegalReferences = debounce( + this.loadLegalReferences.bind(this), + 250 + ); + } + + componentDidMount() { + const { dispatch, id: answerId } = this.props; + + dispatch(actions.answers.loadOne(answerId)); + dispatch(actions.comments.load(answerId)); + } + + componentDidUpdate() { + const { answers, comments } = this.props; + const { isFirstLoad } = this.state; + + if (isFirstLoad && !answers.isLoading) { + const { prevalue, value } = answers.data; + this.originalPrevalue = prevalue; + this.originalValue = value; + + this.setState({ + isFirstLoad: false, + selectedAgreementIdcc: !this.isGeneric + ? answers.data.agreement.idcc + : null, + }); + } + + if (!answers.isLoading && !comments.isLoading) { + this.$commentsContainer.scrollTo(0, this.$commentsContainer.scrollHeight); + } + } + + load() { + const { dispatch, id: answerId } = this.props; + + dispatch(actions.answers.loadOne(answerId)); + dispatch(actions.comments.load(answerId)); + } + + getReferences(category) { + const { answers } = this.props; + + if (answers.isLoading) { + return []; + } + + return answers.data.references.filter( + (reference) => reference.category === category + ); + } + + async _updateAnswerValue({ source }) { + try { + const value = source.trim(); + const { state } = this.props.answers.data; + const data = { value }; + + // An answer can't have a "to do" state with a non-empty value: + if (state === C.ANSWER_STATE.TO_DO && value.length > 0) { + data.state = C.ANSWER_STATE.UNDER_REVIEW; + } + + await api.update(updateAnswersStates, { data, ids: [this.props.id] }); + } catch (err) { + console.warn(err); + } + } + + updateAnswerState({ value }) { + const { id } = this.props; + + this.props.dispatch( + actions.answers.updateState([id], value, () => window.location.reload()) + ); + } + + updateGenericReference(generic_reference) { + const { dispatch, id } = this.props; + + dispatch( + actions.answers.updateGenericReference( + [id], + generic_reference, + this.load.bind(this) + ) + ); + } + + async createReference(reference) { + try { + const { state } = this.props.answers.data; + + // An answer can't have a reference and be generic at the same time: + const answersData = { + generic_reference: null, + }; + + // An answer can't have a "to do" state with a reference: + if (state === C.ANSWER_STATE.TO_DO) { + answersData.state = C.ANSWER_STATE.UNDER_REVIEW; + } + + const answersReferencesData = { + answer_id: this.props.id, + ...reference, + }; + + await api.update(updateAnswersStates, { + data: answersData, + ids: [this.props.id], + }); + await api.create(createAnswerReference, answersReferencesData); + } catch (err) { + console.warn(err); + } + + await this.load(); + } + + async deleteReference(id) { + try { + await api.delete(deleteAnswerReference, { ids: [id] }); + } catch (err) { + console.warn(err); + } + + await this.load(); + } + + submitReference(event, category = null) { + event.preventDefault(); + + let reference; + if (category === "agreement") { + reference = { + category, + value: this.$agreementReferenceValueInput.value.trim(), + }; + } else { + const url = this.$otherReferenceUrlInput.value.trim(); + reference = { + category, + url: url.length !== 0 ? url : null, + value: this.$otherReferenceValueInput.value.trim(), + }; + + this.setState({ + otherReferenceUrlInputKey: this.state.otherReferenceUrlInputKey + 1, + otherReferenceValueInputKey: this.state.otherReferenceValueInputKey + 1, + }); + } + + this.createReference(reference); + } + + toggleSidebar() { + this.setState({ isSidebarHidden: !this.state.isSidebarHidden }); + } + + handleCommentField(event) { + if (event.which !== 13) return; + + if (event.shiftKey) { + event.preventDefault(); + this.props.dispatch(actions.comments.toggleOnePrivacy()); + + return; + } + + const value = this.$commentTextarea.value.trim(); + if (value.length === 0) return; + + if (event.ctrlKey) { + this.props.dispatch( + actions.comments.addOne( + value, + this.props.comments.currentIsPrivate, + this.props.id + ) + ); + } + } + + removeComment(id) { + this.props.dispatch(actions.comments._delete([id], this.props.id)); + } + + loadLegalReferences(category, query) { + const { dispatch, legalReferences } = this.props; + const { selectedAgreementIdcc } = this.state; + if (legalReferences.isLoading) return; + + if (category === C.LEGAL_REFERENCE_CATEGORY.AGREEMENT) { + dispatch( + actions.legalReferences.load(category, query, selectedAgreementIdcc) + ); + + return; + } + + dispatch(actions.legalReferences.load(category, query)); + } + + async addReference(category, legalReference) { + const { dispatch, id: answer_id } = this.props; + + const answerReference = { + answer_id, + category, + dila_cid: legalReference.cid, + dila_container_id: legalReference.containerId, + dila_id: legalReference.id, + value: legalReference.name, + }; + + dispatch( + actions.answers.addReferences([answerReference], this.load.bind(this)) + ); + } + + updateReference(data) { + const { dispatch } = this.props; + + dispatch(actions.answers.updateReferences([data], this.load.bind(this))); + } + + removeReference(answerReferenceId) { + const { dispatch } = this.props; + + dispatch( + actions.answers.removeReferences( + [answerReferenceId], + this.load.bind(this) + ) + ); + } + + renderTop() { + const { answers, comments } = this.props; + const { isFirstLoad } = this.state; + + const stateActionDefaultValue = !isFirstLoad + ? C.ANSWER_STATE_OPTIONS.find(({ value }) => value === answers.data.state) + : undefined; + + return ( + + (this.$otherReferenceValueInput = node)} + /> + (this.$otherReferenceUrlInput = node)} + style={{ marginTop: "0.5rem" }} + /> + + + )} + this.addReference(null, data)} + onChange={this.updateReference.bind(this)} + onRemove={this.removeReference.bind(this)} + references={this.getReferences(null)} + /> + + + ); + } + + renderGenericReference() { + const { answers } = this.props; + const { isFirstLoad } = this.state; + + if (isFirstLoad) { + return null; + } + + const { data, isLoading } = answers; + const { state, generic_reference } = data; + + if ( + [C.ANSWER_STATE.VALIDATED, C.ANSWER_STATE.PUBLISHED].includes(state) && + generic_reference === null + ) { + return null; + } + + return ( +
+
+ Renvoi + {![C.ANSWER_STATE.VALIDATED, C.ANSWER_STATE.PUBLISHED].includes( + state + ) && ( + + )} +
+ ); + } + + renderSidebar() { + const { comments } = this.props; + + const { isSidebarHidden } = this.state; + + return ( + + Commentaires + (this.$commentsContainer = node)} + > + {this.renderComments()} + + + (this.$commentTextarea = node)} + rows={10} + /> + + this.props.dispatch(actions.comments.toggleOnePrivacy()) + } + /> + + + ); + } + + renderComments() { + const { comments } = this.props; + + return comments.list.map(({ id, is_private, value }, index) => ( + this.removeComment(id)} + value={value} + /> + )); + } + + render() { + return ( + + + + {this.renderTop()} +
+ {this.renderHead()} +
+ + {this.renderEditor()} +
+ + {this.renderReferences()} + + {this.renderGenericReference()} +
+ + {this.renderSidebar()} +
+
+ ); + } +} + +export default connect(({ answers, comments, legalReferences }) => ({ + answers, + comments, + legalReferences, +}))(AdminAnwsersEditPage); diff --git a/targets/contributions/pages/admin/answers/index.js b/targets/contributions/pages/admin/answers/index.js new file mode 100644 index 000000000..415ee6ae6 --- /dev/null +++ b/targets/contributions/pages/admin/answers/index.js @@ -0,0 +1,287 @@ +import styled from "@emotion/styled"; +import debounce from "lodash.debounce"; +import Router from "next/router"; +import React from "react"; +import { connect } from "react-redux"; +import { Flex } from "rebass"; + +import * as actions from "../../../src/actions"; +import Answer from "../../../src/components/Answer"; +import Pagination from "../../../src/components/Pagination"; +import * as C from "../../../src/constants"; +import Button from "../../../src/elements/Button"; +import Checkbox from "../../../src/elements/Checkbox"; +import Input from "../../../src/elements/Input"; +import LoadingSpinner from "../../../src/elements/LoadingSpinner"; +import Select from "../../../src/elements/Select"; +import Title from "../../../src/elements/Title"; +import AdminMainLayout from "../../../src/layouts/AdminMain"; +import T from "../../../src/texts"; + +const Container = styled(Flex)` + flex-grow: 1; + margin: 0 1rem 1rem; +`; + +const List = styled(Flex)` + flex-grow: 1; + padding-right: 1rem; + min-height: 0; + overflow-y: auto; +`; + +const FiltersContainer = styled(Flex)` + border-bottom: solid 1px var(--color-border); + padding: 0.5rem 0; + + > * { + flex-grow: 0.25; + } +`; + +const ActionsContainer = styled(Flex)` + background-color: var(--color-alice-blue); + border-bottom: solid 1px var(--color-border); + padding: 0.5rem 0.5rem 0.5rem 1rem; +`; + +export class AdminAnswersIndexPage extends React.Component { + get queryFilter() { + return this.$queryFilter !== undefined && this.$queryFilter !== null + ? this.$queryFilter.value + : ""; + } + + constructor(props) { + super(props); + + this.setPageIndex = this.setPageIndex.bind(this); + this.setQueryFilter = debounce(this.setQueryFilter, 250).bind(this); + } + + componentDidMount() { + const { isGeneric } = this.props; + + this.props.dispatch(actions.agreements.load()); + this.props.dispatch(actions.questions.load()); + this.props.dispatch( + actions.answers.setFilters({ + isGeneric, + }), + ); + } + + getCheckableAnswerIds() { + const { answers } = this.props; + + return answers.list.map(({ id }) => id).filter(id => !answers.checked.includes(id)); + } + + setAgreeementsFilter(selected) { + const agreements = selected !== null ? selected : []; + this.props.dispatch(actions.answers.setFilter("agreements", agreements)); + } + + setPageIndex(pageIndex) { + const { answers, dispatch } = this.props; + if (answers.isLoading) return; + + dispatch(actions.answers.load(pageIndex)); + } + + setQuestionsFilter(selected) { + const questions = selected !== null ? selected : []; + this.props.dispatch(actions.answers.setFilter("questions", questions)); + } + + setQueryFilter() { + this.props.dispatch(actions.answers.setFilter("query", this.queryFilter)); + } + + setStatesFilter(selected) { + const states = selected !== null ? selected : []; + this.props.dispatch(actions.answers.setFilter("states", states)); + } + + check(id) { + this.props.dispatch(actions.answers.toggleCheck([id])); + } + + checkAll() { + const { dispatch } = this.props; + const ids = this.getCheckableAnswerIds(); + + dispatch(actions.answers.toggleCheck(ids)); + } + + uncheckAll() { + const { dispatch, answers } = this.props; + + dispatch(actions.answers.toggleCheck(answers.checked)); + } + + setCheckedAnswersState() { + const { checked } = this.props.answers; + const { value } = this.$newState.state.value; + + this.props.dispatch( + actions.answers.updateState(checked, value, () => + this.props.dispatch(actions.answers.load()), + ), + ); + } + + printAnswers() { + const { isGeneric } = this.props; + const path = isGeneric ? "generic-answers" : "answers"; + + window.open(`/admin/${path}/print`, "_blank"); + } + + editAnswer(id) { + const path = this.props.isGeneric ? "generic-answers" : "answers"; + + if (process.env.NODE_ENV !== "development") { + window.open(`/admin/${path}/${id}`, "_blank"); + + return; + } + + Router.push(`/admin/${path}/${id}`); + } + + renderAnswersList() { + const { checked, list, isLoading } = this.props.answers; + + if (list.length === 0) { + return ( + + {isLoading ? : T.ADMIN_ANSWERS_INFO_NO_DATA} + + ); + } + + return ( + + {list.map(answer => ( + + ))} + + ); + } + + render() { + const { agreements, answers, isGeneric, questions } = this.props; + + const isLoading = isGeneric + ? answers.isLoading + : agreements.isLoading || answers.isLoading || questions.isLoading; + const stateFilterAgreements = agreements.list.map(({ id, idcc, name }) => ({ + label: `[${idcc}] ${name}`, + value: id, + })); + const stateFilterQuestions = questions.list.map(({ id, index, value }) => ({ + label: `${index}) ${value}`, + value: id, + })); + + return ( + + + + {`Réponses${isGeneric ? " génériques" : ""}`} + + + + + {/* Filters */} + {!isGeneric && ( + + (this.$queryFilter = node)} + /> + + (this.$newState = node)} + /> + + + + + {this.renderAnswersList()} + + + + + ); + } +} + +export default connect(({ agreements, answers, questions }) => ({ + agreements, + answers, + isGeneric: false, + questions, +}))(AdminAnswersIndexPage); diff --git a/targets/contributions/pages/admin/answers/print.js b/targets/contributions/pages/admin/answers/print.js new file mode 100644 index 000000000..835bc57ef --- /dev/null +++ b/targets/contributions/pages/admin/answers/print.js @@ -0,0 +1,95 @@ +import styled from "@emotion/styled"; +import React from "react"; +import { connect } from "react-redux"; + +import * as actions from "../../../src/actions"; +import markdown from "../../../src/libs/markdown"; + +const Container = styled.div` + height: 100vh; + overflow-y: auto; + padding: 1rem; + + @media print { + height: auto; + padding: 0; + } +`; + +const Answer = styled.div` + @media print { + page-break-inside: avoid; + } +`; +const Content = styled.p` + font-size: 1.1rem; + margin-bottom: 1rem; + white-space: normal; +`; +const List = styled.ul` + font-size: 0.875rem; + list-style-type: square; + padding-inline-start: 0; + padding-left: 1.5rem; +`; + +export class AdminAnswersPrintPage extends React.Component { + componentDidMount() { + const { isGeneric } = this.props; + + this.props.dispatch( + actions.answers.setFilters({ + isGeneric, + pageLength: 100, + }), + ); + } + + componentDidUpdate() { + const { isLoading } = this.props.answers; + + if (!isLoading) window.print(); + } + + renderValue(value) { + return { __html: markdown.toHtml(value) }; + } + + renderAnswers() { + const { list } = this.props.answers; + + return list.map(({ agreement_idcc, id, question_index, question_value, references, value }) => ( + +

{`[IDCC: ${agreement_idcc}] ${question_index}) ${question_value}`}

+

Réponse corrigée:

+ + {references.length !== 0 && ( +
+

Références:

+ + {references.map(({ id, value }) => ( +
  • {value}
  • + ))} +
    +
    + )} +
    +
    + )); + } + + render() { + const { isLoading } = this.props.answers; + + if (isLoading) { + return Chargement…; + } + + return {this.renderAnswers()}; + } +} + +export default connect(({ answers }) => ({ + answers, + isGeneric: false, +}))(AdminAnswersPrintPage); diff --git a/targets/contributions/pages/admin/generic-answers/edit.js b/targets/contributions/pages/admin/generic-answers/edit.js new file mode 100644 index 000000000..e4914b4d5 --- /dev/null +++ b/targets/contributions/pages/admin/generic-answers/edit.js @@ -0,0 +1,12 @@ +import { connect } from "react-redux"; + +import { AdminAnwsersEditPage } from "../answers/edit"; + +class AdminGenericAnwsersEditPage extends AdminAnwsersEditPage {} + +export default connect(({ answers, comments, legalReferences }) => ({ + answers, + comments, + isGeneric: true, + legalReferences, +}))(AdminGenericAnwsersEditPage); diff --git a/targets/contributions/pages/admin/generic-answers/index.js b/targets/contributions/pages/admin/generic-answers/index.js new file mode 100644 index 000000000..66cb14ef1 --- /dev/null +++ b/targets/contributions/pages/admin/generic-answers/index.js @@ -0,0 +1,12 @@ +import { connect } from "react-redux"; + +import { AdminAnswersIndexPage } from "../answers/index"; + +class AdminGenericAnswersIndexPage extends AdminAnswersIndexPage {} + +export default connect(({ agreements, answers, questions }) => ({ + agreements, + answers, + isGeneric: true, + questions, +}))(AdminGenericAnswersIndexPage); diff --git a/targets/contributions/pages/admin/generic-answers/print.js b/targets/contributions/pages/admin/generic-answers/print.js new file mode 100644 index 000000000..4ea82f75f --- /dev/null +++ b/targets/contributions/pages/admin/generic-answers/print.js @@ -0,0 +1,10 @@ +import { connect } from "react-redux"; + +import { AdminAnswersPrintPage } from "../answers/print"; + +class AdminGenericAnswersPrintPage extends AdminAnswersPrintPage {} + +export default connect(({ answers }) => ({ + answers, + isGeneric: true, +}))(AdminGenericAnswersPrintPage); diff --git a/targets/contributions/pages/admin/index.js b/targets/contributions/pages/admin/index.js new file mode 100644 index 000000000..cc44b3a74 --- /dev/null +++ b/targets/contributions/pages/admin/index.js @@ -0,0 +1,354 @@ +import styled from "@emotion/styled"; +import React from "react"; +import { Flex } from "rebass"; + +import { ANSWER_STATE } from "../../src/constants"; +import Button from "../../src/elements/Button"; +import ContentTitle from "../../src/elements/ContentTitle"; +import Subtitle from "../../src/elements/Subtitle"; +import _Table from "../../src/elements/Table"; +import Title from "../../src/elements/Title"; +import shortenAgreementName from "../../src/helpers/shortenAgreementName"; +import AdminMainLayout from "../../src/layouts/AdminMain"; +import numeral from "../../src/libs/customNumeral"; +import { GraphQLApi } from "../../src/libs/GraphQLApi"; +import { getAnswerStats, getLocationStats } from "../../src/libs/graphql"; + +// TODO Clean these columns. +/* eslint-disable react/display-name */ +const COLUMNS = [ + { + Cell: ({ value }) => {value}, + Header: "Nom", + accessor: "name", + }, + { + Cell: ({ value }) => (value === -1 ? "…" : numeral(value).format("0,0")), + Header: "À rédiger", + accessor: "todo", + }, + { + Cell: ({ value }) => (value === -1 ? "…" : numeral(value).format("0,0")), + Header: "En cours de rédaction", + accessor: "draft", + }, + { + Cell: ({ value }) => (value === -1 ? "…" : numeral(value).format("0,0")), + Header: "À valider", + accessor: "pendingReview", + }, + { + Cell: ({ value }) => (value === -1 ? "…" : numeral(value).format("0,0")), + Header: "En cours de validation", + accessor: "underReview", + }, + { + Cell: ({ value }) => (value === -1 ? "…" : numeral(value).format("0,0")), + Header: "Validées", + accessor: "validated", + }, + { + Cell: ({ value }) => (value === -1 ? "…" : numeral(value).format("0,0")), + Header: "Publiées", + accessor: "published", + }, +]; +const PERCENTAGE_COLUMNS = [ + { ...COLUMNS[0] }, + { + ...COLUMNS[1], + Cell: ({ value }) => (value === -1 ? "…" : numeral(value).format("0.00%")), + }, + { + ...COLUMNS[2], + Cell: ({ value }) => (value === -1 ? "…" : numeral(value).format("0.00%")), + }, + { + ...COLUMNS[3], + Cell: ({ value }) => (value === -1 ? "…" : numeral(value).format("0.00%")), + }, + { + ...COLUMNS[4], + Cell: ({ value }) => (value === -1 ? "…" : numeral(value).format("0.00%")), + }, + { + ...COLUMNS[5], + Cell: ({ value }) => (value === -1 ? "…" : numeral(value).format("0.00%")), + }, + { + ...COLUMNS[6], + Cell: ({ value }) => (value === -1 ? "…" : numeral(value).format("0.00%")), + }, +]; +/* eslint-enable react/display-name */ + +const Container = styled(Flex)` + margin: 0 1rem 1rem; +`; +const Table = styled(_Table)` + font-size: 0.875rem; + + .rt-tr > .rt-th, + .rt-tr > .rt-td { + :first-of-type { + width: 30% !important; + } + :not(:first-of-type) { + width: 10% !important; + } + } + + .rt-tr > .rt-td { + :first-of-type { + cursor: help; + } + :not(:first-of-type) { + text-align: right; + } + } +`; + +const REFRESH_DELAY = 30000; + +const StatsTable = ({ data, isPercentage, ...props }) => ( + +); + +export default class AdminIndexPage extends React.Component { + constructor(props) { + super(props); + + this.state = { + agreementsStats: [], + globalStats: [], + isCalculating: true, + isLoading: true, + isPercentage: true, + }; + } + + async componentDidMount() { + this.isUmounted = false; + this.api = new GraphQLApi(); + + await this.initializeStats(); + await this.updateStats(); + } + + componentWillUnmount() { + this.isUmounted = true; + + if (this.timeout === undefined) { + return; + } + + clearTimeout(this.timeout); + } + + async fetchLocations() { + const data = await this.api.fetch(getLocationStats); + return data.map((item) => ({ + ...item, + agreements: item.agreements.map((element) => ({ ...element.agreement })), + })); + } + + async fetchAnswersForAgreement(agreementId) { + const answers = await this.api.fetch(getAnswerStats, { + agreement_id: agreementId, + }); + return answers; + } + + async initializeStats() { + const locations = await this.fetchLocations(); + + const agreementsStats = locations + .reduce((prev, { agreements }) => [...prev, ...agreements], []) + .map(({ id, idcc, name, parent_id }) => ({ + id, + isNational: parent_id === null, + name: `[${idcc}] ${shortenAgreementName(name)}`, + totals: [0, 0, 0, 0, 0, 0, 0], + })); + + this.setState({ + agreementsStats, + isLoading: false, + }); + } + + async updateStats() { + const { agreementsStats } = this.state; + + const nextAgreementsStats = []; + for (const agreementStatsEntry of agreementsStats) { + if (this.isUmounted) { + break; + } + + const { id: agreementId } = agreementStatsEntry; + const answers = await this.fetchAnswersForAgreement(agreementId); + + const totals = answers.reduce( + (totals, { state }) => { + switch (state) { + case ANSWER_STATE.TO_DO: + totals[0] += 1; + break; + + case ANSWER_STATE.DRAFT: + totals[1] += 1; + break; + + case ANSWER_STATE.PENDING_REVIEW: + totals[2] += 1; + break; + + case ANSWER_STATE.UNDER_REVIEW: + totals[3] += 1; + break; + + case ANSWER_STATE.VALIDATED: + totals[4] += 1; + break; + + case ANSWER_STATE.PUBLISHED: + totals[5] += 1; + break; + } + + totals[6] += 1; + + return totals; + }, + [0, 0, 0, 0, 0, 0, 0] + ); + + nextAgreementsStats.push({ + ...agreementStatsEntry, + totals, + }); + } + + const nextGlobalStats = nextAgreementsStats.reduce( + (globalTotals, { totals }) => [ + globalTotals[0] + totals[0], + globalTotals[1] + totals[1], + globalTotals[2] + totals[2], + globalTotals[3] + totals[3], + globalTotals[4] + totals[4], + globalTotals[5] + totals[5], + globalTotals[6] + totals[6], + ], + [0, 0, 0, 0, 0, 0, 0] + ); + + if (this.isUmounted) { + return; + } + + this.setState({ + agreementsStats: nextAgreementsStats, + globalStats: nextGlobalStats, + isCalculating: false, + }); + + this.timeout = setTimeout(this.updateStats.bind(this), REFRESH_DELAY); + } + + generateDataRow(name, stats, isCalculating) { + const { isPercentage } = this.state; + + if (isCalculating) { + return { + draft: -1, + name, + pendingReview: -1, + published: -1, + todo: -1, + underReview: -1, + validated: -1, + }; + } + + return { + draft: isPercentage ? stats[1] / stats[6] : stats[1], + name, + pendingReview: isPercentage ? stats[2] / stats[6] : stats[2], + published: isPercentage ? stats[5] / stats[6] : stats[5], + todo: isPercentage ? stats[0] / stats[6] : stats[0], + underReview: isPercentage ? stats[3] / stats[6] : stats[3], + validated: isPercentage ? stats[4] / stats[6] : stats[4], + }; + } + + renderGlobalStats() { + const { globalStats, isCalculating, isPercentage } = this.state; + const data = [this.generateDataRow("Total", globalStats, isCalculating)]; + + return ( + + ); + } + + renderAgreementsStats(isNational = false) { + const { agreementsStats, isCalculating, isPercentage } = this.state; + const data = agreementsStats + .filter((agreement) => agreement.isNational === isNational) + .map(({ name, totals }) => + this.generateDataRow(name, totals, isCalculating) + ); + + return ( + + ); + } + + render() { + const { isLoading, isPercentage } = this.state; + + return ( + + + + Tableau de bord + + + + Global + {isLoading ?

    Calcul en cours…

    : this.renderGlobalStats()} + + Par convention collective + Conventions nationales + {isLoading ? ( +

    Calcul en cours…

    + ) : ( + this.renderAgreementsStats(true) + )} + Conventions locales + {isLoading ?

    Calcul en cours…

    : this.renderAgreementsStats()} +
    +
    + ); + } +} diff --git a/targets/contributions/pages/admin/legacy-tracker/agreement.js b/targets/contributions/pages/admin/legacy-tracker/agreement.js new file mode 100644 index 000000000..dc0b33043 --- /dev/null +++ b/targets/contributions/pages/admin/legacy-tracker/agreement.js @@ -0,0 +1,174 @@ +import styled from "@emotion/styled"; +import React from "react"; +import { Flex } from "rebass"; + +import LegalReferenceTag from "../../../src/components/LegalReferences/Tag"; +import * as C from "../../../src/constants"; +import Button from "../../../src/elements/Button"; +import Idcc from "../../../src/elements/Idcc"; +import LoadingSpinner from "../../../src/elements/LoadingSpinner"; +import Title from "../../../src/elements/Title"; +import shortenAgreementName from "../../../src/helpers/shortenAgreementName"; +import AdminMainLayout from "../../../src/layouts/AdminMain"; +import cdtnApi from "../../../src/libs/cdtnApi"; +import customPostgrester from "../../../src/libs/customPostgrester"; +import T from "../../../src/texts"; + +const Container = styled(Flex)` + margin: 0 1rem 1rem; +`; + +const List = styled(Flex)` + flex-grow: 1; + padding-right: 0.5rem; + min-height: 0; + overflow-y: auto; +`; +const ListRow = styled(Flex)` + background-color: white; + border: solid 1px var(--color-border); + border-radius: 0.4rem; + margin-top: 0.5rem; + padding: 0 0.75rem 0.5rem; +`; +export const OpenButton = styled(Button)` + font-size: 0.875rem; + + padding: 0.325rem 0.375rem 0.375rem 0.5rem; +`; + +class AdminTrackerAgreementIdPage extends React.Component { + constructor(props) { + super(props); + + this.state = { + agreement: null, + answers: [], + answersReferences: [], + isLoading: true, + }; + } + + async componentDidMount() { + this.postgrest = customPostgrester(); + + const { id: agreementId } = this.props; + const { data: agreements } = await this.postgrest.eq("id", agreementId).get("/agreements"); + const agreement = agreements[0]; + + this.setState({ agreement }); + + const { data: answers } = await this.postgrest + .select("*") + .select("question(index)") + .eq("agreement_id", agreementId) + .orderBy("question.index") + .get("/answers"); + + this.setState({ answers }); + + const answerIds = answers.map(({ id }) => id); + + const { data: answersReferences } = await this.postgrest + .in("answer_id", answerIds) + .get("/answers_references"); + + this.setState({ + answersReferences, + isLoading: false, + }); + } + + async fetchAnswersForAgreement(agreementId) { + const { data: answers } = await this.postgrest.eq("agreement_id", agreementId).get("/answers"); + + return answers; + } + + async findObsoleteAnswersReferences(answersReferences) { + // TODO Remove `dila_cid !== null` check once all the references are cleaned. + const localAgreementAnswersReferences = answersReferences.filter( + ({ category, dila_cid }) => + category === C.ANSWER_REFERENCE_CATEGORY.AGREEMENT && dila_cid !== null, + ); + + const obsoleteAgreementAnswersReference = []; + for (const localAgreementAnswerReference of localAgreementAnswersReferences) { + try { + const { dila_id } = localAgreementAnswerReference; + await cdtnApi.get(`/agreement/articles?articleIdsOrCids=${dila_id}`); + } catch (err) { + obsoleteAgreementAnswersReference.push(localAgreementAnswerReference); + } + } + + return [...obsoleteAgreementAnswersReference]; + } + + open(answerId) { + window.open(`/admin/answers/${answerId}`, "_blank"); + } + + renderAnswerReferences() { + const { answers, answersReferences, isLoading } = this.state; + + if (isLoading) { + return ( + + + + ); + } + + if (answersReferences.length === 0) { + return ( + + {T.ADMIN_TRACKER_INFO_NO_DATA} + + ); + } + + return ( + + {answersReferences.map(answerReference => { + const answer = answers.find(({ id }) => id === answerReference.answer_id); + + return ( + + + + this.open(answerReference.answer_id)} + /> + + ); + })} + + ); + } + + render() { + const { agreement } = this.state; + + return ( + + + + {agreement === null && Tableau de veille » …} + {agreement !== null && ( + {`Tableau de veille » [${agreement.idcc}] ${shortenAgreementName( + agreement.name, + )}`} + )} + + + {this.renderAnswerReferences()} + + + ); + } +} + +export default AdminTrackerAgreementIdPage; diff --git a/targets/contributions/pages/admin/legacy-tracker/index.js b/targets/contributions/pages/admin/legacy-tracker/index.js new file mode 100644 index 000000000..d30d3e7f6 --- /dev/null +++ b/targets/contributions/pages/admin/legacy-tracker/index.js @@ -0,0 +1,239 @@ +import styled from "@emotion/styled"; +import React from "react"; +import { Flex } from "rebass"; + +import * as C from "../../../src/constants"; +import LoadingSpinner from "../../../src/elements/LoadingSpinner"; +import _Table from "../../../src/elements/Table"; +import Title from "../../../src/elements/Title"; +import shortenAgreementName from "../../../src/helpers/shortenAgreementName"; +import AdminMainLayout from "../../../src/layouts/AdminMain"; +import cdtnApi from "../../../src/libs/cdtnApi"; +import numeral from "../../../src/libs/customNumeral"; +import customPostgrester from "../../../src/libs/customPostgrester"; + +const Container = styled(Flex)` + margin: 0 1rem 1rem; +`; + +const Table = styled(_Table)` + display: fles; + flex-grow: 1; + font-size: 0.875rem; + margin-top: 0.5rem; + overflow-y: auto; + + .rt-tr > .rt-th, + .rt-tr > .rt-td { + :first-of-type { + width: 50% !important; + } + :not(:first-of-type) { + width: 50% !important; + } + } + + .rt-tr > .rt-td { + :first-of-type { + cursor: pointer; + } + :not(:first-of-type) { + text-align: right; + } + } +`; + +// TODO Clean these columns. +/* eslint-disable react/display-name */ +const DASHBOARD_TABLE_COLUMNS = [ + { + Cell: ({ row, value }) => { + return ( + window.open(`/admin/tracker/${row._original.id}`)} + onKeyPress={() => window.open(`/admin/tracker/${row._original.id}`)} + role="link" + tabIndex="0" + title={value} + > + {value} + + ); + }, + Header: "Convention", + accessor: "name", + }, + { + Cell: ({ value }) => (value === -1 ? "…" : numeral(value).format("0,0")), + Header: "Nombre de références obsolètes", + accessor: "total", + }, +]; +/* eslint-enable react/display-name */ + +class AdminTrackerPage extends React.Component { + constructor(props) { + super(props); + + this.state = { + answersReferencesStats: [], + isLoading: true, + selectedAgreementOption: null, + }; + } + + async componentDidMount() { + this.isUmounted = false; + this.postgrest = customPostgrester(); + + await this.initializeStats(); + await this.updateStats(); + } + + componentWillUnmount() { + this.isUmounted = true; + } + + async initializeStats() { + const { data: locations } = await this.postgrest + .select("*") + .select("agreements(id,idcc,name,parent_id)") + .get("/locations"); + + const answersReferencesStats = locations + .reduce((prev, { agreements }) => [...prev, ...agreements], []) + .filter(({ parent_id }) => parent_id === null) + .map(({ id, idcc, name }) => ({ + id, + name: `[${idcc}] ${shortenAgreementName(name)}`, + total: -1, + })); + + this.setState({ + answersReferencesStats: [ + { + id: null, + name: `[Code du travail] Réponses génériques`, + total: -1, + }, + ...answersReferencesStats, + ], + isLoading: false, + }); + } + + async fetchAnswersForAgreement(agreementId) { + const { data: answers } = await this.postgrest.eq("agreement_id", agreementId).get("/answers"); + + return answers; + } + + async findObsoleteAnswersReferences(answersReferences) { + // TODO Remove `dila_cid !== null` check once all the references are cleaned. + const localAgreementAnswersReferences = answersReferences.filter( + ({ category, dila_cid }) => + category === C.ANSWER_REFERENCE_CATEGORY.AGREEMENT && dila_cid !== null, + ); + const localCodeAnswersReferences = answersReferences.filter( + ({ category, dila_cid }) => + category === C.ANSWER_REFERENCE_CATEGORY.LABOR_CODE && dila_cid !== null, + ); + + const obsoleteAgreementAnswersReference = []; + for (const localAgreementAnswerReference of localAgreementAnswersReferences) { + try { + const { dila_id } = localAgreementAnswerReference; + await cdtnApi.get(`/agreement/articles?articleIdsOrCids=${dila_id}`); + } catch (err) { + obsoleteAgreementAnswersReference.push(localAgreementAnswerReference); + } + } + const obsoleteCodeAnswersReference = []; + for (const localCodeAnswerReference of localCodeAnswersReferences) { + try { + const { dila_id } = localCodeAnswerReference; + await cdtnApi.get(`/code/articles?articleIdsOrCids=${dila_id}`); + } catch (err) { + obsoleteAgreementAnswersReference.push(localCodeAnswerReference); + } + } + + return [...obsoleteAgreementAnswersReference, ...obsoleteCodeAnswersReference]; + } + + async updateStats() { + const { answersReferencesStats } = this.state; + + const { length } = answersReferencesStats; + let index = -1; + while (++index < length) { + if (this.isUmounted) { + break; + } + + const { answersReferencesStats } = this.state; + const nextAnswersReferencesStats = [...answersReferencesStats]; + const { id: agreementId } = answersReferencesStats[index]; + + const { data: answers } = + agreementId !== null + ? await this.postgrest.eq("agreement_id", agreementId).get("/answers") + : await this.postgrest.is("agreement_id", null).get("/answers"); + + const answerIds = answers.map(({ id }) => id); + const { data: answersReferences } = await this.postgrest + .in("answer_id", answerIds) + .get("/answers_references"); + + const obsoleteAnswersReferences = await this.findObsoleteAnswersReferences(answersReferences); + + nextAnswersReferencesStats[index].total = obsoleteAnswersReferences.length; + + if (this.isUmounted) { + return; + } + + this.setState({ + answersReferencesStats: nextAnswersReferencesStats, + }); + } + } + + renderDashboard() { + const { answersReferencesStats, isLoading } = this.state; + + if (isLoading) { + return ( + + + + ); + } + + return ( +
    + ); + } + + render() { + return ( + + + Tableau de veille + {this.renderDashboard()} + + + ); + } +} + +export default AdminTrackerPage; diff --git a/targets/contributions/pages/admin/locations/edit.js b/targets/contributions/pages/admin/locations/edit.js new file mode 100644 index 000000000..fc208c03d --- /dev/null +++ b/targets/contributions/pages/admin/locations/edit.js @@ -0,0 +1,48 @@ +import withAdminEdit from "../../../src/templates/withAdminEdit"; +import { FIELDS } from "./new"; + +const componentDidMount = async (api, id) => { + const { data: locations } = await api + .eq("id", id) + .select("*") + .select("agreements(*)") + .get("/locations"); + const { data: agreements } = await api.get("/agreements"); + + const defaultData = { + ...locations[0], + agreements: locations[0].agreements.map(agreement => ({ + id: agreement.id, + value: `${agreement.idcc} - ${agreement.name}`, + })), + }; + + const fields = [ + ...FIELDS, + { + apiPath: "/locations_agreements", + label: "Conventions", + name: "agreements", + tags: agreements.map(({ id, idcc, name }) => ({ + label: `${idcc} - ${name}`, + value: id, + })), + type: "tags", + }, + ]; + + return { defaultData, fields }; +}; + +const AdminLocationsEditPage = withAdminEdit( + { + apiPath: "/locations", + i18nIsFeminine: true, + i18nSubject: "unité", + indexPath: "/locations", + name: "locations", + }, + componentDidMount, +); + +export default AdminLocationsEditPage; diff --git a/targets/contributions/pages/admin/locations/index.js b/targets/contributions/pages/admin/locations/index.js new file mode 100644 index 000000000..a423b5fb9 --- /dev/null +++ b/targets/contributions/pages/admin/locations/index.js @@ -0,0 +1,16 @@ +import React from "react"; + +import AdminIndex from "../../../src/components/AdminIndex"; + +const COLUMNS = [ + { + Header: "Nom", + accessor: "name", + }, +]; + +const AdminLocationsIndexPage = () => ( + +); + +export default AdminLocationsIndexPage; diff --git a/targets/contributions/pages/admin/locations/new.js b/targets/contributions/pages/admin/locations/new.js new file mode 100644 index 000000000..cda82fbf3 --- /dev/null +++ b/targets/contributions/pages/admin/locations/new.js @@ -0,0 +1,43 @@ +import withAdminNew from "../../../src/templates/withAdminNew"; + +export const FIELDS = [ + { + label: "Nom", + name: "name", + type: "input", + }, +]; + +const componentDidMount = async api => { + const { data: agreements } = await api.get("/agreements"); + + const fields = [ + ...FIELDS, + { + apiPath: "/locations_agreements", + ariaName: "la convention", + label: "Conventions", + name: "agreements", + tags: agreements.map(({ id, idcc, name }) => ({ + label: `${idcc} - ${name}`, + value: id, + })), + type: "tags", + }, + ]; + + return { fields }; +}; + +const AdminLocationsNewPage = withAdminNew( + { + apiPath: "/locations", + i18nIsFeminine: true, + i18nSubject: "unité", + indexPath: "/locations", + name: "locations", + }, + componentDidMount, +); + +export default AdminLocationsNewPage; diff --git a/targets/contributions/pages/admin/logs/index.js b/targets/contributions/pages/admin/logs/index.js new file mode 100644 index 000000000..12e2c3da8 --- /dev/null +++ b/targets/contributions/pages/admin/logs/index.js @@ -0,0 +1,97 @@ +import React from "react"; +import { connect } from "react-redux"; + +import * as actions from "../../../src/actions"; +import { Container, Head } from "../../../src/components/AdminIndex/styles"; +import Button from "../../../src/elements/Button"; +import Table from "../../../src/elements/Table"; +import Title from "../../../src/elements/Title"; +import capitalize from "../../../src/helpers/capitalize"; +import humanizeLogAction from "../../../src/helpers/humanizeLogAction"; +import AdminMainLayout from "../../../src/layouts/AdminMain"; +import moment from "../../../src/libs/customMoment"; + +// TODO Clean these columns. +/* eslint-disable react/display-name */ +const COLUMNS = [ + { + Cell: ({ value }) => (value !== null ? value.name : "N/A"), + Header: "Nom", + accessor: "user", + }, + { + Cell: ({ value }) => (value !== null ? capitalize(value.role) : "N/A"), + Header: "Role", + accessor: "user", + }, + { + Header: "IP", + accessor: "ip", + }, + { + Cell: ({ original: { method, path } }) => humanizeLogAction(method, path), + Header: "Action", + accessor: "method", + }, + { + Header: "Chemin", + accessor: "path", + }, + { + Cell: ({ value }) => (value !== null ? {value} : "N/A"), + Header: "Corps", + accessor: "body", + }, + { + Cell: ({ value }) => moment(value).format("L HH:mm:ss"), + Header: "Date", + accessor: "created_at", + }, +]; +/* eslint-enable react/display-name */ + +class AdminLogsIndexPage extends React.Component { + componentDidMount() { + this.props.dispatch(actions.logs.load({ pageIndex: -1 })); + } + + deleteOlderThanOneWeek() { + this.props.dispatch(actions.logs.deleteOlderThanOneWeek()); + } + + render() { + const { logs } = this.props; + + return ( + + + + Logs + + +
    + + + ); + } +} + +export default connect(({ logs }) => ({ + logs, +}))(AdminLogsIndexPage); diff --git a/targets/contributions/pages/admin/migrations/edit.js b/targets/contributions/pages/admin/migrations/edit.js new file mode 100644 index 000000000..b132e7d36 --- /dev/null +++ b/targets/contributions/pages/admin/migrations/edit.js @@ -0,0 +1,55 @@ +import React from "react"; + +import AdminForm from "../../../src/components/AdminForm"; +import AdminMainLayout from "../../../src/layouts/AdminMain"; +import api from "../../../src/libs/api"; + +const FIELDS = [ + { + label: "Nom", + name: "name", + type: "input", + }, +]; + +export default class AdminQuestionsEditPage extends React.Component { + constructor(props) { + super(props); + + this.state = { + isLoading: true, + }; + } + + async componentDidMount() { + try { + const uri = `/administrator_migrations?id=eq.${this.props.id}`; + + const migrations = await api.get(uri); + + this.setState({ + data: migrations[0], + isLoading: false, + }); + } catch (err) { + if (err !== undefined) console.warn(err); + } + } + + render() { + if (this.state.isLoading) return ; + + return ( + + ); + } +} diff --git a/targets/contributions/pages/admin/migrations/index.js b/targets/contributions/pages/admin/migrations/index.js new file mode 100644 index 000000000..2532216bf --- /dev/null +++ b/targets/contributions/pages/admin/migrations/index.js @@ -0,0 +1,38 @@ +import moment from "moment-timezone"; +import React from "react"; + +import AdminIndex from "../../../src/components/AdminIndex"; + +const COLUMNS = [ + { + Header: "Index", + accessor: "id", + }, + { + Header: "Nom", + accessor: "name", + }, + { + Header: "Créé le", + accessor: data => moment(data.migration_time).tz("Europe/Paris").format("YYYY-MM-DD HH:mm"), + filterable: false, + id: "createdAt", + style: { textAlign: "center" }, + width: 160, + }, +]; + +const AdminMigrationsIndexPage = () => ( + +); + +export default AdminMigrationsIndexPage; diff --git a/targets/contributions/pages/admin/questions/edit.js b/targets/contributions/pages/admin/questions/edit.js new file mode 100644 index 000000000..3b2206282 --- /dev/null +++ b/targets/contributions/pages/admin/questions/edit.js @@ -0,0 +1,36 @@ +import withAdminEdit from "../../../src/templates/withAdminEdit"; + +const FIELDS = [ + { + inputType: "number", + label: "Index", + name: "index", + type: "input", + }, + { + label: "Intitulé", + name: "value", + type: "text", + }, +]; + +const componentDidMount = async (api, id) => { + const questions = await api.fetchAll("/questions", { id }); + + return { + defaultData: questions[0], + fields: FIELDS, + }; +}; + +const AdminDefinitionsEditPage = withAdminEdit( + { + apiPath: "/questions", + i18nIsFeminine: true, + i18nSubject: "question", + indexPath: "/questions", + }, + componentDidMount, +); + +export default AdminDefinitionsEditPage; diff --git a/targets/contributions/pages/admin/questions/index.js b/targets/contributions/pages/admin/questions/index.js new file mode 100644 index 000000000..0d19e95cb --- /dev/null +++ b/targets/contributions/pages/admin/questions/index.js @@ -0,0 +1,22 @@ +import React from "react"; + +import AdminIndex from "../../../src/components/AdminIndex"; + +const COLUMNS = [ + { + Header: "Index", + accessor: "index", + style: { textAlign: "right" }, + width: 80, + }, + { + Header: "Intitulé", + accessor: "value", + }, +]; + +const QuestionsIndexPage = () => ( + +); + +export default QuestionsIndexPage; diff --git a/targets/contributions/pages/admin/questions/new.jsx b/targets/contributions/pages/admin/questions/new.jsx new file mode 100644 index 000000000..0960e140c --- /dev/null +++ b/targets/contributions/pages/admin/questions/new.jsx @@ -0,0 +1,61 @@ +import { useRouter } from "next/router"; +import React from "react"; + +import { addQuestion } from "../../../src/api"; +import AdminForm from "../../../src/components/AdminForm"; + +const FIELDS = [ + { + inputType: "number", + label: "Index", + name: "index", + type: "input", + }, + { + label: "Intitulé", + name: "value", + type: "text", + }, +]; + +export default function AdminNew() { + const { back } = useRouter(); + const [isSubmitting, submitting] = React.useState(false); + const [error, setError] = React.useState(); + + const onSubmit = async data => { + try { + submitting(true); + setError(null); + await addQuestion(data.index, data.value); + back(); + } catch (err) { + if ( + err !== undefined && + err.response !== undefined && + err.response.data !== undefined && + typeof err.response.data.message === "string" + ) { + setError(`Erreur: ${err.response.data.message}.`); + } else { + setError(`Erreur: ${err}`); + if (err !== undefined) console.warn(err); + } + } finally { + submitting(false); + } + }; + + return ( + + ); +} diff --git a/targets/contributions/pages/admin/tracker/index.js b/targets/contributions/pages/admin/tracker/index.js new file mode 100644 index 000000000..094fcbe0e --- /dev/null +++ b/targets/contributions/pages/admin/tracker/index.js @@ -0,0 +1,193 @@ +import styled from "@emotion/styled"; +import debounce from "lodash.debounce"; +import React from "react"; +import ReactDiffViewer from "react-diff-viewer"; +import { connect } from "react-redux"; +import { Flex } from "rebass"; + +import * as actions from "../../../src/actions"; +import Tree from "../../../src/components/Tree"; +import Button from "../../../src/elements/Button"; +import Icon from "../../../src/elements/Icon"; +import LoadingSpinner from "../../../src/elements/LoadingSpinner"; +import MarkdownEditor from "../../../src/elements/MarkdownEditor"; +import Subtitle from "../../../src/elements/Subtitle"; +import Title from "../../../src/elements/Title"; +import AdminMainLayout from "../../../src/layouts/AdminMain"; +import api from "../../../src/libs/api"; + +const Container = styled(Flex)` + margin: 0 1rem 1rem; +`; + +const TreeContainer = styled(Flex)` + max-width: 20rem; + min-width: 20rem; +`; + +const Editor = styled(MarkdownEditor)` + max-height: 100%; + min-height: 100%; + + .editor { + border: 0 !important; + } +`; + +export class AdminTrackerIndexPage extends React.Component { + constructor(props) { + super(props); + + this.selectAnswer = this.selectAnswer.bind(this); + this.updateAnswerValue = debounce(this._updateAnswerValue.bind(this), 500).bind(this); + + this.state = { + selectedReferenceDiff: null, + }; + } + + componentDidMount() { + this.props.dispatch(actions.answers.toggleIsLoading()); + this.props.dispatch(actions.alerts.load()); + } + + selectAnswer({ key }) { + this.props.dispatch(actions.alerts.selectOne(key)); + } + + async _updateAnswerValue({ source }) { + try { + const { + answers: { + data: { id }, + }, + } = this.props; + const value = source.trim(); + const uri = `/answers?id=eq.${id}`; + const data = { value }; + + await api.patch(uri, data); + } catch (err) { + console.warn(err); + } + } + + openAnswerInNewTab(id) { + window.open(`/admin/answers/${id}`, "_blank"); + } + + processAnswer() { + this.props.dispatch(actions.alerts.processOne()); + } + + renderDiff() { + const { + alerts: { + diff: { etat, texts }, + }, + } = this.props; + + return ( + + + État : {etat.previous} ► {etat.current} + + {texts.length !== 0 && ( + + )} + + ); + } + + renderTree() { + const { alerts } = this.props; + const { isLoading, selectedKey, tree } = alerts; + + if (isLoading) { + return ( + + + + ); + } + + return ( + + Tableau de veille + + + ); + } + + renderEditor() { + const { + alerts: { isLoading: alertsIsLoading }, + answers: { data: answersData, isLoading: answersIsLoading }, + } = this.props; + + if (alertsIsLoading || answersIsLoading) { + return ( + + + + ); + } + + if (answersData === null) { + return ( +

    + + Sélectionnez une réponse parmi les alertes pour la traiter. +

    + ); + } + + const { id, value } = answersData; + + return ( + + + {this.renderDiff()} + + + + + + + + + + ); + } + + render() { + return ( + + + {this.renderTree()} + {this.renderEditor()} + + + ); + } +} + +export default connect(({ alerts, answers, legalReferences }) => ({ + alerts, + answers, + legalReferences, +}))(AdminTrackerIndexPage); diff --git a/targets/contributions/pages/admin/users/edit.js b/targets/contributions/pages/admin/users/edit.js new file mode 100644 index 000000000..6836b649d --- /dev/null +++ b/targets/contributions/pages/admin/users/edit.js @@ -0,0 +1,54 @@ +import withAdminEdit from "../../../src/templates/withAdminEdit"; +import { FIELDS } from "./new"; + +const componentDidMount = async (api, id) => { + const { data: users } = await api.eq("id", id).get("/administrator_users"); + const { data: agreements } = await api.get("/agreements"); + const { data: locations } = await api.get("/locations"); + + const defaultData = { + ...users[0], + agreements: users[0].agreements.map(agreement => ({ + id: agreement.id, + value: `${agreement.idcc} - ${agreement.name}`, + })), + }; + + const fields = [ + ...FIELDS, + { + label: "Unité", + name: "location_id", + options: locations.map(({ id: value, name: label }) => ({ + label, + value, + })), + type: "select", + }, + { + label: "Conventions", + name: "agreements", + tags: agreements.map(({ id, idcc, name }) => ({ + label: `${idcc} - ${name}`, + value: id, + })), + type: "tags", + }, + ]; + + return { + defaultData, + fields, + }; +}; + +const AdminUsersEditPage = withAdminEdit( + { + apiPath: "/rpc/update_user", + i18nSubject: "utilisateur", + indexPath: "/users", + }, + componentDidMount, +); + +export default AdminUsersEditPage; diff --git a/targets/contributions/pages/admin/users/index.js b/targets/contributions/pages/admin/users/index.js new file mode 100644 index 000000000..234e1796f --- /dev/null +++ b/targets/contributions/pages/admin/users/index.js @@ -0,0 +1,30 @@ +import React from "react"; + +import AdminIndex from "../../../src/components/AdminIndex"; + +const COLUMNS = [ + { + Header: "Nom", + accessor: "name", + }, + { + Header: "E-mail", + accessor: "email", + }, + { + Header: "Rôle", + accessor: "role", + }, + { + Header: "Conventions", + accessor: data => data.agreements.map(({ idcc }) => idcc).join(", "), + id: "idccs", + width: 160, + }, +]; + +const AdminUsersIndexPage = () => ( + +); + +export default AdminUsersIndexPage; diff --git a/targets/contributions/pages/admin/users/new.js b/targets/contributions/pages/admin/users/new.js new file mode 100644 index 000000000..669368dc3 --- /dev/null +++ b/targets/contributions/pages/admin/users/new.js @@ -0,0 +1,83 @@ +import generatePassword from "../../../src/libs/generatePassword"; +import withAdminNew from "../../../src/templates/withAdminNew"; + +const PASSWORD_LENGTH = 16; + +export const FIELDS = [ + { + label: "Nom", + name: "name", + type: "input", + }, + { + label: "Rôle", + name: "role", + options: [ + { label: "Administrateur", value: "administrator" }, + { label: "Administrateur régional", value: "regional_administrator" }, + { label: "Contributeur", value: "contributor" }, + ], + type: "select", + }, + { + inputType: "email", + label: "E-mail", + name: "email", + type: "input", + }, + { + button: { + handler: () => generatePassword(PASSWORD_LENGTH), + icon: "sync", + title: "Bouton générant un mot de passe aléatoire", + }, + label: "Mot-de-passe", + name: "password", + type: "input", + }, +]; + +const componentDidMount = async api => { + const { data: agreements } = await api.get("/agreements"); + const { data: locations } = await api.get("/locations"); + + const defaultData = { + password: generatePassword(PASSWORD_LENGTH), + }; + + const fields = [ + ...FIELDS, + { + label: "Unité", + name: "location_id", + options: locations.map(({ id: value, name: label }) => ({ + label, + value, + })), + type: "select", + }, + { + ariaName: "la convention", + label: "Conventions", + name: "agreements", + tags: agreements.map(({ id, idcc, name }) => ({ + label: `${idcc} - ${name}`, + value: id, + })), + type: "tags", + }, + ]; + + return { defaultData, fields }; +}; + +const AdminUsersNewPage = withAdminNew( + { + apiPath: "/rpc/create_user", + i18nSubject: "utilisateur", + indexPath: "/users", + }, + componentDidMount, +); + +export default AdminUsersNewPage; diff --git a/targets/contributions/pages/answers/edit.js b/targets/contributions/pages/answers/edit.js new file mode 100644 index 000000000..4a289fb1a --- /dev/null +++ b/targets/contributions/pages/answers/edit.js @@ -0,0 +1,293 @@ +import styled from "@emotion/styled"; +import debounce from "lodash.debounce"; +import Router from "next/router"; +import React from "react"; +import { connect } from "react-redux"; +import { Flex } from "rebass"; + +import * as actions from "../../src/actions"; +import AnswerEditionContentBlock from "../../src/blocks/AnswerEditionContent"; +import AnswerEditionHeadBlock from "../../src/blocks/AnswerEditionHead"; +import { TABS } from "../../src/blocks/AnswerEditionHead/Tabs"; +import AnswerEditionReferencesBlock from "../../src/blocks/AnswerEditionReferences"; +import { ANSWER_STATE } from "../../src/constants"; +import SavingSpinner from "../../src/elements/SavingSpinner"; +import Main from "../../src/layouts/Main"; +import { api } from "../../src/libs/GraphQLApi"; +import getCurrentUser from "../../src/libs/getCurrentUser"; +import makeApiFilter from "../../src/libs/makeApiFilter"; +import { + getAnswerReferences, + updateAnswerReference, + updateAnswersStates, +} from "../../src/libs/graphql"; + +const Container = styled(Main)` + overflow-x: hidden; +`; +const Content = styled(Flex)` + flex-grow: 1; + overflow-y: auto; +`; +const ContentInfo = styled(Flex)` + color: var(--color-shadow); + font-size: 1rem; + margin-top: 0.5rem; + position: absolute; + right: 1rem; + width: 12.5rem; +`; + +class AnswersEditPage extends React.Component { + constructor(props) { + super(props); + + this.state = { + currentTab: TABS.EDITOR, + hasSavingSpinner: false, + isLoading: true, + isSaving: false, + me: null, + references: [], + savingSpinnerTimeout: 0, + }; + + this.prevalue = null; + this.newPrevalue = null; + + this.createReference = debounce(this._createReference.bind(this), 500); + this.deleteReference = debounce(this._deleteReference.bind(this), 500); + this.updatePrevalue = debounce(this._updatePrevalue.bind(this), 500); + } + + async componentDidMount() { + const { id } = this.props; + const me = getCurrentUser(); + + this.load(); + + try { + const references = await api.fetch(getAnswerReferences, { + answer_id: id, + }); + + this.setState({ + isLoading: false, + me, + references, + }); + } catch (err) { + console.warn(err); + } + } + + componentDidUpdate() { + if (!this.props.answers.isLoading) { + if (this.prevalue === null) { + this.prevalue = this.props.answers.data.prevalue; + this.newPrevalue = this.props.answers.data.prevalue; + this.forceUpdate(); + } + } + } + + load() { + const { dispatch, id } = this.props; + + dispatch(actions.answers.loadOne(id)); + } + + async cancel() { + if (this.state.isSaving) return; + + this.props.dispatch( + actions.modal.open( + `Êtes-vous sûr d'annuler cette réponse (son contenu sera supprimé) ?`, + () => + actions.answers.cancel([this.props.id], () => + Router.push("/answers/draft/1") + ) + ) + ); + } + + async requestForAnswerValidation() { + if (this.state.isSaving) return; + + this.props.dispatch( + actions.modal.open( + `Êtes-vous sûr d'envoyer cette réponse en validation ?`, + () => + actions.answers.updateState( + [this.props.id], + ANSWER_STATE.PENDING_REVIEW, + () => Router.push("/answers/draft/1") + ) + ) + ); + } + + async _updatePrevalue(value) { + this.setState({ isSaving: true }); + this.showSavingSpinner(); + + try { + const data = { + prevalue: value, + state: "draft", + user_id: this.state.me.id, + }; + + await api.update(updateAnswersStates, { data, ids: [this.props.id] }); + this.newPrevalue = value; + } catch (err) { + console.warn(err); + } + + this.setState({ isSaving: false }); + } + + async _createReference(reference) { + this.setState({ isSaving: true }); + + try { + const answersData = { + state: "draft", + user_id: this.state.me.id, + }; + const answersReferencesData = { + answer_id: this.props.id, + ...reference, + }; + + await api.update(updateAnswersStates, { + data: answersData, + ids: [this.props.id], + }); + + const { id, ...data } = answersReferencesData; + await api.update(updateAnswerReference, { id, data }); + } catch (err) { + console.warn(err); + } + + this.setState({ + isSaving: false, + references: [...this.state.references, reference], + }); + } + + async _deleteReference(_value) { + this.setState({ isSaving: true }); + + try { + const uri = makeApiFilter("/answers_references", { + answer_id: this.props.id, + value: _value, + }); + + // TODO await api.delete(deleteAnswerReference, {}) + await api.delete(uri); + } catch (err) { + console.warn(err); + } + + this.setState({ + isSaving: false, + references: this.state.references.filter(({ value }) => value !== _value), + }); + } + + showSavingSpinner() { + if (this.state.hasSavingSpinner) { + clearTimeout(this.state.savingSpinnerTimeout); + } + + this.setState({ + hasSavingSpinner: true, + savingSpinnerTimeout: setTimeout( + () => + this.setState({ + hasSavingSpinner: false, + savingSpinnerTimeout: 0, + }), + 2000 + ), + }); + } + + switchTab(nextTab) { + if (nextTab === this.state.currentTab) return; + + if (nextTab === TABS.EDITOR) { + this.prevalue = this.newPrevalue; + } + + this.setState({ currentTab: nextTab }); + } + + renderTab() { + const { prevalue } = this; + const { references } = this.state; + + switch (this.state.currentTab) { + case TABS.REFERENCES: + return ( + + ); + + case TABS.EDITOR: + default: + return ( + + ); + } + } + + render() { + const { prevalue } = this; + const { answers } = this.props; + const { isLoading, references } = this.state; + + if (isLoading || answers.isLoading || prevalue === null) { + return
    ; + } + + const { agreement, question } = answers.data; + + return ( + + this.cancel()} + onSubmit={() => this.requestForAnswerValidation()} + onTabChange={this.switchTab.bind(this)} + question={question} + referencesCount={references.length} + /> + + {this.state.hasSavingSpinner && ( + + + Sauvegarde en cours… + + )} + + {this.renderTab()} + + + ); + } +} + +export default connect(({ answers }) => ({ answers }))(AnswersEditPage); diff --git a/targets/contributions/pages/answers/index.js b/targets/contributions/pages/answers/index.js new file mode 100644 index 000000000..491bbb9a0 --- /dev/null +++ b/targets/contributions/pages/answers/index.js @@ -0,0 +1,219 @@ +import styled from "@emotion/styled"; +import debounce from "lodash.debounce"; +import Router, { withRouter } from "next/router"; +import React from "react"; +import { connect } from "react-redux"; +import { Flex } from "rebass"; + +import * as actions from "../../src/actions"; +import AnswerBlock from "../../src/blocks/Answer"; +import { ANSWER_STATE } from "../../src/constants"; +import Input from "../../src/elements/Input"; +import Subtitle from "../../src/elements/Subtitle"; +import Main from "../../src/layouts/Main"; +import T from "../../src/texts"; + +const Content = styled(Flex)` + flex-grow: 1; + padding: 0 1rem 1rem; +`; +const List = styled(Flex)` + flex-grow: 1; + min-height: 0; + overflow-y: auto; +`; + +const Text = styled.p` + margin-bottom: 0.5rem; +`; +const InfoText = styled(Text)` + color: var(--color-dark-slate-gray); +`; +const ErrorText = styled(Text)` + color: var(--color-text-red); + font-weight: 600; +`; +const HelpText = styled(Text)` + font-size: 0.875rem; +`; + +const FilterInputContainer = styled.div` + margin-right: 13rem; +`; +const FilterInput = styled(Input)` + margin: 0.5rem 0; +`; + +class AnswersIndexPage extends React.Component { + get query() { + return this.$query !== undefined && this.$query !== null ? this.$query.value : ""; + } + + constructor(props) { + super(props); + + this.state = { + me: null, + }; + + this.load = debounce(this._load.bind(this), 500); + } + + componentDidMount() { + this.load(); + } + + componentDidUpdate(prevProps) { + const { + router: { + query: { page: prevPage, state: prevState }, + }, + } = prevProps; + const { + router: { + query: { page, state }, + }, + } = this.props; + + if (page !== prevPage || state !== prevState) { + this.load(); + } + } + + _load() { + const { + router: { + query: { page, state }, + }, + } = this.props; + + const pageIndex = Number(page) - 1; + const states = + state === ANSWER_STATE.UNDER_REVIEW + ? [ANSWER_STATE.PENDING_REVIEW, ANSWER_STATE.UNDER_REVIEW] + : [state]; + + const meta = { + pageIndex, + query: this.query, + states, + }; + + this.props.dispatch(actions.answers.load(meta)); + } + + goToPage({ selected }) { + const { + router: { + query: { state }, + }, + } = this.props; + + const href = `/answers?state=${state}&page=${selected + 1}`; + const as = `/answers/${state}/${selected + 1}`; + Router.push(href, as, { shallow: true }); + } + + cancel(id) { + const action = () => actions.answers.cancel([id], this.load.bind(this)); + + this.props.dispatch(actions.modal.open(T.ANSWERS_INDEX_MODAL_CANCEL, action)); + } + + updateGenericReference(id, genericReference) { + this.props.dispatch( + actions.answers.updateGenericReference([id], genericReference, this.load.bind(this)), + ); + } + + openAnswer(id) { + const { + router: { + query: { state }, + }, + } = this.props; + + if ([ANSWER_STATE.TO_DO, ANSWER_STATE.DRAFT].includes(state)) { + Router.push(`/answers/edit/${id}`); + + return; + } + + Router.push(`/answers/view/${id}`); + } + + renderAnswers() { + const { + answers, + router: { + query: { state }, + }, + } = this.props; + const { list, error } = answers; + + if (error !== null) { + return {error}; + } + + if (list.length === 0) { + if (this.query.length !== 0) { + return {T.ANSWERS_INDEX_INFO_NO_SEARCH_RESULT}; + } + + return {T.ANSWERS_INDEX_INFO_NO_DATA(state)}; + } + + return list.map(answer => [ + , + ]); + } + + render() { + const { + answers, + router: { + query: { state }, + }, + } = this.props; + const { isLoading, list } = answers; + + return ( +
    + + {T.ANSWERS_INDEX_TITLE(state)} + + this.load()} + placeholder={T.ANSWERS_INDEX_SEARCH_PLACEHOLDER} + ref={node => (this.$query = node)} + /> + + {isLoading && ( + + Chargement… + + )} + {!isLoading && ( + + {state === ANSWER_STATE.TO_DO && list.length !== 0 && ( + {T.ANSWERS_INDEX_HELP_TO_DO} + )} + {this.renderAnswers()} + + )} + +
    + ); + } +} + +export default connect(({ answers }) => ({ + answers, +}))(withRouter(AnswersIndexPage)); diff --git a/targets/contributions/pages/answers/view.js b/targets/contributions/pages/answers/view.js new file mode 100644 index 000000000..f2f34f5e1 --- /dev/null +++ b/targets/contributions/pages/answers/view.js @@ -0,0 +1,230 @@ +import styled from "@emotion/styled"; +import React from "react"; +import Medixtor from "react-medixtor"; +import { connect } from "react-redux"; +import { Flex } from "rebass"; + +import * as actions from "../../src/actions"; +import LegalReferences from "../../src/components/LegalReferences"; +import { ANSWER_STATE } from "../../src/constants"; +import Hr from "../../src/elements/Hr"; +import Idcc from "../../src/elements/Idcc"; +import Subtitle from "../../src/elements/Subtitle"; +import Title from "../../src/elements/Title"; +import Main from "../../src/layouts/Main"; +import { api } from "../../src/libs/GraphQLApi"; +import { getAnswerReferences } from "../../src/libs/graphql"; + +const Container = styled(Main)` + overflow-y: auto; + padding: 1rem; +`; + +const AnswerEditor = styled(Medixtor)` + border: solid 1px var(--color-border) !important; + border-radius: 0.25rem; + flex-grow: unset; + min-height: 15rem; + + .editor { + background-color: white; + } + .editor-status { + display: none; + } + + .preview { + background-color: white; + border-bottom-right-radius: 0.25rem; + border-top-right-radius: 0.25rem; + line-height: 1.4; + min-width: 100% !important; + overflow-y: auto; + padding-top: 0 !important; + width: 100% !important; + + a { + color: #0053b3; + + :after { + content: ""; + position: relative; + top: 1px; + display: inline-block; + width: 15px; + height: 15px; + margin-left: 5px; + background: url("/static/assets/icons/external-link.svg") 100% 50% / + 15px no-repeat; + } + + :hover { + text-decoration: none; + } + } + + h2, + h3, + h4, + h5, + h6 { + color: #006ab2; + } + h2 { + font-size: 1.875rem; + } + h3 { + font-size: 1.625rem; + } + h4 { + font-size: 1.375rem; + } + h5 { + font-size: 1.125rem; + } + h6 { + font-size: 1rem; + } + + p { + color: #434956; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0; + margin-inline-end: 0; + } + + ul { + list-style-type: disc; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0px; + margin-inline-end: 0px; + padding-inline-start: 20px; + + li { + display: list-item; + } + + ul { + list-style-type: circle; + } + } + } +`; + +const Strong = styled.p` + font-weight: 600; + margin: ${(props) => (props.isFirst ? "0 0 0.5rem" : "1rem 0 0.5rem")}; +`; + +class AnswersViewPage extends React.Component { + constructor(props) { + super(props); + + this.state = { + isLoading: true, + references: [], + }; + } + + async componentDidMount() { + this.load(); + await this.loadReferences(); + } + + load() { + const { dispatch, id } = this.props; + + dispatch(actions.answers.loadOne(id)); + } + + async loadReferences() { + try { + const references = await api.fetch(getAnswerReferences, { + answer_id: this.props.id, + }); + + this.setState({ + isLoading: false, + references, + }); + } catch (err) { + if (err !== undefined) console.warn(err); + } + } + + renderReferences(category = null) { + const references = this.state.references.filter( + ({ category: _category }) => _category === category + ); + + return ( + + ); + } + + render() { + const { answers } = this.props; + const { isLoading } = this.state; + + if (isLoading || answers.isLoading) { + return
    ; + } + + const { + agreement, + generic_reference, + prevalue, + question, + state, + value, + } = answers.data; + + const finalValue = state === ANSWER_STATE.VALIDATED ? value : prevalue; + const valueTitle = + state === ANSWER_STATE.VALIDATED ? "Réponse validée" : "Réponse proposée"; + + return ( + + + {this.isGeneric ? ( + + ) : ( + + )} + {`${question.index}) ${question.value}`} + +
    + + {valueTitle} + + + Références juridiques + Convention collective + {this.renderReferences("agreement")} + Code du travail + {this.renderReferences("labor_code")} + Autres + {this.renderReferences()} +
    + + Renvoi + { + { + labor_code: "Renvoyée au texte Code du Travail.", + national_agreement: "Renvoyée au texte de la CCN.", + null: "Aucun renvoi.", + }[String(generic_reference)] + } +
    + ); + } +} + +export default connect(({ answers }) => ({ answers }))(AnswersViewPage); diff --git a/targets/contributions/pages/api/graphql.js b/targets/contributions/pages/api/graphql.js new file mode 100644 index 000000000..e8c907512 --- /dev/null +++ b/targets/contributions/pages/api/graphql.js @@ -0,0 +1,28 @@ +import { createProxyMiddleware } from "http-proxy-middleware"; + +export const config = { + api: { + bodyParser: false, + }, + externalResolver: true, +}; + +const proxy = createProxyMiddleware({ + changeOrigin: true, + followRedirects: true, + logLevel: "debug", + onError: (err, req, res) => { + res.writeHead(500, { + "Content-Type": "text/plain", + }); + // todo: sentry + res.end("Something went wrong. We've been notified."); + }, + pathRewrite: { "^/api/graphql": "/v1/graphql" }, + prependPath: false, + target: process.env.HASURA_GRAPHQL_ENDPOINT, + ws: true, + xfwd: true, // proxy websockets +}); + +export default proxy; diff --git a/targets/contributions/pages/charter.js b/targets/contributions/pages/charter.js new file mode 100644 index 000000000..c184aa952 --- /dev/null +++ b/targets/contributions/pages/charter.js @@ -0,0 +1,38 @@ +// import { Flex } from "rebass"; +import styled from "@emotion/styled"; +import React from "react"; + +import Main from "../src/layouts/Main"; + +const IFrame = styled.iframe` + flex-grow: 1; +`; + +export default class CharterPage extends React.Component { + constructor(props) { + super(props); + + this.state = { + isLoading: true, + }; + } + + componentDidMount() { + this.setState({ isLoading: false }); + } + + render() { + if (this.state.isLoading) return
    ; + + return ( +
    +